Files
tower/apps/api/src/modules/groups/groups.service.ts
T
2026-06-09 02:02:40 +05:30

268 lines
9.0 KiB
TypeScript

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<GroupSummary[]> {
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<GroupSummary[]> {
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<string, { groupId: string; groupName: string; sharedWith: { tenantId: string; tenantName: string; grantedAt: Date }[] }>();
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<GroupSummary> {
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() };
}
}