@openlup/platform 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +369 -156
- package/README.md +220 -4
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/marketingContribution.d.ts +21 -0
- package/dist/composition/marketingContribution.js +29 -0
- package/dist/composition/nodeHost.d.ts +1 -1
- package/dist/composition/nodeHost.js +3 -2
- package/dist/lifecycle/appUpgrade.d.ts +52 -0
- package/dist/lifecycle/appUpgrade.js +180 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
- package/dist/lifecycle/postgresInstallQualification.js +74 -0
- package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
- package/dist/marketing/acquisitionCaseSchemas.js +53 -0
- package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
- package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
- package/dist/marketing/analytics/dataLayer.d.ts +18 -0
- package/dist/marketing/analytics/dataLayer.js +111 -0
- package/dist/marketing/analytics/noop.d.ts +9 -0
- package/dist/marketing/analytics/noop.js +8 -0
- package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
- package/dist/marketing/analytics/purchaseFacts.js +69 -0
- package/dist/marketing/analytics/routePolicy.d.ts +10 -0
- package/dist/marketing/analytics/routePolicy.js +59 -0
- package/dist/marketing/consentEvidence.d.ts +8 -0
- package/dist/marketing/consentEvidence.js +32 -0
- package/dist/marketing/contactLegal.d.ts +14 -0
- package/dist/marketing/contactLegal.js +19 -0
- package/dist/marketing/contracts/acquisition.d.ts +88 -0
- package/dist/marketing/contracts/acquisition.js +1 -0
- package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
- package/dist/marketing/contracts/acquisitionPorts.js +1 -0
- package/dist/marketing/contracts/analytics.d.ts +144 -0
- package/dist/marketing/contracts/analytics.js +1 -0
- package/dist/marketing/contracts/contactLegal.d.ts +53 -0
- package/dist/marketing/contracts/contactLegal.js +1 -0
- package/dist/marketing/contracts/index.d.ts +6 -0
- package/dist/marketing/contracts/index.js +1 -0
- package/dist/marketing/contracts/prelaunch.d.ts +39 -0
- package/dist/marketing/contracts/prelaunch.js +1 -0
- package/dist/marketing/index.d.ts +16 -0
- package/dist/marketing/index.js +14 -0
- package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
- package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
- package/dist/marketing/seo/jsonLd.d.ts +157 -0
- package/dist/marketing/seo/jsonLd.js +127 -0
- package/dist/marketing/seo/metadata.d.ts +41 -0
- package/dist/marketing/seo/metadata.js +36 -0
- package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
- package/dist/marketing/seo/serializeJsonLd.js +6 -0
- package/dist/marketing/seo/urls.d.ts +14 -0
- package/dist/marketing/seo/urls.js +23 -0
- package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
- package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
- package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
- package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
- package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
- package/dist/marketing/server/acquisitionCasePorts.js +1 -0
- package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
- package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
- package/dist/marketing/server/analyticsContext.d.ts +3 -0
- package/dist/marketing/server/analyticsContext.js +65 -0
- package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
- package/dist/marketing/server/analyticsContextPorts.js +1 -0
- package/dist/marketing/server/bindings.d.ts +14 -0
- package/dist/marketing/server/bindings.js +18 -0
- package/dist/marketing/server/directPorts.d.ts +26 -0
- package/dist/marketing/server/directPorts.js +1 -0
- package/dist/marketing/server/index.d.ts +14 -0
- package/dist/marketing/server/index.js +11 -0
- package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
- package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
- package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
- package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
- package/dist/marketing/server/postgres/index.d.ts +3 -0
- package/dist/marketing/server/postgres/index.js +3 -0
- package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
- package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
- package/dist/marketing/server/privacyContributor.d.ts +66 -0
- package/dist/marketing/server/privacyContributor.js +87 -0
- package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
- package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
- package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
- package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
- package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
- package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
- package/dist/marketing/validation.d.ts +2 -0
- package/dist/marketing/validation.js +3 -0
- package/dist/migrations/postgresRunner.d.ts +9 -0
- package/dist/migrations/postgresRunner.js +17 -10
- package/dist/operations/adminAlertsSchemas.d.ts +153 -0
- package/dist/operations/adminAlertsSchemas.js +69 -0
- package/dist/operations/adminAlertsView.d.ts +73 -0
- package/dist/operations/adminAlertsView.js +184 -0
- package/dist/operations/alertPagingPolicy.d.ts +15 -0
- package/dist/operations/alertPagingPolicy.js +43 -0
- package/dist/operations/alertThrottlePolicy.d.ts +3 -0
- package/dist/operations/alertThrottlePolicy.js +18 -0
- package/dist/operations/clients.d.ts +17 -0
- package/dist/operations/clients.js +32 -0
- package/dist/operations/contracts/admin.d.ts +93 -0
- package/dist/operations/contracts/admin.js +1 -0
- package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
- package/dist/operations/contracts/adminAlertsContracts.js +1 -0
- package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
- package/dist/operations/contracts/alertHumanContext.js +1 -0
- package/dist/operations/contracts/index.d.ts +6 -0
- package/dist/operations/contracts/index.js +6 -0
- package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
- package/dist/operations/contracts/observabilityContracts.js +1 -0
- package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
- package/dist/operations/contracts/observabilityPorts.js +1 -0
- package/dist/operations/contracts/ports.d.ts +19 -0
- package/dist/operations/contracts/ports.js +1 -0
- package/dist/operations/errors.d.ts +6 -0
- package/dist/operations/errors.js +6 -0
- package/dist/operations/index.d.ts +9 -0
- package/dist/operations/index.js +9 -0
- package/dist/operations/schemas.d.ts +138 -0
- package/dist/operations/schemas.js +104 -0
- package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
- package/dist/operations/server/adminAlertsHandlers.js +35 -0
- package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
- package/dist/operations/server/adminPipelineHandlers.js +48 -0
- package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
- package/dist/operations/server/adminSettingsHandlers.js +82 -0
- package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
- package/dist/operations/server/controlPlaneHandler.js +54 -0
- package/dist/operations/server/controlPlaneStore.d.ts +12 -0
- package/dist/operations/server/controlPlaneStore.js +138 -0
- package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
- package/dist/operations/server/controlPlaneWatchdog.js +20 -0
- package/dist/operations/server/index.d.ts +12 -0
- package/dist/operations/server/index.js +12 -0
- package/dist/operations/server/migrations.d.ts +14 -0
- package/dist/operations/server/migrations.js +7 -0
- package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
- package/dist/operations/server/platformWatchdogDecision.js +71 -0
- package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
- package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
- package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
- package/dist/operations/server/platformWatchdogQueue.js +94 -0
- package/dist/operations/server/platformWatchdogService.d.ts +47 -0
- package/dist/operations/server/platformWatchdogService.js +105 -0
- package/dist/operations/validation.d.ts +2 -0
- package/dist/operations/validation.js +3 -0
- package/dist/portability/filesystemObjects.d.ts +12 -0
- package/dist/portability/filesystemObjects.js +191 -0
- package/dist/portability/index.d.ts +5 -0
- package/dist/portability/index.js +4 -0
- package/dist/portability/objects.d.ts +63 -0
- package/dist/portability/objects.js +138 -0
- package/dist/portability/postgresRows.d.ts +64 -0
- package/dist/portability/postgresRows.js +182 -0
- package/dist/scheduler/advisoryLock.js +20 -2
- package/dist/scheduler/nodeScheduler.d.ts +2 -2
- package/dist/scheduler/nodeScheduler.js +35 -6
- package/package.json +47 -2
- package/release-gates.json +143 -9
- package/sql/20260814010000_platform_control_plane.sql +286 -0
- package/src/composition/index.ts +1 -0
- package/src/composition/marketingContribution.ts +37 -0
- package/src/composition/nodeHost.ts +4 -3
- package/src/lifecycle/appUpgrade.ts +186 -0
- package/src/lifecycle/index.ts +19 -0
- package/src/lifecycle/postgresInstallQualification.ts +125 -0
- package/src/marketing/acquisitionCaseSchemas.ts +75 -0
- package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
- package/src/marketing/analytics/dataLayer.ts +100 -0
- package/src/marketing/analytics/noop.ts +14 -0
- package/src/marketing/analytics/purchaseFacts.ts +63 -0
- package/src/marketing/analytics/routePolicy.ts +55 -0
- package/src/marketing/consentEvidence.ts +36 -0
- package/src/marketing/contactLegal.ts +22 -0
- package/src/marketing/contracts/acquisition.ts +50 -0
- package/src/marketing/contracts/acquisitionPorts.ts +48 -0
- package/src/marketing/contracts/analytics.ts +88 -0
- package/src/marketing/contracts/contactLegal.ts +41 -0
- package/src/marketing/contracts/index.ts +17 -0
- package/src/marketing/contracts/prelaunch.ts +15 -0
- package/src/marketing/index.ts +16 -0
- package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
- package/src/marketing/seo/jsonLd.ts +153 -0
- package/src/marketing/seo/metadata.ts +62 -0
- package/src/marketing/seo/serializeJsonLd.ts +6 -0
- package/src/marketing/seo/urls.ts +34 -0
- package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
- package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
- package/src/marketing/server/acquisitionCasePorts.ts +42 -0
- package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
- package/src/marketing/server/analyticsContext.ts +55 -0
- package/src/marketing/server/analyticsContextPorts.ts +28 -0
- package/src/marketing/server/bindings.ts +27 -0
- package/src/marketing/server/directPorts.ts +27 -0
- package/src/marketing/server/index.ts +14 -0
- package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
- package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
- package/src/marketing/server/postgres/index.ts +3 -0
- package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
- package/src/marketing/server/privacyContributor.ts +123 -0
- package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
- package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
- package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
- package/src/marketing/validation.ts +3 -0
- package/src/migrations/postgresRunner.ts +24 -9
- package/src/operations/adminAlertsSchemas.ts +78 -0
- package/src/operations/adminAlertsView.ts +238 -0
- package/src/operations/alertPagingPolicy.ts +50 -0
- package/src/operations/alertThrottlePolicy.ts +22 -0
- package/src/operations/clients.ts +44 -0
- package/src/operations/contracts/admin.ts +24 -0
- package/src/operations/contracts/adminAlertsContracts.ts +13 -0
- package/src/operations/contracts/alertHumanContext.ts +17 -0
- package/src/operations/contracts/index.ts +6 -0
- package/src/operations/contracts/observabilityContracts.ts +40 -0
- package/src/operations/contracts/observabilityPorts.ts +30 -0
- package/src/operations/contracts/ports.ts +22 -0
- package/src/operations/errors.ts +6 -0
- package/src/operations/index.ts +9 -0
- package/src/operations/schemas.ts +122 -0
- package/src/operations/server/adminAlertsHandlers.ts +61 -0
- package/src/operations/server/adminPipelineHandlers.ts +73 -0
- package/src/operations/server/adminSettingsHandlers.ts +114 -0
- package/src/operations/server/controlPlaneHandler.ts +56 -0
- package/src/operations/server/controlPlaneStore.ts +150 -0
- package/src/operations/server/controlPlaneWatchdog.ts +28 -0
- package/src/operations/server/index.ts +12 -0
- package/src/operations/server/migrations.ts +7 -0
- package/src/operations/server/platformWatchdogDecision.ts +93 -0
- package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
- package/src/operations/server/platformWatchdogQueue.ts +96 -0
- package/src/operations/server/platformWatchdogService.ts +209 -0
- package/src/operations/validation.ts +3 -0
- package/src/portability/filesystemObjects.ts +138 -0
- package/src/portability/index.ts +12 -0
- package/src/portability/objects.ts +160 -0
- package/src/portability/postgresRows.ts +169 -0
- package/src/scheduler/advisoryLock.ts +20 -5
- package/src/scheduler/nodeScheduler.ts +28 -8
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { Pool, type PoolClient } from "pg";
|
|
3
|
+
|
|
4
|
+
import { ACQUISITION_CASE_CONTRACT_VERSION, acquisitionCaseListResponseSchema, acquisitionCaseProjectionSchema } from "../../acquisitionCaseSchemas.js";
|
|
5
|
+
import type { AcquisitionCaseProjection, AcquisitionCaseFailureKind, AcquisitionCaseListQuery, AcquisitionCaseResult } from "../../contracts/index.js";
|
|
6
|
+
import type {
|
|
7
|
+
AcquisitionCasePort,
|
|
8
|
+
AcquisitionCaseSubmitCommand,
|
|
9
|
+
AcquisitionCaseTransitionCommand,
|
|
10
|
+
} from "../acquisitionCasePorts.js";
|
|
11
|
+
|
|
12
|
+
const ROLE = "SET LOCAL ROLE platform_acquisition_runtime";
|
|
13
|
+
const SUBMIT = `SELECT public.acquisition_case_submit_v1(
|
|
14
|
+
$1::text,$2::text,$3::text,$4::text,$5::boolean,$6::text,$7::text,
|
|
15
|
+
$8::text,$9::text,$10::text,$11::text,$12::text,$13::text,$14::timestamptz
|
|
16
|
+
) AS response`;
|
|
17
|
+
const LIST = "SELECT public.acquisition_case_operator_list_v1($1::uuid,$2::timestamptz,$3::uuid,$4::integer) AS response";
|
|
18
|
+
const GET = "SELECT public.acquisition_case_operator_get_v1($1::uuid,$2::text) AS response";
|
|
19
|
+
const TRANSITION = `SELECT public.acquisition_case_transition_v1(
|
|
20
|
+
$1::uuid,$2::text,$3::bigint,$4::text,$5::text,$6::text,$7::timestamptz
|
|
21
|
+
) AS response`;
|
|
22
|
+
const ACTIVE_COUNT = "SELECT public.acquisition_case_active_count_v1() AS response";
|
|
23
|
+
|
|
24
|
+
type Env = { connectionString: string };
|
|
25
|
+
type Options = { poolFactory?: (env: Env) => Pool | Promise<Pool> };
|
|
26
|
+
type Row = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
export function createPostgresAcquisitionCasePort(
|
|
29
|
+
env: Env,
|
|
30
|
+
options: Options = {},
|
|
31
|
+
): AcquisitionCasePort {
|
|
32
|
+
if (!env.connectionString.trim()) throw new Error("acquisition_case_database_url_required");
|
|
33
|
+
let poolPromise: Promise<Pool> | null = null;
|
|
34
|
+
const pool = () => poolPromise ??= Promise.resolve(
|
|
35
|
+
options.poolFactory ? options.poolFactory(env) : new Pool({ connectionString: env.connectionString }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const call = async (sql: string, values: unknown[]): Promise<unknown> =>
|
|
39
|
+
withRole(await pool(), async (client) => {
|
|
40
|
+
const result = await client.query(sql, values);
|
|
41
|
+
return object(result.rows[0])?.response ?? null;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async submit(command) {
|
|
46
|
+
if (command.scope !== "public_tester_application" || command.sourceKind !== "tester_application") {
|
|
47
|
+
return failure("invalid");
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const request = command.request;
|
|
51
|
+
const response = object(await call(SUBMIT, [
|
|
52
|
+
command.idempotencyKey,
|
|
53
|
+
command.requesterKey,
|
|
54
|
+
request.contact.email.trim().toLowerCase(),
|
|
55
|
+
null,
|
|
56
|
+
request.consent.accepted,
|
|
57
|
+
request.consent.consentVersion,
|
|
58
|
+
request.consent.policyVersion,
|
|
59
|
+
request.consent.locale,
|
|
60
|
+
command.sourcePath,
|
|
61
|
+
request.addressReference.source,
|
|
62
|
+
request.addressReference.reference,
|
|
63
|
+
request.addressReference.revision,
|
|
64
|
+
request.addressReference.provenance,
|
|
65
|
+
command.acceptedAt,
|
|
66
|
+
]));
|
|
67
|
+
return projectionResult(response);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return databaseFailure(error);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async list(query) {
|
|
74
|
+
if (query.scope !== "public_tester_application") return failure("invalid");
|
|
75
|
+
const cursor = decodeCursor(query.cursor);
|
|
76
|
+
if (cursor === undefined) return failure("invalid");
|
|
77
|
+
try {
|
|
78
|
+
const response = object(await call(LIST, [
|
|
79
|
+
query.actorRef,
|
|
80
|
+
cursor?.createdAt ?? null,
|
|
81
|
+
cursor?.id ?? null,
|
|
82
|
+
query.limit,
|
|
83
|
+
]));
|
|
84
|
+
if (!response || !Array.isArray(response.cases)) return failure("unavailable");
|
|
85
|
+
const parsed = acquisitionCaseListResponseSchema.safeParse({
|
|
86
|
+
contractVersion: response.contractVersion ?? ACQUISITION_CASE_CONTRACT_VERSION,
|
|
87
|
+
cases: response.cases,
|
|
88
|
+
nextCursor: response.nextCursor === null ? null : encodeCursor(response.nextCursor),
|
|
89
|
+
});
|
|
90
|
+
return parsed.success ? { ok: true, value: parsed.data } : failure("unavailable");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return databaseFailure(error);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async get(query) {
|
|
97
|
+
if (query.scope !== "public_tester_application") return failure("invalid");
|
|
98
|
+
try {
|
|
99
|
+
const response = object(await call(GET, [query.actorRef, query.caseRef]));
|
|
100
|
+
if (response?.outcome === "not_found") return failure("not_found");
|
|
101
|
+
return projectionResult(response);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return databaseFailure(error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async transition(command) {
|
|
108
|
+
if (command.scope !== "public_tester_application") return failure("invalid");
|
|
109
|
+
const target = transitionTarget(command);
|
|
110
|
+
try {
|
|
111
|
+
const response = object(await call(TRANSITION, [
|
|
112
|
+
command.actorRef,
|
|
113
|
+
command.request.caseRef,
|
|
114
|
+
command.request.expectedVersion,
|
|
115
|
+
target,
|
|
116
|
+
command.request.reason ?? null,
|
|
117
|
+
command.idempotencyKey,
|
|
118
|
+
new Date().toISOString(),
|
|
119
|
+
]));
|
|
120
|
+
return projectionResult(response);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return databaseFailure(error);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async activeCount(scope) {
|
|
127
|
+
if (scope !== "public_tester_application") return failure("invalid");
|
|
128
|
+
try {
|
|
129
|
+
const raw = await call(ACTIVE_COUNT, []);
|
|
130
|
+
const count = typeof raw === "number" ? raw : Number(raw);
|
|
131
|
+
return Number.isSafeInteger(count) && count >= 0
|
|
132
|
+
? { ok: true, value: count }
|
|
133
|
+
: failure("unavailable");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return databaseFailure(error);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async close() {
|
|
140
|
+
if (poolPromise) await (await poolPromise).end();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function withRole<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
146
|
+
const client = await pool.connect();
|
|
147
|
+
try {
|
|
148
|
+
await client.query("BEGIN");
|
|
149
|
+
await client.query(ROLE);
|
|
150
|
+
const value = await work(client);
|
|
151
|
+
await client.query("COMMIT");
|
|
152
|
+
return value;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
155
|
+
throw error;
|
|
156
|
+
} finally {
|
|
157
|
+
client.release();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function projectionResult(response: Row | null): AcquisitionCaseResult<AcquisitionCaseProjection> {
|
|
162
|
+
if (!response) return failure("unavailable");
|
|
163
|
+
const outcome = response.outcome;
|
|
164
|
+
if (outcome === "created" || outcome === "replayed" || outcome === "transitioned" || outcome === "found") {
|
|
165
|
+
const parsed = acquisitionCaseProjectionSchema.safeParse(response.acquisitionCase);
|
|
166
|
+
return parsed.success
|
|
167
|
+
? { ok: true, value: parsed.data, replayed: response.replayed === true }
|
|
168
|
+
: failure("unavailable");
|
|
169
|
+
}
|
|
170
|
+
return failure(outcomeFailure(outcome));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function outcomeFailure(outcome: unknown): AcquisitionCaseFailureKind {
|
|
174
|
+
if (outcome === "invalid_address") return "invalid";
|
|
175
|
+
if (outcome === "rate_limited") return "rate_limited";
|
|
176
|
+
if (outcome === "not_found") return "not_found";
|
|
177
|
+
if (outcome === "idempotency_conflict" || outcome === "contact_conflict"
|
|
178
|
+
|| outcome === "version_conflict" || outcome === "transition_conflict") return "conflict";
|
|
179
|
+
return "unavailable";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function databaseFailure(error: unknown): AcquisitionCaseResult<never> {
|
|
183
|
+
return failure(object(error)?.code === "22023" ? "invalid" : "unavailable");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function failure<T>(kind: AcquisitionCaseFailureKind): AcquisitionCaseResult<T> {
|
|
187
|
+
return { ok: false, error: { kind } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function transitionTarget(command: AcquisitionCaseTransitionCommand): string {
|
|
191
|
+
return ({ approve: "approved", activate: "active", reject: "rejected", withdraw: "withdrawn" } as const)[
|
|
192
|
+
command.request.transition
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function encodeCursor(value: unknown): string {
|
|
197
|
+
const cursor = object(value);
|
|
198
|
+
if (!cursor || typeof cursor.createdAt !== "string" || typeof cursor.id !== "string") {
|
|
199
|
+
throw new Error("acquisition_case_cursor_invalid");
|
|
200
|
+
}
|
|
201
|
+
return Buffer.from(JSON.stringify({ createdAt: cursor.createdAt, id: cursor.id }), "utf8").toString("base64url");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function decodeCursor(value: AcquisitionCaseListQuery["cursor"]): { createdAt: string; id: string } | null | undefined {
|
|
205
|
+
if (value === undefined) return null;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = object(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
|
|
208
|
+
return parsed && typeof parsed.createdAt === "string" && typeof parsed.id === "string"
|
|
209
|
+
? { createdAt: parsed.createdAt, id: parsed.id }
|
|
210
|
+
: undefined;
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function object(value: unknown): Row | null {
|
|
217
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Row : null;
|
|
218
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Pool, type PoolClient } from "pg";
|
|
2
|
+
|
|
3
|
+
import { ACQUISITION_NEWSLETTER_VERSION, ACQUISITION_SURVEY_VERSION, acquisitionNewsletterConsentResponseSchema, acquisitionSurveyListResponseSchema, acquisitionSurveySubmitResponseSchema } from "../../acquisitionEvidenceSchemas.js";
|
|
4
|
+
import type {
|
|
5
|
+
AcquisitionEvidenceFailure,
|
|
6
|
+
AcquisitionEvidencePort,
|
|
7
|
+
AcquisitionEvidenceResult,
|
|
8
|
+
} from "../../contracts/index.js";
|
|
9
|
+
|
|
10
|
+
const ROLE = "SET LOCAL ROLE platform_acquisition_runtime";
|
|
11
|
+
const SUBMIT = "SELECT public.acquisition_survey_submit_v1($1::text,$2::text,$3::text,$4::jsonb,$5::timestamptz) AS response";
|
|
12
|
+
const LIST = "SELECT public.acquisition_survey_operator_list_v1($1::uuid,$2::text,$3::integer) AS response";
|
|
13
|
+
const CONSENT = "SELECT public.acquisition_newsletter_consent_v1($1::text,$2::text,$3::text,$4::text,$5::text,$6::boolean,$7::timestamptz,$8::timestamptz) AS response";
|
|
14
|
+
|
|
15
|
+
type Env = { connectionString: string };
|
|
16
|
+
type Options = { poolFactory?: (env: Env) => Pool | Promise<Pool> };
|
|
17
|
+
type Row = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
export function createPostgresAcquisitionEvidencePort(env: Env, options: Options = {}): AcquisitionEvidencePort {
|
|
20
|
+
if (!env.connectionString.trim()) throw new Error("acquisition_evidence_database_url_required");
|
|
21
|
+
let poolPromise: Promise<Pool> | null = null;
|
|
22
|
+
const pool = () => poolPromise ??= Promise.resolve(
|
|
23
|
+
options.poolFactory ? options.poolFactory(env) : new Pool({ connectionString: env.connectionString }),
|
|
24
|
+
);
|
|
25
|
+
const call = async (sql: string, values: unknown[]): Promise<Row | null> => withRole(
|
|
26
|
+
await pool(),
|
|
27
|
+
async (client) => object(object((await client.query(sql, values)).rows[0])?.response),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
async submitSurvey(input) {
|
|
32
|
+
try {
|
|
33
|
+
const response = await call(SUBMIT, [input.idempotencyKey, input.caseReference,
|
|
34
|
+
input.surveyType, JSON.stringify(input.responseData), new Date().toISOString()]);
|
|
35
|
+
const failure = outcomeFailure(response?.outcome);
|
|
36
|
+
if (failure) return fail(failure);
|
|
37
|
+
const parsed = acquisitionSurveySubmitResponseSchema.safeParse({
|
|
38
|
+
contractVersion: ACQUISITION_SURVEY_VERSION,
|
|
39
|
+
evidence: response?.evidence,
|
|
40
|
+
replayed: response?.replayed === true,
|
|
41
|
+
});
|
|
42
|
+
return parsed.success ? { ok: true, value: parsed.data } : fail("unavailable");
|
|
43
|
+
} catch (error) { return databaseFailure(error); }
|
|
44
|
+
},
|
|
45
|
+
async listSurveys(input) {
|
|
46
|
+
try {
|
|
47
|
+
const response = await call(LIST, [input.actorReference, input.surveyType ?? null, input.limit]);
|
|
48
|
+
const parsed = acquisitionSurveyListResponseSchema.safeParse({
|
|
49
|
+
contractVersion: ACQUISITION_SURVEY_VERSION,
|
|
50
|
+
rows: response?.rows,
|
|
51
|
+
totalCount: number(response?.totalCount),
|
|
52
|
+
});
|
|
53
|
+
return parsed.success ? { ok: true, value: parsed.data } : fail("unavailable");
|
|
54
|
+
} catch (error) { return databaseFailure(error); }
|
|
55
|
+
},
|
|
56
|
+
async applyNewsletterConsent(input) {
|
|
57
|
+
try {
|
|
58
|
+
const response = await call(CONSENT, [input.caseReference,input.contactReference,
|
|
59
|
+
input.eventReference,input.eventType,input.purpose,input.explicitOptInEvidence,
|
|
60
|
+
input.occurredAt,new Date().toISOString()]);
|
|
61
|
+
const failure = outcomeFailure(response?.outcome);
|
|
62
|
+
if (failure) return fail(failure);
|
|
63
|
+
const parsed = acquisitionNewsletterConsentResponseSchema.safeParse({
|
|
64
|
+
contractVersion: ACQUISITION_NEWSLETTER_VERSION,
|
|
65
|
+
eventReference: response?.eventReference,
|
|
66
|
+
outcome: response?.outcome,
|
|
67
|
+
consentState: response?.consentState ?? null,
|
|
68
|
+
auditReference: response?.auditReference,
|
|
69
|
+
replayed: response?.replayed === true,
|
|
70
|
+
});
|
|
71
|
+
return parsed.success ? { ok: true, value: parsed.data } : fail("unavailable");
|
|
72
|
+
} catch (error) { return databaseFailure(error); }
|
|
73
|
+
},
|
|
74
|
+
async close() { if (poolPromise) await (await poolPromise).end(); },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function withRole<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
79
|
+
const client = await pool.connect();
|
|
80
|
+
try {
|
|
81
|
+
await client.query("BEGIN"); await client.query(ROLE);
|
|
82
|
+
const value = await work(client); await client.query("COMMIT"); return value;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await client.query("ROLLBACK").catch(() => {}); throw error;
|
|
85
|
+
} finally { client.release(); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function outcomeFailure(outcome: unknown): AcquisitionEvidenceFailure | null {
|
|
89
|
+
if (outcome === "not_found") return "not_found";
|
|
90
|
+
if (outcome === "idempotency_conflict") return "conflict";
|
|
91
|
+
if (["recorded","replayed","applied","ignored","stale"].includes(String(outcome))) return null;
|
|
92
|
+
return "unavailable";
|
|
93
|
+
}
|
|
94
|
+
function databaseFailure<T>(error: unknown): AcquisitionEvidenceResult<T> {
|
|
95
|
+
return fail(object(error)?.code === "22023" ? "invalid" : "unavailable");
|
|
96
|
+
}
|
|
97
|
+
function fail<T>(kind: AcquisitionEvidenceFailure): AcquisitionEvidenceResult<T> {
|
|
98
|
+
return { ok: false, error: { kind } };
|
|
99
|
+
}
|
|
100
|
+
function object(value: unknown): Row | null {
|
|
101
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Row : null;
|
|
102
|
+
}
|
|
103
|
+
function number(value: unknown): number { return typeof value === "number" ? value : Number(value); }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PRELAUNCH_ACQUISITION_CONTRACT_VERSION,
|
|
3
|
+
prelaunchAcquisitionDetailRequestSchema,
|
|
4
|
+
prelaunchAcquisitionDetailResponseSchema,
|
|
5
|
+
prelaunchAcquisitionListRequestSchema,
|
|
6
|
+
prelaunchAcquisitionListResponseSchema,
|
|
7
|
+
type PrelaunchAcquisitionLead,
|
|
8
|
+
} from "../prelaunchAcquisitionSchemas.js";
|
|
9
|
+
import type { AcquisitionCaseProjection } from "../contracts/index.js";
|
|
10
|
+
import type { HttpRequest, HttpResponse } from "../../http/index.js";
|
|
11
|
+
import type { BffResponder } from "../../bff/index.js";
|
|
12
|
+
import type { AcquisitionCaseFailureKind } from "../contracts/index.js";
|
|
13
|
+
import type { PrelaunchAcquisitionDirectDeps, AcquisitionCaseBindingResolver } from "./directPorts.js";
|
|
14
|
+
|
|
15
|
+
export function createPrelaunchAcquisitionListHandler(options: PrelaunchAcquisitionDirectDeps) {
|
|
16
|
+
const { respond, authorize, resolveBinding } = options;
|
|
17
|
+
const { sendBffSuccess, sendMethodNotAllowed } = respond;
|
|
18
|
+
return async (req: HttpRequest, res: HttpResponse): Promise<void> => {
|
|
19
|
+
if (req.method !== "GET") return sendMethodNotAllowed(res, ["GET"]);
|
|
20
|
+
const actorRef = await authorize(req, res);
|
|
21
|
+
if (!actorRef) return;
|
|
22
|
+
const request = prelaunchAcquisitionListRequestSchema.safeParse({
|
|
23
|
+
view: first(req.query.view),
|
|
24
|
+
cursor: first(req.query.cursor),
|
|
25
|
+
limit: first(req.query.limit) ?? "25",
|
|
26
|
+
});
|
|
27
|
+
if (!request.success) return badRequest(res, respond, request.error.flatten());
|
|
28
|
+
const binding = await bindingOrError(res, respond, resolveBinding);
|
|
29
|
+
if (!binding) return;
|
|
30
|
+
const result = await operationOrError(res, respond, () => binding.handlers.list(actorRef, {
|
|
31
|
+
view: "acquisition_case_v1",
|
|
32
|
+
...(request.data.cursor ? { cursor: request.data.cursor } : {}),
|
|
33
|
+
limit: request.data.limit,
|
|
34
|
+
}));
|
|
35
|
+
if (!result) return;
|
|
36
|
+
if (result.ok === false) return failure(res, respond, result.error.kind);
|
|
37
|
+
const response = prelaunchAcquisitionListResponseSchema.safeParse({
|
|
38
|
+
contractVersion: PRELAUNCH_ACQUISITION_CONTRACT_VERSION,
|
|
39
|
+
leads: result.value.cases.map(toLead),
|
|
40
|
+
nextCursor: result.value.nextCursor,
|
|
41
|
+
});
|
|
42
|
+
if (!response.success) return invalidResponse(res, respond);
|
|
43
|
+
sendBffSuccess(res, response.data);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPrelaunchAcquisitionDetailHandler(options: PrelaunchAcquisitionDirectDeps) {
|
|
48
|
+
const { respond, authorize, resolveBinding } = options;
|
|
49
|
+
const { sendBffSuccess, sendMethodNotAllowed } = respond;
|
|
50
|
+
return async (req: HttpRequest, res: HttpResponse): Promise<void> => {
|
|
51
|
+
if (req.method !== "GET") return sendMethodNotAllowed(res, ["GET"]);
|
|
52
|
+
const actorRef = await authorize(req, res);
|
|
53
|
+
if (!actorRef) return;
|
|
54
|
+
const request = prelaunchAcquisitionDetailRequestSchema.safeParse({
|
|
55
|
+
sourceRef: first(req.query.sourceRef),
|
|
56
|
+
});
|
|
57
|
+
if (!request.success) return badRequest(res, respond, request.error.flatten());
|
|
58
|
+
const binding = await bindingOrError(res, respond, resolveBinding);
|
|
59
|
+
if (!binding) return;
|
|
60
|
+
const result = await operationOrError(res, respond, () => binding.handlers.get(actorRef, request.data.sourceRef));
|
|
61
|
+
if (!result) return;
|
|
62
|
+
if (result.ok === false) return failure(res, respond, result.error.kind);
|
|
63
|
+
const response = prelaunchAcquisitionDetailResponseSchema.safeParse({
|
|
64
|
+
contractVersion: PRELAUNCH_ACQUISITION_CONTRACT_VERSION,
|
|
65
|
+
lead: toLead(result.value),
|
|
66
|
+
});
|
|
67
|
+
if (!response.success) return invalidResponse(res, respond);
|
|
68
|
+
sendBffSuccess(res, response.data);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toLead(value: AcquisitionCaseProjection): PrelaunchAcquisitionLead {
|
|
73
|
+
return {
|
|
74
|
+
sourceRef: value.caseRef,
|
|
75
|
+
contactRef: value.contactRef,
|
|
76
|
+
sourceKind: value.sourceKind,
|
|
77
|
+
consent: value.consent,
|
|
78
|
+
addressReference: value.addressReference,
|
|
79
|
+
state: value.state,
|
|
80
|
+
version: value.version,
|
|
81
|
+
createdAt: value.createdAt,
|
|
82
|
+
updatedAt: value.updatedAt,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function bindingOrError(res: HttpResponse, respond: BffResponder, resolve: AcquisitionCaseBindingResolver) {
|
|
87
|
+
try {
|
|
88
|
+
const binding = await resolve();
|
|
89
|
+
if (binding) return binding;
|
|
90
|
+
} catch { /* fail closed below */ }
|
|
91
|
+
respond.sendBffError(res, "UPSTREAM_UNAVAILABLE", "Prelaunch acquisition service unavailable");
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function operationOrError<T>(res: HttpResponse, respond: BffResponder, operation: () => Promise<T>): Promise<T | null> {
|
|
96
|
+
try { return await operation(); }
|
|
97
|
+
catch {
|
|
98
|
+
respond.sendBffError(res, "UPSTREAM_UNAVAILABLE", "Prelaunch acquisition service unavailable");
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function first(value: string | string[] | undefined): string | undefined {
|
|
104
|
+
return Array.isArray(value) ? value[0] : value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function badRequest(res: HttpResponse, respond: BffResponder, details?: unknown): void {
|
|
108
|
+
respond.sendBffError(res, "BAD_REQUEST", "Invalid prelaunch acquisition request", { details });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function invalidResponse(res: HttpResponse, respond: BffResponder): void {
|
|
112
|
+
respond.sendBffError(res, "INVALID_RESPONSE", "Prelaunch acquisition returned invalid response");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function failure(res: HttpResponse, respond: BffResponder, kind: AcquisitionCaseFailureKind): void {
|
|
116
|
+
if (kind === "invalid") return badRequest(res, respond);
|
|
117
|
+
if (kind === "not_found") return respond.sendBffError(res, "NOT_FOUND", "Prelaunch acquisition case not found");
|
|
118
|
+
if (kind === "conflict") return respond.sendBffError(res, "CONFLICT", "Prelaunch acquisition request conflicts with prior state");
|
|
119
|
+
if (kind === "rate_limited") return respond.sendBffError(res, "RATE_LIMITED", "Prelaunch acquisition request rate limited");
|
|
120
|
+
respond.sendBffError(res, "UPSTREAM_UNAVAILABLE", "Prelaunch acquisition service unavailable");
|
|
121
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { personalDataObligationSchema } from "../../identity/contracts/index.js";
|
|
2
|
+
import type { PersonalDataDocument, PersonalDataObligation, PrivacyJson } from "../../identity/contracts/index.js";
|
|
3
|
+
|
|
4
|
+
interface AuthorizedHumanAdministrator { principalId: string }
|
|
5
|
+
interface ResolvedPrivacySubject {
|
|
6
|
+
contactId: string | null;
|
|
7
|
+
principalId: string | null;
|
|
8
|
+
identity: { [key: string]: PrivacyJson };
|
|
9
|
+
}
|
|
10
|
+
interface ErasureAssessment { obligations: PersonalDataObligation[] }
|
|
11
|
+
/** Structural identity contributor contract without selecting identity's server adapters. */
|
|
12
|
+
export interface MarketingPrivacyContributor<Context> {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly version: string;
|
|
15
|
+
readonly ownedResources: readonly string[];
|
|
16
|
+
prepareErasure(context: Context): Promise<void>;
|
|
17
|
+
export(context: Context, subject: ResolvedPrivacySubject): Promise<PersonalDataDocument>;
|
|
18
|
+
assessErasure(context: Context, subject: ResolvedPrivacySubject): Promise<ErasureAssessment>;
|
|
19
|
+
eraseOrRetain(context: Context, subject: ResolvedPrivacySubject, assessment: ErasureAssessment, actor: AuthorizedHumanAdministrator): Promise<{
|
|
20
|
+
contributorId: string; version: string; obligations: PersonalDataObligation[];
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MarketingAcquisitionPrivacyPort<Context> {
|
|
25
|
+
export(context: Context, subject: ResolvedPrivacySubject): Promise<{ records: PrivacyJson; evidence: PrivacyJson; obligations: PersonalDataObligation[] }>;
|
|
26
|
+
/** Read-only; declare both local resources, including explicit retention policy. */
|
|
27
|
+
assessErasure(context: Context, subject: ResolvedPrivacySubject): Promise<ErasureAssessment>;
|
|
28
|
+
eraseOrRetain(context: Context, subject: ResolvedPrivacySubject, assessment: ErasureAssessment, actor: AuthorizedHumanAdministrator): Promise<ErasureAssessment>;
|
|
29
|
+
}
|
|
30
|
+
export interface MarketingRemoteErasureCase {
|
|
31
|
+
state: "pending" | "requested" | "accepted" | "failed" | "not_bound";
|
|
32
|
+
fenced: boolean;
|
|
33
|
+
pendingTargets: number;
|
|
34
|
+
waitingImports: number;
|
|
35
|
+
}
|
|
36
|
+
export interface MarketingRemoteErasurePort<Context> {
|
|
37
|
+
/** Read-only subject lookup; return no addresses or provider references. */
|
|
38
|
+
read(context: Context, subject: ResolvedPrivacySubject): Promise<MarketingRemoteErasureCase | null>;
|
|
39
|
+
/** Authorized transaction: atomically fence against recreation and record the case, replay-safe per caseId.
|
|
40
|
+
* In-flight imports and late-import orphan targets remain the case owner's obligations.
|
|
41
|
+
*/
|
|
42
|
+
openAndFence(context: Context, input: { caseId: string; subject: ResolvedPrivacySubject; actor: AuthorizedHumanAdministrator; openedAt: string }): Promise<MarketingRemoteErasureCase>;
|
|
43
|
+
}
|
|
44
|
+
export interface MarketingPrivacyDependencies<Context> {
|
|
45
|
+
acquisition: MarketingAcquisitionPrivacyPort<Context> | null;
|
|
46
|
+
remoteErasure: MarketingRemoteErasurePort<Context> | null;
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
randomId(): string;
|
|
49
|
+
}
|
|
50
|
+
const localResources = ["marketing.acquisition-records", "marketing.acquisition-evidence"] as const;
|
|
51
|
+
const remoteResource = "marketing.remote-profile";
|
|
52
|
+
const id = "marketing", version = "marketing.personal-data.v1";
|
|
53
|
+
function obligation(resource: string, disposition: PersonalDataObligation["disposition"], reasonCode: string): PersonalDataObligation {
|
|
54
|
+
return { owner: id, resource, disposition, reasonCode, remote: resource === remoteResource };
|
|
55
|
+
}
|
|
56
|
+
function unresolved(resource: string): PersonalDataObligation { return obligation(resource, "unresolved", "MARKETING_OWNER_UNAVAILABLE"); }
|
|
57
|
+
function localObligations(value: unknown): PersonalDataObligation[] {
|
|
58
|
+
const invalid = () => localResources.map(unresolved);
|
|
59
|
+
if (!Array.isArray(value) || value.length !== localResources.length) return invalid();
|
|
60
|
+
const parsed = value.map(item => personalDataObligationSchema.safeParse(item));
|
|
61
|
+
if (parsed.some(item => !item.success)) return invalid();
|
|
62
|
+
const rows = parsed.map(item => { if (!item.success) throw new Error("marketing_obligation_invalid"); return item.data; });
|
|
63
|
+
if (rows.some(row => row.owner !== id || row.remote || !localResources.some(resource => resource === row.resource))
|
|
64
|
+
|| localResources.some(resource => rows.filter(row => row.resource === resource).length !== 1)) return invalid();
|
|
65
|
+
// Owner text is never echoed into receipts; only closed, content-free reasons cross this boundary.
|
|
66
|
+
return rows.map(row => obligation(row.resource, row.disposition, row.disposition === "erase" ? "LOCAL_OWNER_ERASURE"
|
|
67
|
+
: row.disposition === "retain" ? "LOCAL_OWNER_RETENTION" : "MARKETING_OWNER_UNAVAILABLE"));
|
|
68
|
+
}
|
|
69
|
+
function validCase(value: MarketingRemoteErasureCase): boolean {
|
|
70
|
+
return !!value && ["pending", "requested", "accepted", "failed", "not_bound"].includes(value.state)
|
|
71
|
+
&& typeof value.fenced === "boolean" && Number.isSafeInteger(value.pendingTargets) && value.pendingTargets >= 0
|
|
72
|
+
&& Number.isSafeInteger(value.waitingImports) && value.waitingImports >= 0;
|
|
73
|
+
}
|
|
74
|
+
function remoteObligation(value: MarketingRemoteErasureCase | null): PersonalDataObligation {
|
|
75
|
+
if (value === null) return obligation(remoteResource, "retain", "REMOTE_ERASURE_NOT_REQUESTED");
|
|
76
|
+
if (!validCase(value)) return unresolved(remoteResource);
|
|
77
|
+
const reasons = { pending: "REMOTE_ERASURE_PENDING", requested: "REMOTE_ERASURE_REQUESTED", accepted: "REMOTE_ERASURE_ACCEPTED_NOT_OBSERVED",
|
|
78
|
+
failed: "REMOTE_ERASURE_FAILED", not_bound: value.fenced ? "REMOTE_ERASURE_NOT_BOUND_LOCAL_FENCE_ONLY" : "REMOTE_ERASURE_NOT_BOUND" };
|
|
79
|
+
return obligation(remoteResource, "retain", `${reasons[value.state]}${value.waitingImports > 0 ? "_WAITING_IMPORTS" : ""}${value.pendingTargets > 0 ? "_PENDING_TARGETS" : ""}`);
|
|
80
|
+
}
|
|
81
|
+
async function ownerCall<T>(run: () => Promise<T>): Promise<T> {
|
|
82
|
+
try { return await run(); } catch { throw new Error("marketing_privacy_owner_failed"); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Identity orchestrates authorization, assessment, transaction rollback and receipt replay. */
|
|
86
|
+
export function createMarketingPrivacyContributor<Context>(input: MarketingPrivacyDependencies<Context>): MarketingPrivacyContributor<Context> {
|
|
87
|
+
// Partial ownership cannot establish coverage or permit any erasure effects.
|
|
88
|
+
const acquisition = input.acquisition && input.remoteErasure ? input.acquisition : null;
|
|
89
|
+
const remoteErasure = acquisition ? input.remoteErasure : null;
|
|
90
|
+
const { randomId } = input, now = input.now ?? (() => new Date());
|
|
91
|
+
return {
|
|
92
|
+
id, version, ownedResources: [...localResources, remoteResource],
|
|
93
|
+
async prepareErasure() {},
|
|
94
|
+
async export(context, subject) {
|
|
95
|
+
const local = acquisition ? await ownerCall(() => acquisition.export(context, subject)) : null;
|
|
96
|
+
const remote = remoteErasure ? await ownerCall(() => remoteErasure.read(context, subject)) : null;
|
|
97
|
+
const validRemote = remote === null || validCase(remote);
|
|
98
|
+
return { contributorId: id, version, data: { acquisition: local ? { records: local.records, evidence: local.evidence } : null,
|
|
99
|
+
remoteProfile: { state: validRemote ? remote?.state ?? null : null } },
|
|
100
|
+
coverage: { complete: false, included: local ? [...localResources] : [], unavailable: [...(local ? [] : localResources), remoteResource] },
|
|
101
|
+
obligations: [...(local ? localObligations(local.obligations) : localResources.map(unresolved)),
|
|
102
|
+
remoteErasure ? remoteObligation(remote) : unresolved(remoteResource)] };
|
|
103
|
+
},
|
|
104
|
+
async assessErasure(context, subject) {
|
|
105
|
+
const local = acquisition ? await ownerCall(() => acquisition.assessErasure(context, subject)) : null;
|
|
106
|
+
const remote = remoteErasure ? await ownerCall(() => remoteErasure.read(context, subject)) : null;
|
|
107
|
+
return { obligations: [...(local ? localObligations(local.obligations) : localResources.map(unresolved)),
|
|
108
|
+
remoteErasure ? remoteObligation(remote) : unresolved(remoteResource)] };
|
|
109
|
+
},
|
|
110
|
+
async eraseOrRetain(context, subject, assessment, actor) {
|
|
111
|
+
if (!acquisition || !remoteErasure || assessment.obligations.some(row => row.disposition === "unresolved")) throw new Error("marketing_privacy_unbound");
|
|
112
|
+
// Identity receipt replay bypasses contributors. Rolled-back attempts may use a fresh ID;
|
|
113
|
+
// the owner atomically reuses the subject fence and deduplicates each case ID.
|
|
114
|
+
const remote = await ownerCall(() => remoteErasure.openAndFence(context, { caseId: randomId(), subject, actor, openedAt: now().toISOString() }));
|
|
115
|
+
if (!validCase(remote) || remote.fenced !== true) throw new Error("marketing_privacy_fence_required");
|
|
116
|
+
const local = await ownerCall(() => acquisition.eraseOrRetain(context, subject,
|
|
117
|
+
{ obligations: assessment.obligations.filter(row => localResources.some(resource => resource === row.resource)) }, actor));
|
|
118
|
+
const obligations = localObligations(local.obligations);
|
|
119
|
+
if (obligations.some(row => row.disposition === "unresolved")) throw new Error("marketing_privacy_disposition_invalid");
|
|
120
|
+
return { contributorId: id, version, obligations: [...obligations, remoteObligation(remote)] };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { HttpRequest, HttpResponse } from "../../../http/index.js";
|
|
2
|
+
import type { BffResponder } from "../../../bff/index.js";
|
|
3
|
+
import {
|
|
4
|
+
adminSurveyResponsesRequestSchema,
|
|
5
|
+
adminSurveyResponsesResponseSchema,
|
|
6
|
+
} from "@openlup/core/marketing/research";
|
|
7
|
+
import type { MarketingResearchSurveyResponsesReadPort } from "@openlup/core/marketing/research";
|
|
8
|
+
|
|
9
|
+
export interface AdminSurveyResponsesHandlerDeps {
|
|
10
|
+
respond: BffResponder;
|
|
11
|
+
readPort: MarketingResearchSurveyResponsesReadPort;
|
|
12
|
+
authorizeAdmin: (req: HttpRequest) => Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createAdminSurveyResponsesHandler({
|
|
16
|
+
respond,
|
|
17
|
+
readPort,
|
|
18
|
+
authorizeAdmin,
|
|
19
|
+
}: AdminSurveyResponsesHandlerDeps) {
|
|
20
|
+
const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = respond;
|
|
21
|
+
return async function handler(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
22
|
+
if (req.method !== "GET") {
|
|
23
|
+
sendMethodNotAllowed(res, ["GET"]);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const authorized = await authorize(req, res, authorizeAdmin, respond);
|
|
28
|
+
if (!authorized) return;
|
|
29
|
+
|
|
30
|
+
const request = adminSurveyResponsesRequestSchema.safeParse({
|
|
31
|
+
surveyType: firstQueryValue(req.query.surveyType),
|
|
32
|
+
page: firstQueryValue(req.query.page),
|
|
33
|
+
pageSize: firstQueryValue(req.query.pageSize),
|
|
34
|
+
limit: firstQueryValue(req.query.limit),
|
|
35
|
+
});
|
|
36
|
+
if (!request.success) {
|
|
37
|
+
sendBffError(res, "BAD_REQUEST", "Invalid survey responses request", {
|
|
38
|
+
details: request.error.flatten(),
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await readPort.getAdminSurveyResponses(request.data);
|
|
45
|
+
const response = adminSurveyResponsesResponseSchema.safeParse(result);
|
|
46
|
+
if (!response.success) {
|
|
47
|
+
sendBffError(res, "INVALID_RESPONSE", "Survey responses returned invalid response");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sendBffSuccess(res, response.data);
|
|
52
|
+
} catch {
|
|
53
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Survey responses read failed");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function authorize(
|
|
59
|
+
req: HttpRequest,
|
|
60
|
+
res: HttpResponse,
|
|
61
|
+
authorizeAdmin: (req: HttpRequest) => Promise<boolean>,
|
|
62
|
+
respond: BffResponder,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const { sendBffError } = respond;
|
|
65
|
+
try {
|
|
66
|
+
if (await authorizeAdmin(req)) return true;
|
|
67
|
+
} catch {
|
|
68
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin authorization failed");
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
sendBffError(res, "UNAUTHORIZED", "Admin session required");
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function firstQueryValue(value: string | string[] | undefined): string | undefined {
|
|
77
|
+
return Array.isArray(value) ? value[0] : value;
|
|
78
|
+
}
|