All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m4s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m44s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m53s
CI / Import/export fidelity gate (push) Successful in 53s
Trashing a page (promote and subtree modes) clears the affected search vectors, restoring rebuilds them; pond trash clears every page vector of the pond, pond restore reindexes only the live pages (pages trashed inside stay out); the GDPR pseudonymization's personal-pond trash does the same. reindexAll now converges to the invariant (clears trashed, rebuilds live), and a one-off migration backfills vectors of already-trashed content. The query-side deleted_at guards stay untouched as the independent second layer - the test proves both layers separately, including writing a vector back onto a trashed page (simulating a future path that forgot the clear) and asserting the query still hides it. New provider methods removePond/reindexPond behind the SearchProvider seam. Refs #195 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
65 lines
2.3 KiB
TypeScript
65 lines
2.3 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DELETED_USER_DISPLAY_NAME } from '@dorfteich/shared';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SearchProvider } from '../search/search.provider';
|
|
|
|
/**
|
|
* GDPR account deletion (issue #59, security.md §Privacy). Rather than
|
|
* hard-deleting the user row — which would orphan or cascade authored content —
|
|
* this scrubs the personal data, drops the login credentials, and trashes the
|
|
* personal pond. The (kept) row is what `created_by`/authorship references, so
|
|
* shared content the user authored simply shows as "Deleted user".
|
|
*/
|
|
@Injectable()
|
|
export class PseudonymizationService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly audit: AuditService,
|
|
private readonly search: SearchProvider,
|
|
private readonly logger: PinoLogger,
|
|
) {
|
|
this.logger.setContext(PseudonymizationService.name);
|
|
}
|
|
|
|
async pseudonymize(userId: string): Promise<void> {
|
|
const marker = `deleted-${userId}`;
|
|
await this.prisma.$transaction(async (tx) => {
|
|
// Remove every login path (password + any linked identities).
|
|
await tx.userIdentity.deleteMany({ where: { userId } });
|
|
await tx.session.deleteMany({ where: { userId } });
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
username: marker,
|
|
email: `${marker}@deleted.invalid`,
|
|
displayName: DELETED_USER_DISPLAY_NAME,
|
|
status: 'DISABLED',
|
|
isSiteAdmin: false,
|
|
emailVerifiedAt: null,
|
|
},
|
|
});
|
|
// The personal pond follows the trash path (security.md §Privacy).
|
|
await tx.pond.updateMany({
|
|
where: { ownerId: userId, type: 'PERSONAL', deletedAt: null },
|
|
data: { deletedAt: new Date(), deletedBy: userId },
|
|
});
|
|
});
|
|
// Trash path includes leaving the search index (issue #195).
|
|
const personalPonds = await this.prisma.pond.findMany({
|
|
where: { ownerId: userId, type: 'PERSONAL' },
|
|
select: { id: true },
|
|
});
|
|
for (const pond of personalPonds) {
|
|
await this.search.removePond(pond.id);
|
|
}
|
|
await this.audit.record({
|
|
action: 'user.pseudonymized',
|
|
targetType: 'user',
|
|
targetId: userId,
|
|
});
|
|
}
|
|
}
|