import { ConflictException, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AuditAction } from '../audit/audit.types'; import { randomBytes } from 'crypto'; export interface GroupSummary { id: string; name: string; platform: string; platformId: string; isActive: boolean; accountId: string | null; tenantId: string | null; } const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; @Injectable() export class GroupsService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} list(tenantId: string): Promise { return this.prisma.group.findMany({ where: { OR: [ { tenantId }, { groupAccesses: { some: { tenantId } } }, ], }, orderBy: { name: 'asc' }, select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true, tenantId: true, }, }); } async listUnclaimed(): Promise { return this.prisma.group.findMany({ where: { tenantId: null }, orderBy: { name: 'asc' }, select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true, tenantId: true, }, }); } async listShared(tenantId: string): Promise<(GroupSummary & { sharedByTenantName: string })[]> { const accesses = await this.prisma.groupAccess.findMany({ where: { tenantId }, include: { group: { select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true, tenantId: true, }, }, }, }); const ownerTenantIds: string[] = [...new Set(accesses.map((a) => a.group.tenantId).filter((id): id is string => !!id))]; const tenants = ownerTenantIds.length > 0 ? await this.prisma.tenant.findMany({ where: { id: { in: ownerTenantIds } }, select: { id: true, name: true }, }) : []; const tenantMap = new Map(tenants.map((t) => [t.id, t.name])); return accesses.map((a) => ({ ...a.group, sharedByTenantName: a.group.tenantId ? tenantMap.get(a.group.tenantId) ?? 'Unknown' : 'Unknown', })); } async listSharedByMe(tenantId: string) { const accesses = await this.prisma.groupAccess.findMany({ where: { group: { tenantId } }, include: { group: { select: { id: true, name: true } }, tenant: { select: { id: true, name: true } }, }, orderBy: { createdAt: 'desc' }, }); // Group by group const grouped = new Map(); for (const a of accesses) { const key = a.group.id; if (!grouped.has(key)) { grouped.set(key, { groupId: a.group.id, groupName: a.group.name, sharedWith: [] }); } grouped.get(key)!.sharedWith.push({ tenantId: a.tenantId, tenantName: a.tenant.name, grantedAt: a.createdAt, }); } return [...grouped.values()]; } async getClaimTokenInfo(token: string) { const record = await this.prisma.groupClaimToken.findUnique({ where: { token }, include: { group: { select: { name: true } } }, }); if (!record) throw new NotFoundException('Invalid token'); return { groupName: record.group.name, expiresAt: record.expiresAt.toISOString(), isConsumed: record.consumedAt !== null, isExpired: record.expiresAt < new Date(), }; } async claimWithToken(token: string, adminId: string): Promise { const admin = await this.prisma.admin.findUnique({ where: { id: adminId }, select: { tenantId: true }, }); if (!admin) throw new NotFoundException('Admin not found'); const record = await this.prisma.groupClaimToken.findUnique({ where: { token }, }); if (!record) throw new NotFoundException('Invalid token'); if (record.consumedAt) throw new ConflictException('Token has already been used'); if (record.expiresAt < new Date()) throw new ConflictException('Token has expired'); const group = await this.prisma.group.findUnique({ where: { id: record.groupId }, }); if (!group) throw new NotFoundException('Group not found'); if (group.tenantId) throw new ConflictException('Group is already claimed'); // Account-binding: ensure the claiming tenant has a TenantBot link if (group.accountId) { const myLink = await this.prisma.tenantBot.findUnique({ where: { tenantId_accountId: { tenantId: admin.tenantId, accountId: group.accountId } }, }); if (!myLink) { const anyLinks = await this.prisma.tenantBot.count({ where: { accountId: group.accountId }, }); if (anyLinks === 0) { throw new ConflictException('Bot account has no tenant binding — cannot claim'); } await this.prisma.tenantBot.create({ data: { tenantId: admin.tenantId, accountId: group.accountId, isActive: true }, }); await this.audit.log({ tenantId: admin.tenantId, actorId: adminId, action: AuditAction.BOT_ACCESS_GRANTED, resourceType: 'Account', resourceId: group.accountId, payload: { reason: 'auto-grant on token claim', groupId: group.id }, }); } } const [updated] = await this.prisma.$transaction([ this.prisma.group.update({ where: { id: group.id }, data: { tenantId: admin.tenantId, claimStatus: 'CLAIMED' }, select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true, tenantId: true, }, }), this.prisma.groupClaimToken.update({ where: { id: record.id }, data: { consumedAt: new Date() }, }), ]); await this.audit.log({ tenantId: admin.tenantId, actorId: adminId, action: AuditAction.GROUP_CLAIMED_WITH_TOKEN, resourceType: 'Group', resourceId: group.id, payload: { groupName: group.name }, }); return updated; } async share(tenantId: string, adminId: string, groupId: string, targetTenantId: string) { const group = await this.prisma.group.findUnique({ where: { id: groupId } }); if (!group) throw new NotFoundException('Group not found'); if (group.tenantId !== tenantId) throw new NotFoundException('Group not found'); const existing = await this.prisma.groupAccess.findUnique({ where: { groupId_tenantId: { groupId, tenantId: targetTenantId } }, }); if (existing) throw new ConflictException('Group already shared with this tenant'); const access = await this.prisma.groupAccess.create({ data: { groupId, tenantId: targetTenantId, grantedBy: adminId }, }); await this.audit.log({ tenantId, actorId: adminId, action: AuditAction.GROUP_SHARED, resourceType: 'Group', resourceId: groupId, payload: { targetTenantId, groupName: group.name }, }); return access; } async unshare(tenantId: string, groupId: string, targetTenantId: string) { const group = await this.prisma.group.findUnique({ where: { id: groupId } }); if (!group) throw new NotFoundException('Group not found'); if (group.tenantId !== tenantId) throw new NotFoundException('Group not found'); const existing = await this.prisma.groupAccess.findUnique({ where: { groupId_tenantId: { groupId, tenantId: targetTenantId } }, }); if (!existing) throw new NotFoundException('Share not found'); await this.prisma.groupAccess.delete({ where: { groupId_tenantId: { groupId, tenantId: targetTenantId } }, }); await this.audit.log({ tenantId, action: AuditAction.GROUP_UNSHARED, resourceType: 'Group', resourceId: groupId, payload: { targetTenantId }, }); } async regenerateToken(tenantId: string, adminId: string, groupId: string) { const group = await this.prisma.group.findUnique({ where: { id: groupId } }); if (!group) throw new NotFoundException('Group not found'); // Allow regenerate for owned groups OR unclaimed groups (support case) if (group.tenantId && group.tenantId !== tenantId) throw new NotFoundException('Group not found'); const token = randomBytes(32).toString('hex'); const record = await this.prisma.groupClaimToken.create({ data: { groupId, token, creatorJid: token, // placeholder — support will need to extract jid from group metadata expiresAt: new Date(Date.now() + TOKEN_TTL_MS), }, }); await this.audit.log({ tenantId: group.tenantId ?? tenantId, actorId: adminId, action: AuditAction.GROUP_CLAIM_TOKEN_REGENERATED, resourceType: 'Group', resourceId: groupId, payload: { tokenId: record.id }, }); return { token, expiresAt: record.expiresAt.toISOString() }; } }