import { getPayload as _getPayload } from "payload"; import type { SanitizedConfig } from "payload"; import type { AuditLogProtocol } from "@repo/core-shared/di"; import type { IDataExport } from "./data-export.interface"; import type { DsrFormat, UserDataBundle, CollectionDataBucket, SubjectReference, } from "./dsr-types"; import type { DsrCollectionCustom } from "./dsr-collection-custom"; // Mirrors packages/core-dsr/src/contexts/user-data.jsonld — update both together. const USER_DATA_JSONLD_CONTEXT: Record = { "@vocab": "https://schema.org/", dsr: "https://w3.org/ns/dpv#", prov: "https://www.w3.org/ns/prov#", subjectId: "identifier", exportedAt: "dateCreated", format: "encodingFormat", data: { "@id": "prov:hadMember", "@container": "@index" }, asSelf: { "@id": "dsr:hasPersonalDataHandling", "@type": "@id" }, asReference: { "@id": "dsr:hasDataSubjectRight", "@type": "@id" }, rowId: "identifier", linkedField: "name", linkedThrough: { "@id": "isPartOf", "@type": "@id" }, auditLog: { "@id": "prov:wasGeneratedBy", "@type": "@id" }, UserDataBundle: "dsr:RightOfAccess", SubjectReference: "dsr:DataSubjectRight", }; type PayloadDoc = Record; type PayloadAPI = { find(args: { collection: string; where: Record; overrideAccess: true; limit: number; }): Promise<{ docs: PayloadDoc[] }>; }; type GetPayload = (args: { config: SanitizedConfig }) => Promise; /** * Payload-backed IDataExport. Walks all collections annotated with * `custom.subject` linkage, segments rows by role (self/owner vs reference), * and filters to fields marked `exportable: true` in `custom.pii`. * * Emits an EXPORT audit entry for every call. The `getPayloadFn` parameter is * injectable for unit tests; production code omits it. */ export class PayloadDataExport implements IDataExport { constructor( private readonly config: SanitizedConfig, private readonly auditLog: AuditLogProtocol, private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload, ) {} async exportSubjectData( subjectId: string, format: DsrFormat, ): Promise { const payload = await this.getPayloadFn({ config: this.config }); const data: Record = {}; for (const collection of this.config.collections) { const custom = (collection.custom ?? {}) as DsrCollectionCustom; if (!custom.subject) continue; const { field, kind } = custom.subject; const where = field === "id" ? { id: { equals: subjectId } } : { [field]: { equals: subjectId } }; const result = await payload.find({ collection: collection.slug, where, overrideAccess: true, limit: 1000, }); if (result.docs.length === 0) continue; if (kind === "self" || kind === "owner") { const piiMeta = custom.pii ?? {}; const exportableFields = Object.entries(piiMeta) .filter(([, m]) => m.exportable) .map(([name]) => name); data[collection.slug] = { asSelf: result.docs.map((doc) => { const row: PayloadDoc = { id: doc["id"] }; for (const f of exportableFields) { if (f in doc) row[f] = doc[f]; } return row; }), }; } else { // reference kind — expose only linking coordinates, not row content data[collection.slug] = { asReference: result.docs.map( (doc): SubjectReference => ({ rowId: String(doc["id"]), linkedField: field, linkedThrough: collection.slug, }), ), }; } } await this.auditLog.record({ actorId: subjectId, actorType: "user", actorRoles: [], action: "EXPORT", resource: { type: "subject-data" }, at: new Date(), scope: { feature: "core-dsr", environment: process.env["NODE_ENV"] ?? "development", tenant: "default", }, from: { ipTruncated: "system", userAgent: "system" }, containsPii: false, outcome: "success", }); const bundle: UserDataBundle = { subjectId, exportedAt: new Date().toISOString(), format, data, }; if (format === "json-ld") { bundle["@context"] = USER_DATA_JSONLD_CONTEXT; } return bundle; } }