268 lines
9.0 KiB
TypeScript
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() };
|
|
}
|
|
}
|