146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
}
|