Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions docs/privacy/data-inventory.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,9 @@
"holds": {
"active_project": "Laufendes Projekt / Auftrag",
"upcoming_shoot": "Bevorstehendes geplantes Shooting",
"statutory": {
"commercial_document": "Gesetzliche Aufbewahrungspflicht für Handelsdokumente (§ 147 AO, § 257 HGB)"
},
"manual": {
"dispute": "Rechtsstreitigkeit",
"statutory": "Gesetzliche Anforderung",
Expand Down
3 changes: 3 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,9 @@
"holds": {
"active_project": "Active commission / project",
"upcoming_shoot": "Upcoming scheduled shoot",
"statutory": {
"commercial_document": "Statutory commercial agreement (§147 AO, §257 HGB)"
},
"manual": {
"dispute": "Dispute resolution",
"statutory": "Statutory requirement",
Expand Down
7 changes: 6 additions & 1 deletion src/components/clients/erasure-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,12 @@ export function ErasureDialogInner({
<ul className="space-y-2 pt-2 border-t border-amber-200/60 dark:border-amber-800/40 text-xs text-foreground">
{holds.map((h, idx) => {
const manualKey = h.reason.startsWith('manual.') ? h.reason.slice(7) : null;
const localizedReason = manualKey ? tHolds(`manual.${manualKey}`) : h.reason;
const statutoryKey = h.reason.startsWith('statutory.') ? h.reason.slice(10) : null;
const localizedReason = manualKey
? tHolds(`manual.${manualKey}`)
: statutoryKey
? tHolds(`statutory.${statutoryKey}`)
: h.reason;
return (
<li
key={idx}
Expand Down
2 changes: 2 additions & 0 deletions src/server/clients/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { clientTimelineEvents, clientNotes } from '@/server/db/schema/timeline';
import { consentRecords } from '@/server/db/schema/consents';
import { clientContacts } from '@/server/db/schema/clients';
import { projectsActivityProvider } from '@/server/projects/privacy/activity-provider';
import { proposalsActivityProvider } from '@/server/proposals/privacy/activity-provider';

export interface ActivityProvider {
id: string;
Expand Down Expand Up @@ -74,6 +75,7 @@ const DEFAULT_ACTIVITY_PROVIDERS: readonly ActivityProvider[] = [
},
},
projectsActivityProvider,
proposalsActivityProvider,
];

function initDefaultProviders(): void {
Expand Down
6 changes: 1 addition & 5 deletions src/server/privacy/erasure/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ export const ERASURE_COVERAGE_ALLOWLIST: readonly ErasureCoverageExemption[] = [
// - client_timeline_events -> timeline
// - consent_records -> consents
// - projects, project_participants -> projects
// - proposals, proposal_line_items -> proposals (TASK-0059)
// When future epics introduce tables with client_id or contact_id columns
// that have statutory retention or non-deletable data, register an exemption here with reasoning.
{
tableName: 'proposals',
reason:
'Commercial proposals and statutory accepted records. GDPR Art. 17 erasure and commercial hold provider is scheduled in TASK-0059 as part of Epic E5.',
},
];
2 changes: 1 addition & 1 deletion src/server/privacy/erasure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ export * from './types';
export * from './registry';
export * from './allowlist';
export * from './schema';
export { planErasure, eraseSubject } from './service';
export { planErasure, eraseSubject, checkErasureHolds } from './service';
export { planErasureAction, executeErasureAction, eraseClientDataAction } from './actions';
3 changes: 3 additions & 0 deletions src/server/privacy/erasure/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { ErasureProvider, ErasureHoldProvider } from './types';
import { googleContactsErasureProvider } from '@/server/integrations/google/erasure-provider';
import { projectsErasureProvider, projectsHoldProvider } from '@/server/projects/privacy';
import { appointmentsErasureProvider } from './appointments';
import { proposalsErasureProvider, proposalsHoldProvider } from '@/server/proposals/privacy';

const erasureProviders = new Map<string, ErasureProvider>();
const holdProviders = new Map<string, ErasureHoldProvider>();
Expand Down Expand Up @@ -280,6 +281,7 @@ export const DEFAULT_ERASURE_PROVIDERS: ErasureProvider[] = [
},
},
googleContactsErasureProvider,
proposalsErasureProvider,
projectsErasureProvider,
appointmentsErasureProvider,
{
Expand Down Expand Up @@ -509,6 +511,7 @@ export const DEFAULT_HOLD_PROVIDERS: ErasureHoldProvider[] = [
},
},
projectsHoldProvider,
proposalsHoldProvider,
];

function initDefaultProviders(): void {
Expand Down
37 changes: 21 additions & 16 deletions src/server/privacy/erasure/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,25 @@ async function resolveSubject(
};
}

/**
* Evaluates all registered erasure hold providers for the given subject.
* Returns an array of active holds. If array is empty, erasure may proceed.
*/
export async function checkErasureHolds(
subject: ErasureSubject,
client: DbClient = defaultDb,
): Promise<ErasureHold[]> {
const holdProviders = getErasureHoldProviders();
const holds: ErasureHold[] = [];
for (const hp of holdProviders) {
const provHolds = await hp.holds(subject, client);
if (provHolds.length > 0) {
holds.push(...provHolds);
}
}
return holds;
}

/**
* Plans erasure of a client or contact.
* Aggregates counts across all erasure providers, evaluates holds,
Expand All @@ -131,14 +150,7 @@ export async function planErasure(
const { effectiveSubject, clientDisplayName, isMultiContact } = resolved;

// 1. Evaluate holds across all registered hold providers
const holdProviders = getErasureHoldProviders();
const holds: ErasureHold[] = [];
for (const hp of holdProviders) {
const provHolds = await hp.holds(effectiveSubject, client);
if (provHolds.length > 0) {
holds.push(...provHolds);
}
}
const holds = await checkErasureHolds(effectiveSubject, client);

// 2. Aggregate category counts from erasure providers
const providers = getErasureProviders();
Expand Down Expand Up @@ -203,14 +215,7 @@ export async function eraseSubject(
const { effectiveSubject } = resolved;

// 2. Re-evaluate holds inside transaction (TOCTOU protection, AC-5)
const holdProviders = getErasureHoldProviders();
const holds: ErasureHold[] = [];
for (const hp of holdProviders) {
const provHolds = await hp.holds(effectiveSubject, tx);
if (provHolds.length > 0) {
holds.push(...provHolds);
}
}
const holds = await checkErasureHolds(effectiveSubject, tx);

const targetUuid =
effectiveSubject.kind === 'client'
Expand Down
33 changes: 33 additions & 0 deletions src/server/privacy/export/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,39 @@ export const INITIAL_CATEGORIES: readonly CategoryDefinition[] = [
de: 'Von der teilnehmenden Person bei der Terminvereinbarung angegeben.',
},
},
{
id: 'proposals',
titleKey: 'privacy.export.categories.proposals.title',
purposeKey: 'privacy.export.categories.proposals.purpose',
lawfulBasisKey: 'privacy.export.categories.proposals.lawful_basis',
retentionKey: 'privacy.export.categories.proposals.retention',
recipientsKey: 'privacy.export.categories.proposals.recipients',
sourceKey: 'privacy.export.categories.proposals.source',
title: {
en: 'Proposals & Quotes',
de: 'Angebote & Kostenvoranschläge',
},
purpose: {
en: 'Preparation, submission, negotiation, and formal acceptance of service proposals and fee quotes.',
de: 'Erstellung, Übermittlung, Verhandlung und formelle Annahme von Angeboten und Kostenvoranschlägen.',
},
lawfulBasis: {
en: 'Art. 6(1)(b) GDPR (Performance of a contract / pre-contractual measures) and § 147 AO / § 257 HGB (Statutory accounting & tax obligations for accepted proposals).',
de: 'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung / vorvertragliche Maßnahmen) und § 147 AO / § 257 HGB (Steuer- und handelsrechtliche Aufbewahrungspflichten für angenommene Angebote).',
},
retention: {
en: 'Unaccepted/declined proposals are pruned after 12 months; accepted proposals are retained for 10 years pursuant to § 147 AO and § 257 HGB.',
de: 'Nicht angenommene/abgelehnte Angebote werden nach 12 Monaten gelöscht; angenommene Angebote werden 10 Jahre gemäß § 147 AO und § 257 HGB aufbewahrt.',
},
recipients: {
en: 'None (stored locally on the self-hosted studio instance).',
de: 'Keine (ausschließlich lokale Speicherung auf der selbst gehosteten Studio-Instanz).',
},
source: {
en: 'Provided by the studio owner and client during proposal negotiation and acceptance.',
de: 'Vom Studioinhaber und Kunden im Rahmen der Angebotsverhandlung und -annahme bereitgestellt.',
},
},
];

const categoryRegistry = new Map<string, CategoryDefinition>();
Expand Down
19 changes: 19 additions & 0 deletions src/server/privacy/export/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ const GERMAN_COLUMN_HEADERS: Record<string, string> = {
attendee_notes: 'Notizen',
cancellation_reason: 'Grund der Absage',
cancelled_at: 'Abgesagt am',
proposal: 'Angebot',
proposal_title: 'Angebot',
subtotal: 'Zwischensumme',
subtotal_minor: 'Zwischensumme',
tax: 'Umsatzsteuer',
tax_minor: 'Umsatzsteuer',
total: 'Gesamtbetrag',
total_minor: 'Gesamtbetrag',
valid_until: 'Gültig bis',
validity_date: 'Gültig bis',
sent_at: 'Gesendet am',
sent_date: 'Gesendet am',
accepted_at: 'Angenommen am',
acceptance_timestamp: 'Angenommen am',
accepted_by: 'Angenommen von',
accepted_by_name: 'Angenommen von (Name)',
accepted_by_email: 'Angenommen von (E-Mail)',
line_items: 'Positionen',
deliverables: 'Leistungen & Positionen',
};

/**
Expand Down
2 changes: 2 additions & 0 deletions src/server/privacy/export/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
import { googleContactsExportProvider } from '@/server/integrations/google/export-provider';
import { projectsExportProvider } from '@/server/projects/privacy/export-provider';
import { appointmentsExportProvider } from './appointments';
import { proposalsExportProvider } from '@/server/proposals/privacy/export-provider';

const providerRegistry = new Map<string, ExportProvider>();

Expand Down Expand Up @@ -375,6 +376,7 @@ export const DEFAULT_PROVIDERS: ExportProvider[] = [
},
projectsExportProvider,
appointmentsExportProvider,
proposalsExportProvider,
];

function initDefaultProviders(): void {
Expand Down
64 changes: 64 additions & 0 deletions src/server/proposals/privacy/activity-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { inArray, gte, or, and } from 'drizzle-orm';
import type { DbClient } from '@/server/db/client';
import { proposals } from '@/server/db/schema/proposals';
import type { ActivityProvider } from '@/server/clients/activity';

/**
* Retention sweep activity provider for proposals & quotes (ADR-0018 §6).
* Identifies clients that have active pre-contractual proposals, recent quotation updates,
* or accepted commercial agreements, preventing automated retention sweeps from marking
* them inactive or purging them.
*/
export const proposalsActivityProvider: ActivityProvider = {
id: 'proposals',

activeClientIds: async (
tx: DbClient,
since: Date,
candidateIds: readonly string[],
): Promise<string[]> => {
if (candidateIds.length === 0) {
return [];
}

const activeIds = new Set<string>();
const now = new Date();
const candidateList = [...candidateIds];

// 1. Clients with active or accepted proposals (draft, sent, accepted)
const activeStatusRows = await tx
.selectDistinct({ clientId: proposals.clientId })
.from(proposals)
.where(
and(
inArray(proposals.clientId, candidateList),
inArray(proposals.status, ['draft', 'sent', 'accepted']),
),
);

for (const row of activeStatusRows) {
activeIds.add(row.clientId);
}

// 2. Clients with proposals updated, sent, or valid recently or into the future
const recentProposalRows = await tx
.selectDistinct({ clientId: proposals.clientId })
.from(proposals)
.where(
and(
inArray(proposals.clientId, candidateList),
or(
gte(proposals.updatedAt, since),
gte(proposals.createdAt, since),
gte(proposals.validUntil, now),
),
),
);

for (const row of recentProposalRows) {
activeIds.add(row.clientId);
}

return [...activeIds];
},
};
61 changes: 61 additions & 0 deletions src/server/proposals/privacy/erasure-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'server-only';
import { eq, inArray, and, count, ne } from 'drizzle-orm';
import type { DbClient } from '@/server/db/client';
import { proposals, proposalLineItems } from '@/server/db/schema/proposals';
import type { ErasureProvider, ErasureSubject } from '@/server/privacy/erasure/types';

/**
* Proposals Erasure Provider (TASK-0059 / GDPR Art. 17 / ADR-0018 §6).
* Permanently hard-deletes unaccepted, draft, expired, or declined proposals
* and their associated line items during client erasure.
*
* Pre-contractual discussions are not subject to statutory commercial retention.
* Accepted proposals are protected by proposalsHoldProvider and are never deleted.
*/
export const proposalsErasureProvider: ErasureProvider = {
id: 'proposals',
category: 'proposals',
coveredTables: ['proposals', 'proposal_line_items'] as const,
subjectKinds: ['client', 'contact'] as const,

plan: async (subject: ErasureSubject, tx: DbClient): Promise<number> => {
if (subject.kind !== 'client') {
return 0;
}

const [res] = await tx
.select({ count: count() })
.from(proposals)
.where(and(eq(proposals.clientId, subject.clientId), ne(proposals.status, 'accepted')));

return res?.count ?? 0;
},

erase: async (subject: ErasureSubject, tx: DbClient): Promise<number> => {
if (subject.kind !== 'client') {
return 0;
}

const unacceptedProposals = await tx
.select({ id: proposals.id })
.from(proposals)
.where(and(eq(proposals.clientId, subject.clientId), ne(proposals.status, 'accepted')));

if (unacceptedProposals.length === 0) {
return 0;
}

const proposalIds = unacceptedProposals.map((p) => p.id);

// 1. Hard-delete child line items to guarantee zero orphaned records
await tx.delete(proposalLineItems).where(inArray(proposalLineItems.proposalId, proposalIds));

// 2. Hard-delete unaccepted proposal headers
const deleted = await tx
.delete(proposals)
.where(inArray(proposals.id, proposalIds))
.returning({ id: proposals.id });

return deleted.length;
},
};
Loading
Loading