191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { AuditAction } from '../audit/audit.types';
|
|
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
|
|
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
|
|
|
|
@Injectable()
|
|
export class MyService {
|
|
private readonly logger = new Logger(MyService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
|
|
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
|
if (!user) throw new NotFoundException('User not found');
|
|
return {
|
|
id: user.id,
|
|
tenantId: user.tenantId,
|
|
jid: user.jid,
|
|
displayName: user.displayName,
|
|
createdAt: user.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
|
|
const consents = await this.prisma.consentRecord.findMany({
|
|
where: { userId, tenantId },
|
|
include: { group: true },
|
|
});
|
|
return consents.map((c) => ({
|
|
id: c.group.id,
|
|
name: c.group.name,
|
|
tenantId: c.tenantId,
|
|
scopes: c.scopes as ConsentScope[],
|
|
retentionDays: c.retentionDays,
|
|
policyVersion: c.policyVersion,
|
|
consentStatus: c.status as ConsentStatus,
|
|
joinedAt: c.effectiveAt.toISOString(),
|
|
}));
|
|
}
|
|
|
|
async getGroup(userId: string, tenantId: string, groupId: string): Promise<MemberGroupSummary> {
|
|
const consent = await this.prisma.consentRecord.findFirst({
|
|
where: { userId, tenantId, groupId },
|
|
include: { group: true },
|
|
});
|
|
if (!consent) throw new NotFoundException('Not a member of this group');
|
|
return {
|
|
id: consent.group.id,
|
|
name: consent.group.name,
|
|
tenantId: consent.tenantId,
|
|
scopes: consent.scopes as ConsentScope[],
|
|
retentionDays: consent.retentionDays,
|
|
policyVersion: consent.policyVersion,
|
|
consentStatus: consent.status as ConsentStatus,
|
|
joinedAt: consent.effectiveAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async optOut(
|
|
userId: string,
|
|
tenantId: string,
|
|
body: OptOutRequest,
|
|
): Promise<{ ok: true; revoked: number }> {
|
|
const where = body.groupId
|
|
? { userId, tenantId, groupId: body.groupId }
|
|
: { userId, tenantId };
|
|
const consents = await this.prisma.consentRecord.findMany({ where });
|
|
if (consents.length === 0) {
|
|
throw new NotFoundException('No matching consent records');
|
|
}
|
|
const reason = body.reason ?? MemberOptOutReasonEnum.SELF_PORTAL;
|
|
await this.prisma.$transaction(async (tx) => {
|
|
for (const consent of consents) {
|
|
if (body.scopes && body.scopes.length > 0) {
|
|
const remaining = (consent.scopes as ConsentScope[]).filter((s) => !body.scopes!.includes(s));
|
|
if (remaining.length === 0) {
|
|
await tx.consentRecord.update({
|
|
where: { id: consent.id },
|
|
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
|
|
});
|
|
} else {
|
|
await tx.consentRecord.update({
|
|
where: { id: consent.id },
|
|
data: { scopes: remaining },
|
|
});
|
|
}
|
|
} else {
|
|
await tx.consentRecord.update({
|
|
where: { id: consent.id },
|
|
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
|
|
});
|
|
}
|
|
await tx.memberOptOut.create({
|
|
data: {
|
|
tenantId,
|
|
userId,
|
|
groupId: consent.groupId,
|
|
reason,
|
|
notes: body.notes ?? null,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
await this.audit.log({
|
|
tenantId,
|
|
action: AuditAction.MEMBER_OPT_OUT,
|
|
resourceType: 'TowerUser',
|
|
resourceId: userId,
|
|
payload: { groupId: body.groupId, scopes: body.scopes, reason },
|
|
});
|
|
return { ok: true, revoked: consents.length };
|
|
}
|
|
|
|
async optIn(
|
|
userId: string,
|
|
tenantId: string,
|
|
body: OptInRequest,
|
|
): Promise<{ ok: true; consentId: string }> {
|
|
if (body.scopes.length === 0) {
|
|
throw new BadRequestException('At least one scope is required');
|
|
}
|
|
const group = await this.prisma.group.findFirst({ where: { id: body.groupId, tenantId } });
|
|
if (!group) throw new NotFoundException('Group not found in your tenant');
|
|
|
|
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
|
if (!user) throw new UnauthorizedException('User not found');
|
|
|
|
const existing = await this.prisma.consentRecord.findFirst({
|
|
where: { userId, tenantId, groupId: body.groupId },
|
|
});
|
|
|
|
let consent;
|
|
if (existing) {
|
|
consent = await this.prisma.consentRecord.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
scopes: body.scopes,
|
|
retentionDays: body.retentionDays ?? existing.retentionDays,
|
|
status: ConsentStatus.GRANTED,
|
|
revokedAt: null,
|
|
effectiveAt: new Date(),
|
|
},
|
|
});
|
|
} else {
|
|
consent = await this.prisma.consentRecord.create({
|
|
data: {
|
|
tenantId,
|
|
groupId: body.groupId,
|
|
userId,
|
|
scopes: body.scopes,
|
|
retentionDays: body.retentionDays ?? 90,
|
|
policyVersion: 'v1',
|
|
status: ConsentStatus.GRANTED,
|
|
proofEventId: 'self',
|
|
},
|
|
});
|
|
}
|
|
|
|
await this.audit.log({
|
|
tenantId,
|
|
action: AuditAction.MEMBER_OPT_IN,
|
|
resourceType: 'TowerUser',
|
|
resourceId: userId,
|
|
payload: { groupId: body.groupId, scopes: body.scopes },
|
|
});
|
|
|
|
return { ok: true, consentId: consent.id };
|
|
}
|
|
|
|
async deleteAccount(userId: string, tenantId: string): Promise<{ ok: true }> {
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.consentRecord.deleteMany({ where: { userId, tenantId } });
|
|
await tx.memberOptOut.deleteMany({ where: { userId, tenantId } });
|
|
await tx.towerSession.deleteMany({ where: { userId } });
|
|
await tx.towerUser.delete({ where: { id: userId } });
|
|
});
|
|
await this.audit.log({
|
|
tenantId,
|
|
action: AuditAction.MEMBER_DELETED,
|
|
resourceType: 'TowerUser',
|
|
resourceId: userId,
|
|
});
|
|
return { ok: true };
|
|
}
|
|
}
|