Initial commit
This commit is contained in:
20
packages/core-shared/src/payload/access/is-admin.test.ts
Normal file
20
packages/core-shared/src/payload/access/is-admin.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAdmin } from "./is-admin";
|
||||
|
||||
describe("isAdmin", () => {
|
||||
it("returns true when user role is 'admin'", () => {
|
||||
expect(isAdmin({ req: { user: { role: "admin" } } })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when user role is not 'admin'", () => {
|
||||
expect(isAdmin({ req: { user: { role: "editor" } } })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user has no role", () => {
|
||||
expect(isAdmin({ req: { user: {} } })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there is no user", () => {
|
||||
expect(isAdmin({ req: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
7
packages/core-shared/src/payload/access/is-admin.ts
Normal file
7
packages/core-shared/src/payload/access/is-admin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function isAdmin({
|
||||
req,
|
||||
}: {
|
||||
req: { user?: { role?: string } };
|
||||
}): boolean {
|
||||
return req.user?.role === "admin";
|
||||
}
|
||||
16
packages/core-shared/src/payload/blocks/cta.test.ts
Normal file
16
packages/core-shared/src/payload/blocks/cta.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cta } from "./cta";
|
||||
|
||||
describe("cta block", () => {
|
||||
it("has slug 'cta'", () => {
|
||||
expect(cta.slug).toBe("cta");
|
||||
});
|
||||
|
||||
it("requires title, buttonLabel, and href", () => {
|
||||
const fieldNames = cta.fields.map((f) => ("name" in f ? f.name : null));
|
||||
expect(fieldNames).toEqual(["title", "buttonLabel", "href"]);
|
||||
cta.fields.forEach((f) => {
|
||||
if ("required" in f) expect(f.required).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
10
packages/core-shared/src/payload/blocks/cta.ts
Normal file
10
packages/core-shared/src/payload/blocks/cta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const cta: Block = {
|
||||
slug: "cta",
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "buttonLabel", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
],
|
||||
};
|
||||
30
packages/core-shared/src/payload/fields/seo-fields.test.ts
Normal file
30
packages/core-shared/src/payload/fields/seo-fields.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { seoFields } from "./seo-fields";
|
||||
|
||||
describe("seoFields", () => {
|
||||
it("is a group field named 'seo'", () => {
|
||||
if (seoFields.type !== "group" || !("name" in seoFields)) {
|
||||
throw new Error("seoFields must be a named group");
|
||||
}
|
||||
expect(seoFields.name).toBe("seo");
|
||||
expect(seoFields.type).toBe("group");
|
||||
});
|
||||
|
||||
it("contains required title and optional description", () => {
|
||||
if (seoFields.type !== "group") {
|
||||
throw new Error("seoFields must be a group");
|
||||
}
|
||||
const fieldNames = seoFields.fields.map((f) =>
|
||||
"name" in f ? f.name : null,
|
||||
);
|
||||
expect(fieldNames).toContain("title");
|
||||
expect(fieldNames).toContain("description");
|
||||
|
||||
const titleField = seoFields.fields.find(
|
||||
(f) => "name" in f && f.name === "title",
|
||||
);
|
||||
expect(titleField && "required" in titleField && titleField.required).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
10
packages/core-shared/src/payload/fields/seo-fields.ts
Normal file
10
packages/core-shared/src/payload/fields/seo-fields.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Field } from "payload";
|
||||
|
||||
export const seoFields: Field = {
|
||||
name: "seo",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "description", type: "textarea" },
|
||||
],
|
||||
};
|
||||
19
packages/core-shared/src/payload/fields/slug-field.test.ts
Normal file
19
packages/core-shared/src/payload/fields/slug-field.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { slugField } from "./slug-field";
|
||||
|
||||
describe("slugField", () => {
|
||||
it("returns a Payload Field with default name 'slug'", () => {
|
||||
const field = slugField();
|
||||
if (field.type !== "text") throw new Error("expected text field");
|
||||
expect(field.name).toBe("slug");
|
||||
expect(field.required).toBe(true);
|
||||
expect(field.unique).toBe(true);
|
||||
expect(field.index).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a custom field name", () => {
|
||||
const field = slugField("permalink");
|
||||
if (field.type !== "text") throw new Error("expected text field");
|
||||
expect(field.name).toBe("permalink");
|
||||
});
|
||||
});
|
||||
11
packages/core-shared/src/payload/fields/slug-field.ts
Normal file
11
packages/core-shared/src/payload/fields/slug-field.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Field } from "payload";
|
||||
|
||||
export function slugField(name = "slug"): Field {
|
||||
return {
|
||||
name,
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { setPublishedAt } from "./set-published-at";
|
||||
|
||||
describe("setPublishedAt", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-04T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets publishedAt to now when status is published and publishedAt is missing", () => {
|
||||
const result = setPublishedAt({ data: { status: "published" } });
|
||||
expect(result?.publishedAt).toBe("2026-05-04T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing publishedAt", () => {
|
||||
const result = setPublishedAt({
|
||||
data: { status: "published", publishedAt: "2025-01-01T00:00:00.000Z" },
|
||||
});
|
||||
expect(result?.publishedAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does not set publishedAt when status is not published", () => {
|
||||
const result = setPublishedAt({ data: { status: "draft" } });
|
||||
expect(result?.publishedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns data unchanged when data is missing", () => {
|
||||
expect(setPublishedAt({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
13
packages/core-shared/src/payload/hooks/set-published-at.ts
Normal file
13
packages/core-shared/src/payload/hooks/set-published-at.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function setPublishedAt({
|
||||
data,
|
||||
}: {
|
||||
data?: { status?: string; publishedAt?: string | null };
|
||||
}) {
|
||||
if (!data) return data;
|
||||
|
||||
if (data.status === "published" && !data.publishedAt) {
|
||||
data.publishedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { slugifyIfMissing } from "./slugify-if-missing";
|
||||
|
||||
describe("slugifyIfMissing", () => {
|
||||
it("derives slug from title on create when slug is empty", () => {
|
||||
const result = slugifyIfMissing({
|
||||
data: { title: "Hello World" },
|
||||
operation: "create",
|
||||
});
|
||||
expect(result?.slug).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing slug", () => {
|
||||
const result = slugifyIfMissing({
|
||||
data: { title: "New Title", slug: "kept-slug" },
|
||||
operation: "create",
|
||||
});
|
||||
expect(result?.slug).toBe("kept-slug");
|
||||
});
|
||||
|
||||
it("does nothing on update", () => {
|
||||
const result = slugifyIfMissing({
|
||||
data: { title: "Hello World" },
|
||||
operation: "update",
|
||||
});
|
||||
expect(result?.slug).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips non-alphanumerics and trims hyphens", () => {
|
||||
const result = slugifyIfMissing({
|
||||
data: { title: " Hello, World!! 2026 " },
|
||||
operation: "create",
|
||||
});
|
||||
expect(result?.slug).toBe("hello-world-2026");
|
||||
});
|
||||
|
||||
it("returns data unchanged when title is missing", () => {
|
||||
const result = slugifyIfMissing({
|
||||
data: {},
|
||||
operation: "create",
|
||||
});
|
||||
expect(result?.slug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
19
packages/core-shared/src/payload/hooks/slugify-if-missing.ts
Normal file
19
packages/core-shared/src/payload/hooks/slugify-if-missing.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function slugifyIfMissing({
|
||||
data,
|
||||
operation,
|
||||
}: {
|
||||
data?: { title?: string; slug?: string };
|
||||
operation?: string;
|
||||
}) {
|
||||
if (!data) return data;
|
||||
if (operation !== "create") return data;
|
||||
if (data.slug) return data;
|
||||
if (!data.title) return data;
|
||||
|
||||
data.slug = data.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return data;
|
||||
}
|
||||
32
packages/core-shared/src/payload/index.ts
Normal file
32
packages/core-shared/src/payload/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export { isAdmin } from "./access/is-admin";
|
||||
export { slugField } from "./fields/slug-field";
|
||||
export { seoFields } from "./fields/seo-fields";
|
||||
export { cta } from "./blocks/cta";
|
||||
export { setPublishedAt } from "./hooks/set-published-at";
|
||||
export { slugifyIfMissing } from "./hooks/slugify-if-missing";
|
||||
export type {
|
||||
PiiCategory,
|
||||
DataProcessingPurpose,
|
||||
RetentionTrigger,
|
||||
RetentionAction,
|
||||
FieldRetention,
|
||||
FieldPii,
|
||||
} from "./pii-types";
|
||||
export { PAYLOAD_AUTH_PII_DEFAULTS } from "./pii-types";
|
||||
export type { PurgeSchedule, CollectionRetention } from "./retention-types";
|
||||
export type {
|
||||
SubjectLinkKind,
|
||||
SubjectLink,
|
||||
CollectionSubject,
|
||||
} from "./subject-linkage-types";
|
||||
export {
|
||||
parseDurationMs,
|
||||
scheduleDelayMs,
|
||||
buildPurgeHandler,
|
||||
registerRetentionPurgeJobs,
|
||||
} from "./retention-purge/retention-purge.job";
|
||||
export type {
|
||||
PayloadPurgeApi,
|
||||
GetPayloadFn,
|
||||
RetentionPurgeJobDeps,
|
||||
} from "./retention-purge/retention-purge.job";
|
||||
18
packages/core-shared/src/payload/payload-custom-ambient.d.ts
vendored
Normal file
18
packages/core-shared/src/payload/payload-custom-ambient.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FieldPii } from "./pii-types";
|
||||
import type { CollectionRetention } from "./retention-types";
|
||||
import type { CollectionSubject } from "./subject-linkage-types";
|
||||
|
||||
declare module "payload" {
|
||||
// FieldBase.custom is typed as FieldCustom (interface extending Record<string, any>).
|
||||
// Augmenting it makes pii available on every field type.
|
||||
interface FieldCustom {
|
||||
pii?: FieldPii;
|
||||
}
|
||||
|
||||
// CollectionConfig.custom is typed as CollectionCustom (interface extending Record<string, any>).
|
||||
interface CollectionCustom {
|
||||
retention?: CollectionRetention;
|
||||
authPii?: Record<string, FieldPii | null>;
|
||||
subject?: CollectionSubject | CollectionSubject[];
|
||||
}
|
||||
}
|
||||
107
packages/core-shared/src/payload/pii-types.test.ts
Normal file
107
packages/core-shared/src/payload/pii-types.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PAYLOAD_AUTH_PII_DEFAULTS, type FieldPii } from "./pii-types";
|
||||
|
||||
const CREDENTIAL_FIELDS = [
|
||||
"password",
|
||||
"salt",
|
||||
"hash",
|
||||
"resetPasswordToken",
|
||||
"resetPasswordExpiration",
|
||||
"loginAttempts",
|
||||
"lockUntil",
|
||||
"apiKey",
|
||||
"apiKeyIndex",
|
||||
] as const;
|
||||
|
||||
const DSR_MANAGED_FIELDS = ["processingRestrictedAt", "consentState"] as const;
|
||||
|
||||
describe("FieldPii type safety", () => {
|
||||
it("rejects FieldPii missing required fields at compile time", () => {
|
||||
// @ts-expect-error — 'purpose', 'exportable', 'restrictable' are required
|
||||
const _missingRequired: FieldPii = { category: "contact-email" };
|
||||
void _missingRequired;
|
||||
});
|
||||
|
||||
it("rejects FieldPii missing exportable at compile time", () => {
|
||||
// @ts-expect-error — 'exportable' is required
|
||||
const _missingExportable: FieldPii = {
|
||||
category: "contact-email",
|
||||
purpose: ["account-authentication"],
|
||||
restrictable: true,
|
||||
};
|
||||
void _missingExportable;
|
||||
});
|
||||
});
|
||||
|
||||
describe("FieldPii valid shapes", () => {
|
||||
it("accepts a minimal valid FieldPii", () => {
|
||||
const valid: FieldPii = {
|
||||
category: "contact-email",
|
||||
purpose: ["account-authentication"],
|
||||
exportable: true,
|
||||
restrictable: false,
|
||||
};
|
||||
expect(valid.category).toBe("contact-email");
|
||||
expect(valid.retention).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts FieldPii with optional retention", () => {
|
||||
const withRetention: FieldPii = {
|
||||
category: "network-ip",
|
||||
purpose: ["analytics-aggregation"],
|
||||
exportable: false,
|
||||
restrictable: false,
|
||||
retention: {
|
||||
duration: "P30D",
|
||||
trigger: "from-creation",
|
||||
action: "hard-delete",
|
||||
},
|
||||
};
|
||||
expect(withRetention.retention?.duration).toBe("P30D");
|
||||
expect(withRetention.retention?.trigger).toBe("from-creation");
|
||||
expect(withRetention.retention?.action).toBe("hard-delete");
|
||||
});
|
||||
|
||||
it("accepts a custom PiiCategory string via extension escape hatch", () => {
|
||||
const extended: FieldPii = {
|
||||
category: "custom-biometric-data",
|
||||
purpose: ["legal-compliance"],
|
||||
exportable: false,
|
||||
restrictable: true,
|
||||
};
|
||||
expect(extended.category).toBe("custom-biometric-data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PAYLOAD_AUTH_PII_DEFAULTS", () => {
|
||||
it("sets all credential fields to null", () => {
|
||||
for (const field of CREDENTIAL_FIELDS) {
|
||||
expect(PAYLOAD_AUTH_PII_DEFAULTS[field]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps email to a non-null FieldPii with correct shape", () => {
|
||||
const emailPii = PAYLOAD_AUTH_PII_DEFAULTS["email"];
|
||||
expect(emailPii).not.toBeNull();
|
||||
expect(emailPii?.category).toBe("contact-email");
|
||||
expect(emailPii?.purpose).toContain("account-authentication");
|
||||
expect(emailPii?.purpose).toContain("transactional-notifications");
|
||||
expect(emailPii?.exportable).toBe(true);
|
||||
expect(emailPii?.restrictable).toBe(true);
|
||||
});
|
||||
|
||||
it("has exactly 12 keys: email, 9 credential fields, and 2 DSR-managed fields", () => {
|
||||
expect(Object.keys(PAYLOAD_AUTH_PII_DEFAULTS)).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("sets DSR-managed fields to null", () => {
|
||||
for (const field of DSR_MANAGED_FIELDS) {
|
||||
expect(PAYLOAD_AUTH_PII_DEFAULTS[field]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("email has no retention override (falls back to collection-level)", () => {
|
||||
const emailPii = PAYLOAD_AUTH_PII_DEFAULTS["email"];
|
||||
expect(emailPii?.retention).toBeUndefined();
|
||||
});
|
||||
});
|
||||
66
packages/core-shared/src/payload/pii-types.ts
Normal file
66
packages/core-shared/src/payload/pii-types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export type PiiCategory =
|
||||
| "contact-email"
|
||||
| "contact-phone"
|
||||
| "contact-address"
|
||||
| "identification-name"
|
||||
| "identification-username"
|
||||
| "identification-government-id"
|
||||
| "auth-credential"
|
||||
| "auth-token"
|
||||
| "network-ip"
|
||||
| "network-user-agent"
|
||||
| "financial-info"
|
||||
| "behavioral-engagement"
|
||||
| "document-content"
|
||||
| "derived-metric"
|
||||
| (string & Record<never, never>);
|
||||
|
||||
export type DataProcessingPurpose =
|
||||
| "account-authentication"
|
||||
| "transactional-notifications"
|
||||
| "marketing-communications"
|
||||
| "analytics-aggregation"
|
||||
| "legal-compliance"
|
||||
| "service-delivery"
|
||||
| (string & Record<never, never>);
|
||||
|
||||
export type RetentionTrigger =
|
||||
| "from-creation"
|
||||
| "from-last-access"
|
||||
| "after-deletion";
|
||||
|
||||
export type RetentionAction = "hard-delete" | "pseudonymize";
|
||||
|
||||
export type FieldRetention = {
|
||||
duration: string;
|
||||
trigger: RetentionTrigger;
|
||||
action: RetentionAction;
|
||||
};
|
||||
|
||||
export type FieldPii = {
|
||||
category: PiiCategory;
|
||||
purpose: DataProcessingPurpose[];
|
||||
retention?: FieldRetention;
|
||||
exportable: boolean;
|
||||
restrictable: boolean;
|
||||
};
|
||||
|
||||
export const PAYLOAD_AUTH_PII_DEFAULTS: Record<string, FieldPii | null> = {
|
||||
email: {
|
||||
category: "contact-email",
|
||||
purpose: ["account-authentication", "transactional-notifications"],
|
||||
exportable: true,
|
||||
restrictable: true,
|
||||
},
|
||||
password: null,
|
||||
salt: null,
|
||||
hash: null,
|
||||
resetPasswordToken: null,
|
||||
resetPasswordExpiration: null,
|
||||
loginAttempts: null,
|
||||
lockUntil: null,
|
||||
apiKey: null,
|
||||
apiKeyIndex: null,
|
||||
processingRestrictedAt: null,
|
||||
consentState: null,
|
||||
};
|
||||
@@ -0,0 +1,608 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { IJobQueue } from "@/jobs/job-queue.interface";
|
||||
import type { AuditLogProtocol } from "@/di/bind-protocols";
|
||||
import {
|
||||
parseDurationMs,
|
||||
scheduleDelayMs,
|
||||
buildPurgeHandler,
|
||||
registerRetentionPurgeJobs,
|
||||
type PayloadPurgeApi,
|
||||
type RetentionPurgeJobDeps,
|
||||
} from "./retention-purge.job";
|
||||
|
||||
// ---- test helpers ----
|
||||
|
||||
type MockCollection = {
|
||||
slug: string;
|
||||
custom?: { retention?: Record<string, unknown> };
|
||||
fields?: Array<{ name?: string; custom?: { pii?: unknown } }>;
|
||||
};
|
||||
|
||||
function makeConfig(collections: MockCollection[]): SanitizedConfig {
|
||||
return { collections } as unknown as SanitizedConfig;
|
||||
}
|
||||
|
||||
function makeQueue() {
|
||||
const enqueue = vi.fn().mockResolvedValue({ jobId: "job-1" });
|
||||
const queue = { enqueue } as unknown as IJobQueue;
|
||||
return { queue, enqueue };
|
||||
}
|
||||
|
||||
function makePayloadApi(
|
||||
docs: Array<Record<string, unknown>> = [],
|
||||
): PayloadPurgeApi {
|
||||
return {
|
||||
find: vi.fn().mockResolvedValue({ docs }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuditLog(): {
|
||||
auditLog: AuditLogProtocol;
|
||||
record: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const record = vi.fn().mockResolvedValue(undefined);
|
||||
return { auditLog: { record } as AuditLogProtocol, record };
|
||||
}
|
||||
|
||||
// ---- parseDurationMs ----
|
||||
|
||||
describe("parseDurationMs", () => {
|
||||
it("parses years: P2Y → 2 × 365 days", () => {
|
||||
expect(parseDurationMs("P2Y")).toBe(2 * 365 * 86_400_000);
|
||||
});
|
||||
|
||||
it("parses months: P1M → 30 days", () => {
|
||||
expect(parseDurationMs("P1M")).toBe(30 * 86_400_000);
|
||||
});
|
||||
|
||||
it("parses weeks: P1W → 7 days", () => {
|
||||
expect(parseDurationMs("P1W")).toBe(7 * 86_400_000);
|
||||
});
|
||||
|
||||
it("parses days: P30D → 30 days", () => {
|
||||
expect(parseDurationMs("P30D")).toBe(30 * 86_400_000);
|
||||
});
|
||||
|
||||
it("combines components: P1Y2M3D", () => {
|
||||
expect(parseDurationMs("P1Y2M3D")).toBe(
|
||||
365 * 86_400_000 + 60 * 86_400_000 + 3 * 86_400_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 0 for P0D", () => {
|
||||
expect(parseDurationMs("P0D")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for unrecognised strings", () => {
|
||||
expect(parseDurationMs("invalid")).toBe(0);
|
||||
expect(parseDurationMs("")).toBe(0);
|
||||
expect(parseDurationMs("PT2H")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- scheduleDelayMs ----
|
||||
|
||||
describe("scheduleDelayMs", () => {
|
||||
it("returns 1 day for 'daily'", () => {
|
||||
expect(scheduleDelayMs("daily")).toBe(86_400_000);
|
||||
});
|
||||
|
||||
it("returns 7 days for 'weekly'", () => {
|
||||
expect(scheduleDelayMs("weekly")).toBe(7 * 86_400_000);
|
||||
});
|
||||
|
||||
it("returns 30 days for 'monthly'", () => {
|
||||
expect(scheduleDelayMs("monthly")).toBe(30 * 86_400_000);
|
||||
});
|
||||
|
||||
it("falls back to 1 day for cron-style strings", () => {
|
||||
expect(scheduleDelayMs("0 3 * * 0")).toBe(86_400_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- registerRetentionPurgeJobs ----
|
||||
|
||||
describe("registerRetentionPurgeJobs", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("enqueues one job per collection with a purgeSchedule", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const config = makeConfig([
|
||||
{ slug: "users", custom: { retention: { purgeSchedule: "daily" } } },
|
||||
{ slug: "articles", custom: { retention: { purgeSchedule: "weekly" } } },
|
||||
]);
|
||||
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
|
||||
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips collections without a retention config", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const config = makeConfig([{ slug: "media" }]);
|
||||
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the correct taskSlug and runAt for each schedule type", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const config = makeConfig([
|
||||
{ slug: "users", custom: { retention: { purgeSchedule: "weekly" } } },
|
||||
]);
|
||||
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
"retention-purge--users",
|
||||
{},
|
||||
{ runAt: new Date("2026-01-08T00:00:00.000Z") },
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules daily purge 1 day from now", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const config = makeConfig([
|
||||
{ slug: "sessions", custom: { retention: { purgeSchedule: "daily" } } },
|
||||
]);
|
||||
await registerRetentionPurgeJobs({ queue, config, getPayload: vi.fn() });
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
"retention-purge--sessions",
|
||||
{},
|
||||
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — input validation ----
|
||||
|
||||
describe("buildPurgeHandler — input validation", () => {
|
||||
it("throws when the collection slug is not found in the config", () => {
|
||||
const { queue } = makeQueue();
|
||||
const config = makeConfig([]);
|
||||
expect(() =>
|
||||
buildPurgeHandler("missing", { queue, config, getPayload: vi.fn() }),
|
||||
).toThrow("collection not found: missing");
|
||||
});
|
||||
|
||||
it("throws when the collection has no retention config", () => {
|
||||
const { queue } = makeQueue();
|
||||
const config = makeConfig([{ slug: "media" }]);
|
||||
expect(() =>
|
||||
buildPurgeHandler("media", { queue, config, getPayload: vi.fn() }),
|
||||
).toThrow("no retention config on collection: media");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — hard-delete branch ----
|
||||
|
||||
describe("buildPurgeHandler — hard-delete", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("queries by createdAt for from-creation trigger", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P2Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
createdAt: {
|
||||
less_than: new Date(
|
||||
Date.now() - parseDurationMs("P2Y"),
|
||||
).toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("queries by updatedAt for from-last-access trigger", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "sessions",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P30D", trigger: "from-last-access" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("sessions", deps)();
|
||||
|
||||
expect(payload.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { updatedAt: { less_than: expect.any(String) } },
|
||||
}),
|
||||
);
|
||||
expect(payload.find).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ createdAt: expect.anything() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes each returned row", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "row-1" }, { id: "row-2" }]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||
expect(payload.delete).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-1",
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.delete).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-2",
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-enqueues itself for the next purge cycle", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
"retention-purge--users",
|
||||
{},
|
||||
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to hard-delete when postDeletion is not declared", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "row-x" }]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "logs",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("logs", deps)();
|
||||
|
||||
expect(payload.delete).toHaveBeenCalledWith({
|
||||
collection: "logs",
|
||||
id: "row-x",
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||
|
||||
describe("buildPurgeHandler — pseudonymize", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("nulls only PII-annotated fields for each matched row", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "row-2" }]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "contacts",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "monthly",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "pseudonymize",
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||
{ name: "phone", custom: { pii: { category: "contact-phone" } } },
|
||||
{ name: "status" }, // no pii — must NOT be nulled
|
||||
],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("contacts", deps)();
|
||||
|
||||
expect(payload.update).toHaveBeenCalledWith({
|
||||
collection: "contacts",
|
||||
id: "row-2",
|
||||
data: { email: null, phone: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips update when no PII fields are declared on the collection", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "row-3" }]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "tags",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "pseudonymize",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{ name: "label" }, // no pii
|
||||
],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("tags", deps)();
|
||||
|
||||
expect(payload.update).not.toHaveBeenCalled();
|
||||
expect(payload.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — audit emission ----
|
||||
|
||||
describe("buildPurgeHandler — audit emission", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("emits one audit record per processed row", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "a" }, { id: "b" }]);
|
||||
const { auditLog, record } = makeAuditLog();
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
auditLog,
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(record).toHaveBeenCalledTimes(2);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
action: "DELETE",
|
||||
reason: "retention-policy",
|
||||
outcome: "success",
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes resource type and id in the audit entry", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "row-42" }]);
|
||||
const { auditLog, record } = makeAuditLog();
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
auditLog,
|
||||
};
|
||||
await buildPurgeHandler("orders", deps)();
|
||||
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: { type: "orders", id: "row-42" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("gracefully skips audit emission when auditLog is not provided", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "x" }]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P0D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await expect(buildPurgeHandler("users", deps)()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips all processing and audit when activeRetention is not declared", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const payload = makePayloadApi([{ id: "y" }]);
|
||||
const { auditLog, record } = makeAuditLog();
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "logs",
|
||||
custom: { retention: { purgeSchedule: "daily" } },
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
auditLog,
|
||||
};
|
||||
await buildPurgeHandler("logs", deps)();
|
||||
|
||||
expect(payload.find).not.toHaveBeenCalled();
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
// Still re-enqueues for the next cycle
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||
|
||||
/**
|
||||
* Minimal Payload API surface needed by the retention purge job.
|
||||
* Injected via getPayload for testability.
|
||||
*/
|
||||
export type PayloadPurgeApi = {
|
||||
find(args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
limit: number;
|
||||
overrideAccess: true;
|
||||
}): Promise<{ docs: Array<Record<string, unknown>> }>;
|
||||
update(args: {
|
||||
collection: string;
|
||||
id: string | number;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}): Promise<unknown>;
|
||||
delete(args: {
|
||||
collection: string;
|
||||
id: string | number;
|
||||
overrideAccess: true;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
|
||||
export type GetPayloadFn = (args: {
|
||||
config: SanitizedConfig;
|
||||
}) => Promise<PayloadPurgeApi>;
|
||||
|
||||
export type RetentionPurgeJobDeps = {
|
||||
queue: IJobQueue;
|
||||
config: SanitizedConfig;
|
||||
getPayload: GetPayloadFn;
|
||||
auditLog?: AuditLogProtocol;
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Parse a subset of ISO 8601 duration notation (date components: Y, M, W, D)
|
||||
* to milliseconds. Returns 0 for unrecognised patterns.
|
||||
* Approximations: 1 year = 365 days, 1 month = 30 days.
|
||||
*/
|
||||
export function parseDurationMs(iso: string): number {
|
||||
const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/.exec(iso);
|
||||
if (!match) return 0;
|
||||
const years = parseInt(match[1] ?? "0", 10);
|
||||
const months = parseInt(match[2] ?? "0", 10);
|
||||
const weeks = parseInt(match[3] ?? "0", 10);
|
||||
const days = parseInt(match[4] ?? "0", 10);
|
||||
return (
|
||||
years * 365 * MS_PER_DAY +
|
||||
months * 30 * MS_PER_DAY +
|
||||
weeks * 7 * MS_PER_DAY +
|
||||
days * MS_PER_DAY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PurgeSchedule value to the delay in ms before the next run.
|
||||
* Cron-style strings fall back to daily cadence; the Payload scheduler handles
|
||||
* actual cron-aligned firing.
|
||||
*/
|
||||
export function scheduleDelayMs(schedule: string): number {
|
||||
if (schedule === "weekly") return 7 * MS_PER_DAY;
|
||||
if (schedule === "monthly") return 30 * MS_PER_DAY;
|
||||
return MS_PER_DAY; // "daily" and cron fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the purge handler for a single collection. The returned async function
|
||||
* is intended to be registered as a Payload job task handler.
|
||||
*
|
||||
* Per run:
|
||||
* 1. Query rows past their activeRetention period.
|
||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
||||
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
||||
* 4. Re-enqueue itself for the next purge cycle.
|
||||
*
|
||||
* `from-last-access` uses updatedAt as a proxy; a dedicated lastAccessedAt hook
|
||||
* is deferred to Q2 per the PRD.
|
||||
*/
|
||||
export function buildPurgeHandler(
|
||||
collectionSlug: string,
|
||||
deps: RetentionPurgeJobDeps,
|
||||
): () => Promise<void> {
|
||||
const { queue, config, getPayload, auditLog } = deps;
|
||||
|
||||
const collection = config.collections.find((c) => c.slug === collectionSlug);
|
||||
if (!collection) {
|
||||
throw new Error(`retention-purge: collection not found: ${collectionSlug}`);
|
||||
}
|
||||
|
||||
const retention = collection.custom?.retention;
|
||||
if (!retention) {
|
||||
throw new Error(
|
||||
`retention-purge: no retention config on collection: ${collectionSlug}`,
|
||||
);
|
||||
}
|
||||
|
||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||
|
||||
return async () => {
|
||||
const payload = await getPayload({ config });
|
||||
const now = Date.now();
|
||||
|
||||
if (retention.activeRetention) {
|
||||
const { duration, trigger } = retention.activeRetention;
|
||||
const retentionMs = parseDurationMs(duration);
|
||||
const cutoff = new Date(now - retentionMs).toISOString();
|
||||
const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt";
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: { [dateField]: { less_than: cutoff } },
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const action = retention.postDeletion?.action ?? "hard-delete";
|
||||
|
||||
for (const doc of docs) {
|
||||
const id = doc["id"] as string | number;
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of collection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason: "retention-policy",
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const delay = scheduleDelayMs(retention.purgeSchedule);
|
||||
await queue.enqueue(taskSlug, {}, { runAt: new Date(now + delay) });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk all Payload collections that declare `custom.retention.purgeSchedule`
|
||||
* and schedule the first purge run for each via the provided IJobQueue.
|
||||
*
|
||||
* Call once at app startup (inside bindAll or equivalent). Idempotent per
|
||||
* queue implementation — duplicate enqueues are the queue's responsibility.
|
||||
*/
|
||||
export async function registerRetentionPurgeJobs(
|
||||
deps: RetentionPurgeJobDeps,
|
||||
): Promise<void> {
|
||||
const { queue, config } = deps;
|
||||
const now = Date.now();
|
||||
|
||||
for (const collection of config.collections) {
|
||||
const retention = collection.custom?.retention;
|
||||
if (!retention?.purgeSchedule) continue;
|
||||
|
||||
const taskSlug = `retention-purge--${collection.slug}`;
|
||||
const delay = scheduleDelayMs(retention.purgeSchedule);
|
||||
await queue.enqueue(taskSlug, {}, { runAt: new Date(now + delay) });
|
||||
}
|
||||
}
|
||||
41
packages/core-shared/src/payload/retention-types.test.ts
Normal file
41
packages/core-shared/src/payload/retention-types.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CollectionRetention, PurgeSchedule } from "./retention-types";
|
||||
|
||||
describe("CollectionRetention type safety", () => {
|
||||
it("accepts a minimal CollectionRetention with only purgeSchedule", () => {
|
||||
const minimal: CollectionRetention = { purgeSchedule: "daily" };
|
||||
expect(minimal.purgeSchedule).toBe("daily");
|
||||
expect(minimal.activeRetention).toBeUndefined();
|
||||
expect(minimal.postDeletion).toBeUndefined();
|
||||
expect(minimal.coldArchive).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a full CollectionRetention", () => {
|
||||
const full: CollectionRetention = {
|
||||
purgeSchedule: "weekly",
|
||||
activeRetention: { duration: "P2Y", trigger: "from-last-access" },
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "pseudonymize",
|
||||
},
|
||||
coldArchive: { duration: "P7Y", trigger: "from-creation" },
|
||||
};
|
||||
expect(full.activeRetention?.duration).toBe("P2Y");
|
||||
expect(full.postDeletion?.action).toBe("pseudonymize");
|
||||
expect(full.coldArchive?.duration).toBe("P7Y");
|
||||
});
|
||||
|
||||
it("accepts a cron expression as PurgeSchedule via string extension", () => {
|
||||
const schedule: PurgeSchedule = "0 3 * * 0";
|
||||
expect(schedule).toBe("0 3 * * 0");
|
||||
});
|
||||
|
||||
it("rejects CollectionRetention missing required purgeSchedule at compile time", () => {
|
||||
// @ts-expect-error — 'purgeSchedule' is required
|
||||
const _missing: CollectionRetention = {
|
||||
activeRetention: { duration: "P1Y", trigger: "from-creation" },
|
||||
};
|
||||
void _missing;
|
||||
});
|
||||
});
|
||||
21
packages/core-shared/src/payload/retention-types.ts
Normal file
21
packages/core-shared/src/payload/retention-types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { RetentionAction } from "./pii-types";
|
||||
|
||||
export type PurgeSchedule =
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| (string & Record<never, never>);
|
||||
|
||||
export type CollectionRetention = {
|
||||
activeRetention?: {
|
||||
duration: string;
|
||||
trigger: "from-creation" | "from-last-access";
|
||||
};
|
||||
postDeletion?: {
|
||||
duration: string;
|
||||
trigger: "after-deletion";
|
||||
action: RetentionAction;
|
||||
};
|
||||
purgeSchedule: PurgeSchedule;
|
||||
coldArchive?: { duration: string; trigger: "from-creation" };
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CollectionSubject,
|
||||
SubjectLink,
|
||||
SubjectLinkKind,
|
||||
} from "./subject-linkage-types";
|
||||
|
||||
describe("SubjectLinkKind", () => {
|
||||
it("accepts all valid kinds", () => {
|
||||
const kinds: SubjectLinkKind[] = ["self", "owner", "reference"];
|
||||
expect(kinds).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubjectLink type safety", () => {
|
||||
it("accepts a minimal self-link", () => {
|
||||
const link: SubjectLink = { field: "id", kind: "self" };
|
||||
expect(link.field).toBe("id");
|
||||
expect(link.kind).toBe("self");
|
||||
expect(link.target).toBeUndefined();
|
||||
expect(link.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a reference link with target and role", () => {
|
||||
const link: SubjectLink = {
|
||||
field: "createdBy",
|
||||
kind: "reference",
|
||||
target: "users",
|
||||
role: "author",
|
||||
};
|
||||
expect(link.target).toBe("users");
|
||||
expect(link.role).toBe("author");
|
||||
});
|
||||
|
||||
it("accepts an owner link with target", () => {
|
||||
const link: SubjectLink = {
|
||||
field: "userId",
|
||||
kind: "owner",
|
||||
target: "users",
|
||||
};
|
||||
expect(link.kind).toBe("owner");
|
||||
expect(link.target).toBe("users");
|
||||
});
|
||||
|
||||
it("rejects a SubjectLink missing required field at compile time", () => {
|
||||
// @ts-expect-error — 'field' is required
|
||||
const _missing: SubjectLink = { kind: "self" };
|
||||
void _missing;
|
||||
});
|
||||
|
||||
it("rejects a SubjectLink missing required kind at compile time", () => {
|
||||
// @ts-expect-error — 'kind' is required
|
||||
const _missing: SubjectLink = { field: "id" };
|
||||
void _missing;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CollectionSubject", () => {
|
||||
it("is assignable from a SubjectLink", () => {
|
||||
const subject: CollectionSubject = { field: "id", kind: "self" };
|
||||
expect(subject.kind).toBe("self");
|
||||
});
|
||||
|
||||
it("accepts an array of CollectionSubject entries for multi-linkage", () => {
|
||||
const subjects: CollectionSubject[] = [
|
||||
{ field: "id", kind: "self" },
|
||||
{ field: "authorId", kind: "owner", target: "users" },
|
||||
];
|
||||
expect(subjects).toHaveLength(2);
|
||||
expect(subjects[0]?.kind).toBe("self");
|
||||
expect(subjects[1]?.kind).toBe("owner");
|
||||
});
|
||||
});
|
||||
10
packages/core-shared/src/payload/subject-linkage-types.ts
Normal file
10
packages/core-shared/src/payload/subject-linkage-types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type SubjectLinkKind = "self" | "owner" | "reference";
|
||||
|
||||
export type SubjectLink = {
|
||||
field: string;
|
||||
kind: SubjectLinkKind;
|
||||
target?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
export type CollectionSubject = SubjectLink;
|
||||
Reference in New Issue
Block a user