feat(core-dsr): Payload impls, recording doubles, DI binders, contract tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
145
packages/core-dsr/src/payload-data-export.ts
Normal file
145
packages/core-dsr/src/payload-data-export.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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<string, unknown> = {
|
||||
"@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<string, unknown>;
|
||||
|
||||
type PayloadAPI = {
|
||||
find(args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
limit: number;
|
||||
}): Promise<{ docs: PayloadDoc[] }>;
|
||||
};
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||
|
||||
/**
|
||||
* 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<UserDataBundle> {
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
const data: Record<string, CollectionDataBucket> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user