diff --git a/apps/api/.env.example b/apps/api/.env.example
index 06140fb..d4af5f4 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -48,6 +48,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:*,http://*.localhost:*,https://bina
GOOGLE_CLIENT_ID=your_client_id_here
GOOGLE_CLIENT_SECRET=your_client_secret_here
+# Field-level encryption for OAuth tokens at rest (AES-256-GCM).
+# Generate with: openssl rand -hex 32
+FIELD_ENCRYPTION_KEY=your_64_char_hex_key_here
+
# Email (Resend)
RESEND_API_KEY=re_your_api_key_here
RESEND_FROM_EMAIL=onboarding@resend.dev
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index cae1c32..a298ce9 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -21,6 +21,7 @@ import { parseNodeEnvironment } from './lib/types/environment.type';
import { TenantModule } from './modules/tenant/tenant.module';
import { UserProfileModule } from './modules/user-profile/user-profile.module';
import { GlobalCrudModule } from './modules/global-crud/global-crud.module';
+import { GdprModule } from './modules/gdpr/gdpr.module';
@Module({
imports: [
@@ -70,6 +71,7 @@ import { GlobalCrudModule } from './modules/global-crud/global-crud.module';
CrudModule,
GlobalCrudModule,
EmailModule,
+ GdprModule,
],
controllers: [],
providers: [AppContext],
diff --git a/apps/api/src/lib/field-encryption.ts b/apps/api/src/lib/field-encryption.ts
new file mode 100644
index 0000000..8699df9
--- /dev/null
+++ b/apps/api/src/lib/field-encryption.ts
@@ -0,0 +1,81 @@
+import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
+import { Logger } from '@repo/utils-core';
+
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 12;
+const AUTH_TAG_LENGTH = 16;
+const PREFIX = 'enc:';
+
+let cachedKey: Buffer | null = null;
+
+function getKey(): Buffer | null {
+ if (cachedKey) return cachedKey;
+
+ const hex = process.env.FIELD_ENCRYPTION_KEY;
+ if (!hex) return null;
+
+ if (hex.length !== 64) {
+ Logger.instance
+ .withContext('FieldEncryption')
+ .critical(
+ 'FIELD_ENCRYPTION_KEY must be a 64-char hex string (32 bytes). Encryption disabled.',
+ );
+ return null;
+ }
+
+ cachedKey = Buffer.from(hex, 'hex');
+ return cachedKey;
+}
+
+export function encryptField(plaintext: string): string {
+ const key = getKey();
+ if (!key) return plaintext;
+
+ const iv = randomBytes(IV_LENGTH);
+ const cipher = createCipheriv(ALGORITHM, key, iv, {
+ authTagLength: AUTH_TAG_LENGTH,
+ });
+ const encrypted = Buffer.concat([
+ cipher.update(plaintext, 'utf8'),
+ cipher.final(),
+ ]);
+ const authTag = cipher.getAuthTag();
+
+ return `${PREFIX}${iv.toString('base64')}.${authTag.toString('base64')}.${encrypted.toString('base64')}`;
+}
+
+/**
+ * Decrypts a field value. If the value wasn't encrypted (no prefix),
+ * returns it as-is — this provides a smooth migration path for
+ * pre-existing plaintext data.
+ */
+export function decryptField(ciphertext: string): string {
+ if (!ciphertext.startsWith(PREFIX)) return ciphertext;
+
+ const key = getKey();
+ if (!key) return ciphertext;
+
+ try {
+ const payload = ciphertext.slice(PREFIX.length);
+ const [ivB64, tagB64, dataB64] = payload.split('.');
+ const iv = Buffer.from(ivB64, 'base64');
+ const authTag = Buffer.from(tagB64, 'base64');
+ const encrypted = Buffer.from(dataB64, 'base64');
+
+ const decipher = createDecipheriv(ALGORITHM, key, iv, {
+ authTagLength: AUTH_TAG_LENGTH,
+ });
+ decipher.setAuthTag(authTag);
+
+ const decrypted = Buffer.concat([
+ decipher.update(encrypted),
+ decipher.final(),
+ ]);
+ return decrypted.toString('utf8');
+ } catch {
+ Logger.instance
+ .withContext('FieldEncryption')
+ .critical('Failed to decrypt field — returning raw value');
+ return ciphertext;
+ }
+}
diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts
index af98602..7eb4627 100644
--- a/apps/api/src/modules/auth/auth.module.ts
+++ b/apps/api/src/modules/auth/auth.module.ts
@@ -6,6 +6,9 @@ import { BetterAuthLogger } from '../logger/logger-better-auth';
import { AuthService } from './auth.service';
import { AuthMiddleware } from './auth.middleware';
import { createBetterAuth } from './auth';
+import { UserPrismaRepository } from './repositories/prisma/user.prisma-repository';
+import { AccountPrismaRepository } from './repositories/prisma/account.prisma-repository';
+import { SessionPrismaRepository } from './repositories/prisma/session.prisma-repository';
@Module({
imports: [
@@ -29,7 +32,19 @@ import { createBetterAuth } from './auth';
}),
EmailModule,
],
- providers: [AuthService, AuthMiddleware],
- exports: [AuthService, AuthMiddleware],
+ providers: [
+ AuthService,
+ AuthMiddleware,
+ UserPrismaRepository,
+ AccountPrismaRepository,
+ SessionPrismaRepository,
+ ],
+ exports: [
+ AuthService,
+ AuthMiddleware,
+ UserPrismaRepository,
+ AccountPrismaRepository,
+ SessionPrismaRepository,
+ ],
})
export class AuthModule {}
diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts
index 6835f54..381fcb9 100644
--- a/apps/api/src/modules/auth/auth.service.ts
+++ b/apps/api/src/modules/auth/auth.service.ts
@@ -53,6 +53,11 @@ export class AuthService {
return session?.session ?? null;
}
+ async deleteUserForContext(ctx: AppContextType): Promise {
+ const headers = fromNodeHeaders(ctx.req.headers);
+ await this.auth.api.deleteUser({ headers, body: {} });
+ }
+
async getAccessTokenForContext(
provider: 'google',
ctx: AppContextType,
diff --git a/apps/api/src/modules/auth/auth.ts b/apps/api/src/modules/auth/auth.ts
index f2fc0d2..ba3ee50 100644
--- a/apps/api/src/modules/auth/auth.ts
+++ b/apps/api/src/modules/auth/auth.ts
@@ -10,9 +10,107 @@ import {
NodeEnvironment,
parseNodeEnvironment,
} from '../../lib/types/environment.type';
+import { Logger } from '@repo/utils-core';
+import { encryptField, decryptField } from '../../lib/field-encryption';
+
+const TOKEN_FIELDS = ['accessToken', 'refreshToken', 'idToken'] as const;
+
+function encryptTokenFields(data: Record): void {
+ for (const field of TOKEN_FIELDS) {
+ if (typeof data[field] === 'string') {
+ data[field] = encryptField(data[field]);
+ }
+ }
+}
+
+function decryptTokenFields(record: Record): void {
+ for (const field of TOKEN_FIELDS) {
+ if (typeof record[field] === 'string') {
+ record[field] = decryptField(record[field]);
+ }
+ }
+}
+
+function decryptResult(result: unknown): void {
+ if (Array.isArray(result)) {
+ for (const r of result) decryptTokenFields(r as Record);
+ } else if (result && typeof result === 'object') {
+ decryptTokenFields(result as Record);
+ }
+}
+
+const basePrisma = new PrismaClient();
+
+/**
+ * Wraps a Prisma model delegate so that token fields on the Account
+ * model are encrypted before writes and decrypted after reads.
+ * Uses a JS Proxy so it works regardless of how the caller accesses
+ * methods (typed or dynamic string indexing).
+ */
+function wrapAccountDelegate(delegate: Record): unknown {
+ const WRITE_METHODS = new Set(['create', 'update', 'upsert']);
+ const READ_METHODS = new Set([
+ 'findUnique',
+ 'findFirst',
+ 'findMany',
+ 'create',
+ 'update',
+ 'upsert',
+ ]);
+
+ return new Proxy(delegate, {
+ get(target, prop) {
+ const original = target[prop as string];
+ if (typeof original !== 'function') return original;
+
+ const method = String(prop);
+
+ if (WRITE_METHODS.has(method) || READ_METHODS.has(method)) {
+ return async (...args: unknown[]) => {
+ const params = args[0] as Record | undefined;
+
+ if (WRITE_METHODS.has(method) && params) {
+ if (method === 'upsert') {
+ if (params.create)
+ encryptTokenFields(params.create as Record);
+ if (params.update)
+ encryptTokenFields(params.update as Record);
+ } else if (params.data) {
+ encryptTokenFields(params.data as Record);
+ }
+ }
+
+ const result: unknown = await original.apply(target, args);
+
+ if (READ_METHODS.has(method)) {
+ decryptResult(result);
+ }
+
+ return result;
+ };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return original.bind(target);
+ },
+ });
+}
+
+const prisma = new Proxy(basePrisma, {
+ get(target, prop) {
+ const value = (target as unknown as Record)[prop];
+ if (prop === 'account') {
+ return wrapAccountDelegate(value as Record);
+ }
+ if (typeof value === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return value.bind(target);
+ }
+ return value;
+ },
+}) as unknown as PrismaClient;
const createDatabaseAdapter = () => {
- const prisma = new PrismaClient();
return prismaAdapter(prisma, {
provider: 'postgresql',
});
@@ -30,6 +128,77 @@ export const createBetterAuth = (
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
basePath: '/api/auth',
database: createDatabaseAdapter(),
+ databaseHooks: {
+ user: {
+ create: {
+ after: async (user, ctx) => {
+ const ipAddress =
+ ctx?.headers?.get?.('x-forwarded-for')?.split(',')[0]?.trim() ??
+ ctx?.headers?.get?.('x-real-ip') ??
+ null;
+
+ try {
+ await basePrisma.user.update({
+ where: { id: user.id },
+ data: {
+ consentGiven: true,
+ consentAt: new Date(),
+ consentIp: ipAddress,
+ },
+ });
+ await basePrisma.gdprAuditLog.create({
+ data: {
+ userId: user.id,
+ action: 'CONSENT_GIVEN',
+ details: 'Consent recorded at account creation',
+ ipAddress,
+ },
+ });
+ } catch (err) {
+ Logger.instance
+ .withContext('Auth')
+ .critical('Failed to record GDPR consent', err);
+ }
+ },
+ },
+ },
+ },
+ user: {
+ deleteUser: {
+ enabled: true,
+ beforeDelete: async (user, request) => {
+ const email = user.email?.trim().toLowerCase();
+ const ipAddress =
+ request?.headers?.get?.('x-forwarded-for')?.split(',')[0]?.trim() ??
+ request?.headers?.get?.('x-real-ip') ??
+ null;
+
+ try {
+ await basePrisma.gdprAuditLog.create({
+ data: {
+ userId: user.id,
+ action: 'DATA_DELETION',
+ details: 'Account deletion requested and executed',
+ ipAddress,
+ },
+ });
+
+ if (email) {
+ await basePrisma.tenantMembership.deleteMany({
+ where: { email },
+ });
+ await basePrisma.verification.deleteMany({
+ where: { identifier: email },
+ });
+ }
+ } catch (err) {
+ Logger.instance
+ .withContext('Auth')
+ .critical('Failed to execute pre-deletion cleanup', err);
+ }
+ },
+ },
+ },
emailAndPassword: {
enabled: true,
requireEmailVerification: !isDevelopment,
diff --git a/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.interface.ts b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.interface.ts
new file mode 100644
index 0000000..2adaa3d
--- /dev/null
+++ b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.interface.ts
@@ -0,0 +1,7 @@
+import { Prisma } from '@repo/prisma-db';
+
+export interface IAccountPrismaRepository {
+ findMany(
+ args?: Prisma.AccountFindManyArgs,
+ ): Promise[]>;
+}
diff --git a/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.ts b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.ts
new file mode 100644
index 0000000..0210ebe
--- /dev/null
+++ b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.ts
@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import {
+ TransactionHost,
+ InjectTransactionHost,
+} from '@nestjs-cls/transactional';
+import { Prisma } from '@repo/prisma-db';
+import { IAccountPrismaRepository } from './account.prisma-repository.interface';
+import { PrismaTransactionAdapter } from '../../../prisma/prisma.module';
+import { ServerConstants } from '../../../../constants/server.constants';
+
+@Injectable()
+export class AccountPrismaRepository implements IAccountPrismaRepository {
+ constructor(
+ @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma)
+ protected readonly prismaTxHost: TransactionHost,
+ ) {}
+
+ protected get delegate(): Prisma.AccountDelegate {
+ return this.prismaTxHost.tx.account;
+ }
+
+ findMany(
+ args?: Prisma.AccountFindManyArgs,
+ ): Promise[]> {
+ return this.delegate.findMany(args);
+ }
+}
diff --git a/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.interface.ts b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.interface.ts
new file mode 100644
index 0000000..edd57c6
--- /dev/null
+++ b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.interface.ts
@@ -0,0 +1,7 @@
+import { Prisma } from '@repo/prisma-db';
+
+export interface ISessionPrismaRepository {
+ findMany(
+ args?: Prisma.SessionFindManyArgs,
+ ): Promise[]>;
+}
diff --git a/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.ts b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.ts
new file mode 100644
index 0000000..2d458d8
--- /dev/null
+++ b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.ts
@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import {
+ TransactionHost,
+ InjectTransactionHost,
+} from '@nestjs-cls/transactional';
+import { Prisma } from '@repo/prisma-db';
+import { ISessionPrismaRepository } from './session.prisma-repository.interface';
+import { PrismaTransactionAdapter } from '../../../prisma/prisma.module';
+import { ServerConstants } from '../../../../constants/server.constants';
+
+@Injectable()
+export class SessionPrismaRepository implements ISessionPrismaRepository {
+ constructor(
+ @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma)
+ protected readonly prismaTxHost: TransactionHost,
+ ) {}
+
+ protected get delegate(): Prisma.SessionDelegate {
+ return this.prismaTxHost.tx.session;
+ }
+
+ findMany(
+ args?: Prisma.SessionFindManyArgs,
+ ): Promise[]> {
+ return this.delegate.findMany(args);
+ }
+}
diff --git a/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.interface.ts b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.interface.ts
new file mode 100644
index 0000000..f6db513
--- /dev/null
+++ b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.interface.ts
@@ -0,0 +1,11 @@
+import { Prisma } from '@repo/prisma-db';
+
+export interface IUserPrismaRepository {
+ findUniqueOrThrow(
+ args: Prisma.UserFindUniqueOrThrowArgs,
+ ): Promise>;
+
+ update(
+ args: Prisma.UserUpdateArgs,
+ ): Promise>;
+}
diff --git a/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.ts b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.ts
new file mode 100644
index 0000000..9154e8e
--- /dev/null
+++ b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.ts
@@ -0,0 +1,33 @@
+import { Injectable } from '@nestjs/common';
+import {
+ TransactionHost,
+ InjectTransactionHost,
+} from '@nestjs-cls/transactional';
+import { Prisma } from '@repo/prisma-db';
+import { IUserPrismaRepository } from './user.prisma-repository.interface';
+import { PrismaTransactionAdapter } from '../../../prisma/prisma.module';
+import { ServerConstants } from '../../../../constants/server.constants';
+
+@Injectable()
+export class UserPrismaRepository implements IUserPrismaRepository {
+ constructor(
+ @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma)
+ protected readonly prismaTxHost: TransactionHost,
+ ) {}
+
+ protected get delegate(): Prisma.UserDelegate {
+ return this.prismaTxHost.tx.user;
+ }
+
+ findUniqueOrThrow(
+ args: Prisma.UserFindUniqueOrThrowArgs,
+ ): Promise> {
+ return this.delegate.findUniqueOrThrow(args);
+ }
+
+ update(
+ args: Prisma.UserUpdateArgs,
+ ): Promise> {
+ return this.delegate.update(args);
+ }
+}
diff --git a/apps/api/src/modules/email/email.service.ts b/apps/api/src/modules/email/email.service.ts
index daa7644..3cf0988 100644
--- a/apps/api/src/modules/email/email.service.ts
+++ b/apps/api/src/modules/email/email.service.ts
@@ -97,37 +97,27 @@ export class EmailService {
);
try {
- let messageId: string;
+ const payload = {
+ to,
+ subject: renderedEmail.subject,
+ html: renderedEmail.html,
+ text: renderedEmail.text,
+ };
switch (provider) {
case EmailProvider.RESEND:
- messageId = await this.resendProvider.send({
- to,
- subject: renderedEmail.subject,
- html: renderedEmail.html,
- text: renderedEmail.text,
- });
+ await this.resendProvider.send(payload);
break;
case EmailProvider.AWS_SES:
- messageId = await this.awsSesProvider.send({
- to,
- subject: renderedEmail.subject,
- html: renderedEmail.html,
- text: renderedEmail.text,
- });
+ await this.awsSesProvider.send(payload);
break;
default:
throw new Error(`Unsupported email provider: ${String(provider)}`);
}
- Logger.instance.info(
- `Email (${templateName}) sent successfully to ${to} via ${provider}. ID: ${messageId}`,
- );
+ Logger.instance.info(`Email (${templateName}) sent successfully`);
} catch (error) {
- Logger.instance.critical(
- `Failed to send email (${templateName}) to ${to} via ${provider}:`,
- error,
- );
+ Logger.instance.critical(`Failed to send email (${templateName})`, error);
}
}
}
diff --git a/apps/api/src/modules/gdpr/gdpr.module.ts b/apps/api/src/modules/gdpr/gdpr.module.ts
new file mode 100644
index 0000000..1c22f20
--- /dev/null
+++ b/apps/api/src/modules/gdpr/gdpr.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { PrismaModule } from '../prisma/prisma.module';
+import { AuthModule } from '../auth/auth.module';
+import { UserProfileModule } from '../user-profile/user-profile.module';
+import { GdprService } from './gdpr.service';
+import { GdprRouter } from './gdpr.router';
+import { GdprAuditLogPrismaRepository } from './repositories/prisma/gdpr-audit-log.prisma-repository';
+
+@Module({
+ imports: [PrismaModule, AuthModule, UserProfileModule],
+ providers: [GdprService, GdprRouter, GdprAuditLogPrismaRepository],
+ exports: [GdprService],
+})
+export class GdprModule {}
diff --git a/apps/api/src/modules/gdpr/gdpr.router.ts b/apps/api/src/modules/gdpr/gdpr.router.ts
new file mode 100644
index 0000000..7d77ba8
--- /dev/null
+++ b/apps/api/src/modules/gdpr/gdpr.router.ts
@@ -0,0 +1,89 @@
+import {
+ Ctx,
+ Input,
+ Mutation,
+ Query,
+ Router,
+ UseMiddlewares,
+} from 'nestjs-trpc';
+import { TRPCError } from '@trpc/server';
+import { AuthMiddleware } from '../auth/auth.middleware';
+import { GdprService } from './gdpr.service';
+import { AppContextType } from '../../app.context';
+import {
+ ZGdprMyDataResponse,
+ ZGdprUpdateProfileRequest,
+ ZGdprUpdateProfileResponse,
+ ZGdprDeleteAccountRequest,
+ ZGdprDeleteAccountResponse,
+ TGdprMyDataResponse,
+ TGdprUpdateProfileRequest,
+ TGdprUpdateProfileResponse,
+ TGdprDeleteAccountRequest,
+ TGdprDeleteAccountResponse,
+} from './gdpr.schema';
+
+@Router({ alias: 'gdpr' })
+@UseMiddlewares(AuthMiddleware)
+export class GdprRouter {
+ constructor(private readonly gdprService: GdprService) {}
+
+ @Query({ output: ZGdprMyDataResponse })
+ async myData(@Ctx() ctx: AppContextType): Promise {
+ const user = ctx.user!;
+ return this.gdprService.getMyData(user.id, this.extractIp(ctx));
+ }
+
+ @Query({ output: ZGdprMyDataResponse })
+ async exportData(@Ctx() ctx: AppContextType): Promise {
+ const user = ctx.user!;
+ return this.gdprService.getMyData(user.id, this.extractIp(ctx));
+ }
+
+ @Mutation({
+ input: ZGdprUpdateProfileRequest,
+ output: ZGdprUpdateProfileResponse,
+ })
+ async updateProfile(
+ @Ctx() ctx: AppContextType,
+ @Input() req: TGdprUpdateProfileRequest,
+ ): Promise {
+ const user = ctx.user!;
+ return this.gdprService.updateProfile(
+ user.id,
+ { name: req.name, image: req.image },
+ this.extractIp(ctx),
+ );
+ }
+
+ @Mutation({
+ input: ZGdprDeleteAccountRequest,
+ output: ZGdprDeleteAccountResponse,
+ })
+ async deleteAccount(
+ @Ctx() ctx: AppContextType,
+ @Input() req: TGdprDeleteAccountRequest,
+ ): Promise {
+ const user = ctx.user!;
+
+ if (req.confirmation !== user.email) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'Confirmation must match your email address to delete your account',
+ });
+ }
+
+ await this.gdprService.deleteAccount(ctx);
+
+ return { success: true };
+ }
+
+ private extractIp(ctx: AppContextType): string | null {
+ const headers = ctx.req.headers;
+ const forwarded = headers['x-forwarded-for'];
+ if (typeof forwarded === 'string') return forwarded.split(',')[0].trim();
+ if (Array.isArray(forwarded)) return forwarded[0];
+ return (headers['x-real-ip'] as string) ?? null;
+ }
+}
diff --git a/apps/api/src/modules/gdpr/gdpr.schema.ts b/apps/api/src/modules/gdpr/gdpr.schema.ts
new file mode 100644
index 0000000..3c905fe
--- /dev/null
+++ b/apps/api/src/modules/gdpr/gdpr.schema.ts
@@ -0,0 +1,86 @@
+import { z } from 'zod';
+import { ZBaseRequest, ZBaseResponse } from '../../schemas/base.schema';
+
+// ─── My Data (Right of Access / Portability) ────────────────────
+
+export const ZGdprMyDataResponse = ZBaseResponse.extend({
+ user: z.object({
+ name: z.string(),
+ email: z.string(),
+ emailVerified: z.boolean(),
+ image: z.string().nullable(),
+ consentGiven: z.boolean(),
+ consentAt: z.date().nullable(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ }),
+ accounts: z.array(
+ z.object({
+ providerId: z.string(),
+ scope: z.string().nullable(),
+ createdAt: z.date(),
+ }),
+ ),
+ sessions: z.array(
+ z.object({
+ ipAddress: z.string().nullable(),
+ userAgent: z.string().nullable(),
+ createdAt: z.date(),
+ expiresAt: z.date(),
+ }),
+ ),
+ profile: z
+ .object({
+ createdAt: z.date(),
+ })
+ .nullable(),
+});
+
+// ─── Update Profile (Right of Rectification) ───────────────────
+
+export const ZGdprUpdateProfileRequest = ZBaseRequest.extend({
+ name: z
+ .string()
+ .min(2)
+ .max(100)
+ .regex(
+ /^[a-zA-Z\s'-]+$/,
+ 'Name can only contain letters, spaces, hyphens, and apostrophes',
+ )
+ .optional(),
+ image: z.string().max(2048).nullable().optional(),
+});
+
+export const ZGdprUpdateProfileResponse = ZBaseResponse.extend({
+ user: z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ image: z.string().nullable(),
+ updatedAt: z.date(),
+ }),
+});
+
+// ─── Delete Account (Right to Erasure) ──────────────────────────
+
+export const ZGdprDeleteAccountRequest = ZBaseRequest.extend({
+ confirmation: z.string(),
+});
+
+export const ZGdprDeleteAccountResponse = ZBaseResponse;
+
+// ─── Types ──────────────────────────────────────────────────────
+
+export type TGdprMyDataResponse = z.infer;
+export type TGdprUpdateProfileRequest = z.infer<
+ typeof ZGdprUpdateProfileRequest
+>;
+export type TGdprUpdateProfileResponse = z.infer<
+ typeof ZGdprUpdateProfileResponse
+>;
+export type TGdprDeleteAccountRequest = z.infer<
+ typeof ZGdprDeleteAccountRequest
+>;
+export type TGdprDeleteAccountResponse = z.infer<
+ typeof ZGdprDeleteAccountResponse
+>;
diff --git a/apps/api/src/modules/gdpr/gdpr.service.ts b/apps/api/src/modules/gdpr/gdpr.service.ts
new file mode 100644
index 0000000..057e34d
--- /dev/null
+++ b/apps/api/src/modules/gdpr/gdpr.service.ts
@@ -0,0 +1,139 @@
+import { Injectable } from '@nestjs/common';
+import { Propagation } from '@nestjs-cls/transactional';
+import { AutoTransaction } from '../../decorators/class/auto-transaction.decorator';
+import { ServerConstants } from '../../constants/server.constants';
+import { Logger } from '@repo/utils-core';
+import { AuthService } from '../auth/auth.service';
+import { UserPrismaRepository } from '../auth/repositories/prisma/user.prisma-repository';
+import { AccountPrismaRepository } from '../auth/repositories/prisma/account.prisma-repository';
+import { SessionPrismaRepository } from '../auth/repositories/prisma/session.prisma-repository';
+import { UserProfilePrismaRepository } from '../user-profile/repositories/prisma/user-profile.prisma-repository';
+import { GdprAuditLogPrismaRepository } from './repositories/prisma/gdpr-audit-log.prisma-repository';
+import { AppContextType } from '../../app.context';
+import type {
+ TGdprMyDataResponse,
+ TGdprUpdateProfileResponse,
+} from './gdpr.schema';
+
+@Injectable()
+@AutoTransaction(
+ ServerConstants.TransactionConnectionNames.Prisma,
+ Propagation.Required,
+)
+export class GdprService {
+ constructor(
+ private readonly authService: AuthService,
+ private readonly userRepository: UserPrismaRepository,
+ private readonly accountRepository: AccountPrismaRepository,
+ private readonly sessionRepository: SessionPrismaRepository,
+ private readonly userProfileRepository: UserProfilePrismaRepository,
+ private readonly auditLogRepository: GdprAuditLogPrismaRepository,
+ ) {}
+
+ private get logger() {
+ return Logger.instance.withContext(GdprService.name);
+ }
+
+ async getMyData(
+ userId: string,
+ ipAddress: string | null,
+ ): Promise {
+ const user = await this.userRepository.findUniqueOrThrow({
+ where: { id: userId },
+ select: {
+ name: true,
+ email: true,
+ emailVerified: true,
+ image: true,
+ consentGiven: true,
+ consentAt: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ });
+
+ const accounts = await this.accountRepository.findMany({
+ where: { userId },
+ select: {
+ providerId: true,
+ scope: true,
+ createdAt: true,
+ },
+ });
+
+ const sessions = await this.sessionRepository.findMany({
+ where: { userId },
+ select: {
+ ipAddress: true,
+ userAgent: true,
+ createdAt: true,
+ expiresAt: true,
+ },
+ });
+
+ const profile = await this.userProfileRepository.findUnique({
+ where: { userId },
+ select: {
+ createdAt: true,
+ },
+ });
+
+ await this.auditLogRepository.create({
+ data: {
+ userId,
+ action: 'DATA_ACCESS',
+ details: 'User requested personal data export',
+ ipAddress,
+ },
+ });
+
+ return {
+ success: true,
+ user,
+ accounts,
+ sessions,
+ profile,
+ };
+ }
+
+ async updateProfile(
+ userId: string,
+ data: { name?: string; image?: string | null },
+ ipAddress: string | null,
+ ): Promise {
+ const updateData: Record = {};
+ if (data.name !== undefined) updateData.name = data.name.trim();
+ if (data.image !== undefined) updateData.image = data.image;
+
+ const user = await this.userRepository.update({
+ where: { id: userId },
+ data: updateData,
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ updatedAt: true,
+ },
+ });
+
+ const changedFields = Object.keys(updateData).join(', ');
+ await this.auditLogRepository.create({
+ data: {
+ userId,
+ action: 'DATA_RECTIFICATION',
+ details: `Profile fields updated: ${changedFields}`,
+ ipAddress,
+ },
+ });
+
+ this.logger.info(`Profile updated for user ${userId}`);
+
+ return { success: true, user };
+ }
+
+ async deleteAccount(ctx: AppContextType): Promise {
+ await this.authService.deleteUserForContext(ctx);
+ this.logger.info(`Account deleted for user ${ctx.user!.id}`);
+ }
+}
diff --git a/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts
new file mode 100644
index 0000000..6d43948
--- /dev/null
+++ b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts
@@ -0,0 +1,7 @@
+import { Prisma } from '@repo/prisma-db';
+
+export interface IGdprAuditLogPrismaRepository {
+ create(
+ args: Prisma.GdprAuditLogCreateArgs,
+ ): Promise>;
+}
diff --git a/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts
new file mode 100644
index 0000000..1a1ca6b
--- /dev/null
+++ b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts
@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import {
+ TransactionHost,
+ InjectTransactionHost,
+} from '@nestjs-cls/transactional';
+import { Prisma } from '@repo/prisma-db';
+import { IGdprAuditLogPrismaRepository } from './gdpr-audit-log.prisma-repository.interface';
+import { PrismaTransactionAdapter } from '../../../prisma/prisma.module';
+import { ServerConstants } from '../../../../constants/server.constants';
+
+@Injectable()
+export class GdprAuditLogPrismaRepository implements IGdprAuditLogPrismaRepository {
+ constructor(
+ @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma)
+ protected readonly prismaTxHost: TransactionHost,
+ ) {}
+
+ protected get delegate(): Prisma.GdprAuditLogDelegate {
+ return this.prismaTxHost.tx.gdprAuditLog;
+ }
+
+ create(
+ args: Prisma.GdprAuditLogCreateArgs,
+ ): Promise> {
+ return this.delegate.create(args);
+ }
+}
diff --git a/apps/api/src/trpc/trpc-error-formatter.ts b/apps/api/src/trpc/trpc-error-formatter.ts
index eef2564..2626181 100644
--- a/apps/api/src/trpc/trpc-error-formatter.ts
+++ b/apps/api/src/trpc/trpc-error-formatter.ts
@@ -217,15 +217,10 @@ function getFormattedTRPCError(opts: TRPCErrorOptions): FormattedError {
export function trpcErrorFormatter(opts: TRPCErrorOptions): TRPCErrorShapeType {
const formattedError: FormattedError = getFormattedTRPCError(opts);
- const { input } = opts;
const logArguments = {
...formattedError,
};
- if (input && process.env.NODE_ENV === 'development') {
- Object.assign(logArguments, { input: input });
- }
-
Logger.instance
.withContext('TRPC Route')
.critical(formattedError.errorShape.message, logArguments);
diff --git a/apps/web/app/auth-demo/AuthDemoClient.tsx b/apps/web/app/auth-demo/AuthDemoClient.tsx
index 7eb2106..d699452 100644
--- a/apps/web/app/auth-demo/AuthDemoClient.tsx
+++ b/apps/web/app/auth-demo/AuthDemoClient.tsx
@@ -265,7 +265,7 @@ export default function AuthDemoClient() {
-
User ID
+
{LL.Auth.userId()}
{session.user.id}
@@ -309,6 +309,19 @@ export default function AuthDemoClient() {
{authStep === "choose" && (
<>
+
+ {LL.Auth.signInConsentPrefix()}{" "}
+
+ {LL.Settings.privacyAndDataPolicy()}
+ {" "}
+ {LL.Auth.signInConsentSuffix()}
+
+