From 497aaa919bce3a83aa498c94c1aee22bbb4604f7 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sat, 1 Aug 2026 21:00:08 +0000 Subject: [PATCH] feat(CC-057): add ConsentsController (grant/revoke/list consent records per purpose) --- src/consents/consents.controller.ts | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/consents/consents.controller.ts diff --git a/src/consents/consents.controller.ts b/src/consents/consents.controller.ts new file mode 100644 index 0000000..19a0960 --- /dev/null +++ b/src/consents/consents.controller.ts @@ -0,0 +1,81 @@ +import { Body, Controller, Get, IsString, Post } from '@nestjs/common'; +import { and, desc, eq, isNull } from 'drizzle-orm'; +import { CurrentSession } from '../auth/session.decorator'; +import type { AuthenticatedSession } from '../auth/tenant.guard'; +import { db } from '../db/client'; +import { consentRecords } from '../db/schema'; + +class GrantConsentDto { + @IsString() purpose!: string; +} + +class RevokeConsentDto { + @IsString() purpose!: string; +} + +@Controller('consents') +export class ConsentsController { + @Get() + async list(@CurrentSession() session: AuthenticatedSession) { + return db.query.consentRecords.findMany({ + where: and( + eq(consentRecords.tenantId, session.tenantId), + eq(consentRecords.userId, session.userId), + ), + orderBy: [desc(consentRecords.grantedAt)], + }); + } + + @Post('grant') + async grant( + @CurrentSession() session: AuthenticatedSession, + @Body() dto: GrantConsentDto, + ) { + // Revoke any previous record for this purpose first + const existing = await db.query.consentRecords.findFirst({ + where: and( + eq(consentRecords.tenantId, session.tenantId), + eq(consentRecords.userId, session.userId), + eq(consentRecords.purpose, dto.purpose), + isNull(consentRecords.revokedAt), + ), + }); + if (existing) { + return existing; + } + const [created] = await db + .insert(consentRecords) + .values({ + tenantId: session.tenantId, + userId: session.userId, + purpose: dto.purpose, + grantedAt: new Date(), + }) + .returning(); + return created; + } + + @Post('revoke') + async revoke( + @CurrentSession() session: AuthenticatedSession, + @Body() dto: RevokeConsentDto, + ) { + const active = await db.query.consentRecords.findFirst({ + where: and( + eq(consentRecords.tenantId, session.tenantId), + eq(consentRecords.userId, session.userId), + eq(consentRecords.purpose, dto.purpose), + isNull(consentRecords.revokedAt), + ), + }); + if (!active) { + return { revoked: false, reason: 'no active consent for this purpose' }; + } + const [updated] = await db + .update(consentRecords) + .set({ revokedAt: new Date() }) + .where(eq(consentRecords.id, active.id)) + .returning(); + return { revoked: true, record: updated }; + } +}