@openlup/platform 0.28.0 → 0.29.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +219 -18
  2. package/README.md +143 -4
  3. package/dist/composition/index.d.ts +1 -0
  4. package/dist/composition/index.js +1 -0
  5. package/dist/composition/marketingContribution.d.ts +21 -0
  6. package/dist/composition/marketingContribution.js +29 -0
  7. package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
  8. package/dist/marketing/acquisitionCaseSchemas.js +53 -0
  9. package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
  10. package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
  11. package/dist/marketing/analytics/dataLayer.d.ts +18 -0
  12. package/dist/marketing/analytics/dataLayer.js +111 -0
  13. package/dist/marketing/analytics/noop.d.ts +9 -0
  14. package/dist/marketing/analytics/noop.js +8 -0
  15. package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
  16. package/dist/marketing/analytics/purchaseFacts.js +69 -0
  17. package/dist/marketing/analytics/routePolicy.d.ts +10 -0
  18. package/dist/marketing/analytics/routePolicy.js +59 -0
  19. package/dist/marketing/consentEvidence.d.ts +8 -0
  20. package/dist/marketing/consentEvidence.js +32 -0
  21. package/dist/marketing/contactLegal.d.ts +14 -0
  22. package/dist/marketing/contactLegal.js +19 -0
  23. package/dist/marketing/contracts/acquisition.d.ts +88 -0
  24. package/dist/marketing/contracts/acquisition.js +1 -0
  25. package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
  26. package/dist/marketing/contracts/acquisitionPorts.js +1 -0
  27. package/dist/marketing/contracts/analytics.d.ts +144 -0
  28. package/dist/marketing/contracts/analytics.js +1 -0
  29. package/dist/marketing/contracts/contactLegal.d.ts +53 -0
  30. package/dist/marketing/contracts/contactLegal.js +1 -0
  31. package/dist/marketing/contracts/index.d.ts +6 -0
  32. package/dist/marketing/contracts/index.js +1 -0
  33. package/dist/marketing/contracts/prelaunch.d.ts +39 -0
  34. package/dist/marketing/contracts/prelaunch.js +1 -0
  35. package/dist/marketing/index.d.ts +16 -0
  36. package/dist/marketing/index.js +14 -0
  37. package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
  38. package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
  39. package/dist/marketing/seo/jsonLd.d.ts +157 -0
  40. package/dist/marketing/seo/jsonLd.js +127 -0
  41. package/dist/marketing/seo/metadata.d.ts +41 -0
  42. package/dist/marketing/seo/metadata.js +36 -0
  43. package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
  44. package/dist/marketing/seo/serializeJsonLd.js +6 -0
  45. package/dist/marketing/seo/urls.d.ts +14 -0
  46. package/dist/marketing/seo/urls.js +23 -0
  47. package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
  48. package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
  49. package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
  50. package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
  51. package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
  52. package/dist/marketing/server/acquisitionCasePorts.js +1 -0
  53. package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
  54. package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
  55. package/dist/marketing/server/analyticsContext.d.ts +3 -0
  56. package/dist/marketing/server/analyticsContext.js +65 -0
  57. package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
  58. package/dist/marketing/server/analyticsContextPorts.js +1 -0
  59. package/dist/marketing/server/bindings.d.ts +14 -0
  60. package/dist/marketing/server/bindings.js +18 -0
  61. package/dist/marketing/server/directPorts.d.ts +26 -0
  62. package/dist/marketing/server/directPorts.js +1 -0
  63. package/dist/marketing/server/index.d.ts +14 -0
  64. package/dist/marketing/server/index.js +11 -0
  65. package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
  66. package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
  67. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
  68. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
  69. package/dist/marketing/server/postgres/index.d.ts +3 -0
  70. package/dist/marketing/server/postgres/index.js +3 -0
  71. package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
  72. package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
  73. package/dist/marketing/server/privacyContributor.d.ts +66 -0
  74. package/dist/marketing/server/privacyContributor.js +87 -0
  75. package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
  76. package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
  77. package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
  78. package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
  79. package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
  80. package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
  81. package/dist/marketing/validation.d.ts +2 -0
  82. package/dist/marketing/validation.js +3 -0
  83. package/package.json +22 -2
  84. package/release-gates.json +72 -4
  85. package/src/composition/index.ts +1 -0
  86. package/src/composition/marketingContribution.ts +37 -0
  87. package/src/marketing/acquisitionCaseSchemas.ts +75 -0
  88. package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
  89. package/src/marketing/analytics/dataLayer.ts +100 -0
  90. package/src/marketing/analytics/noop.ts +14 -0
  91. package/src/marketing/analytics/purchaseFacts.ts +63 -0
  92. package/src/marketing/analytics/routePolicy.ts +55 -0
  93. package/src/marketing/consentEvidence.ts +36 -0
  94. package/src/marketing/contactLegal.ts +22 -0
  95. package/src/marketing/contracts/acquisition.ts +50 -0
  96. package/src/marketing/contracts/acquisitionPorts.ts +48 -0
  97. package/src/marketing/contracts/analytics.ts +88 -0
  98. package/src/marketing/contracts/contactLegal.ts +41 -0
  99. package/src/marketing/contracts/index.ts +17 -0
  100. package/src/marketing/contracts/prelaunch.ts +15 -0
  101. package/src/marketing/index.ts +16 -0
  102. package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
  103. package/src/marketing/seo/jsonLd.ts +153 -0
  104. package/src/marketing/seo/metadata.ts +62 -0
  105. package/src/marketing/seo/serializeJsonLd.ts +6 -0
  106. package/src/marketing/seo/urls.ts +34 -0
  107. package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
  108. package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
  109. package/src/marketing/server/acquisitionCasePorts.ts +42 -0
  110. package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
  111. package/src/marketing/server/analyticsContext.ts +55 -0
  112. package/src/marketing/server/analyticsContextPorts.ts +28 -0
  113. package/src/marketing/server/bindings.ts +27 -0
  114. package/src/marketing/server/directPorts.ts +27 -0
  115. package/src/marketing/server/index.ts +14 -0
  116. package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
  117. package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
  118. package/src/marketing/server/postgres/index.ts +3 -0
  119. package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
  120. package/src/marketing/server/privacyContributor.ts +123 -0
  121. package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
  122. package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
  123. package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
  124. package/src/marketing/validation.ts +3 -0
@@ -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
+ }
@@ -0,0 +1,158 @@
1
+ import type { ResearchSurveySubmitRequest, ResearchSurveySubmitResponse } from "@openlup/core/marketing/research";
2
+ import type { PublicIntakeDeliveryPort, PublicIntakeDeliveryResult } from "../../../communications/contracts/index.js";
3
+
4
+ type SurveyTable = "survey_responses_producer" | "survey_responses_consumer";
5
+
6
+ export interface ManagedSurveyResponseGateway {
7
+ checkRateLimit(input: {
8
+ table: SurveyTable;
9
+ ip: string;
10
+ windowMinutes: number;
11
+ maxPerIp: number;
12
+ now: Date;
13
+ }): Promise<"allowed" | "limited" | "unavailable">;
14
+ insert(input: {
15
+ table: SurveyTable;
16
+ responseData: Record<string, unknown>;
17
+ ip: string | null;
18
+ userAgent: string | null;
19
+ }): Promise<{ id: string } | null>;
20
+ updateEmailStatus(input: {
21
+ table: SurveyTable;
22
+ id: string;
23
+ sentAt: string | null;
24
+ error: string | null;
25
+ }): Promise<void>;
26
+ }
27
+
28
+ export interface ManagedSurveyResponseSubmitDeps {
29
+ renderNotificationHtml: (surveyType: "producer" | "consumer", responseData: Record<string, unknown>, id: string) => string;
30
+ notificationSubject: (surveyType: "producer" | "consumer", now: Date) => string;
31
+ gateway: ManagedSurveyResponseGateway;
32
+ delivery: PublicIntakeDeliveryPort;
33
+ notifyTo: string[];
34
+ fromEmail: string;
35
+ windowMinutes: number;
36
+ maxPerIp: number;
37
+ now?: () => Date;
38
+ }
39
+
40
+ export function createManagedSurveyResponseSubmit(deps: ManagedSurveyResponseSubmitDeps) {
41
+ const now = deps.now ?? (() => new Date());
42
+ return async (request: ResearchSurveySubmitRequest, headers?: Headers): Promise<ResearchSurveySubmitResponse> => {
43
+ if (!validRequest(request)) return { ok: false, error: "invalid_response_data" };
44
+ const table = request.surveyType === "producer" ? "survey_responses_producer" : "survey_responses_consumer";
45
+ const ip = readIp(headers);
46
+ const rate = await deps.gateway.checkRateLimit({ table, ip: ip ?? "unknown", windowMinutes: deps.windowMinutes, maxPerIp: deps.maxPerIp, now: now() });
47
+ if (rate === "unavailable") return { ok: false, error: "survey_rate_limit_unavailable" };
48
+ if (rate === "limited") return { ok: false, error: "survey_rate_limited" };
49
+
50
+ let inserted: { id: string } | null;
51
+ try {
52
+ inserted = await deps.gateway.insert({
53
+ table,
54
+ responseData: request.responseData,
55
+ ip,
56
+ userAgent: headers?.get("user-agent") || null,
57
+ });
58
+ } catch {
59
+ inserted = null;
60
+ }
61
+ if (!inserted) return { ok: false, error: "db_insert_failed" };
62
+
63
+ if (deps.notifyTo.length === 0) {
64
+ await updateEmailStatus(deps.gateway, table, inserted.id, null, "notification_recipient_not_configured");
65
+ return { ok: true, id: inserted.id, email_skipped: "notification_recipient_not_configured" };
66
+ }
67
+
68
+ const templateSlug = `survey_${request.surveyType}_notification`;
69
+ const policies = await Promise.all(deps.notifyTo.map((recipient, index) => deps.delivery.evaluatePolicy({
70
+ email: recipient,
71
+ purpose: "admin_notification",
72
+ source: "submit-survey-response",
73
+ recipientKind: "admin_internal",
74
+ metadata: {
75
+ template_slug: templateSlug,
76
+ survey_type: request.surveyType,
77
+ survey_response_id: inserted.id,
78
+ recipient_count: deps.notifyTo.length,
79
+ recipient_index: index,
80
+ },
81
+ })));
82
+ const recipients = deps.notifyTo.map((recipient, index) => ({ recipient, index, policy: policies[index]! }));
83
+ const allowedRecipients = recipients.filter(({ policy }) => policy.allowed);
84
+ if (allowedRecipients.length === 0) {
85
+ const reason = policies[0]?.reason ?? "policy_blocked";
86
+ await updateEmailStatus(deps.gateway, table, inserted.id, null, reason);
87
+ return { ok: true, id: inserted.id, email_skipped: reason };
88
+ }
89
+
90
+ const rendered = deps.renderNotificationHtml(request.surveyType, request.responseData, inserted.id);
91
+ const subject = deps.notificationSubject(request.surveyType, now());
92
+ const attempts = await Promise.all(allowedRecipients.map(({ recipient, index, policy }) => sendAndRecord(deps, {
93
+ recipient, source: "submit-survey-response", templateSlug, policyDecisionId: policy.decisionId,
94
+ policyReason: policy.reason, id: inserted.id, index, subject, html: rendered,
95
+ })));
96
+ const notAccepted = attempts.find((attempt) => !attempt.accepted);
97
+ await updateEmailStatus(
98
+ deps.gateway,
99
+ table,
100
+ inserted.id,
101
+ notAccepted ? null : now().toISOString(),
102
+ notAccepted?.error ?? notAccepted?.skip ?? null,
103
+ );
104
+ return { ok: true, id: inserted.id };
105
+ };
106
+ }
107
+
108
+ function validRequest(request: ResearchSurveySubmitRequest): boolean {
109
+ return (request.surveyType === "producer" || request.surveyType === "consumer")
110
+ && Boolean(request.responseData && typeof request.responseData === "object" && !Array.isArray(request.responseData) && Object.keys(request.responseData).length > 0);
111
+ }
112
+
113
+ function readIp(headers: Headers | undefined): string | null {
114
+ return headers?.get("x-forwarded-for")?.split(",")[0]?.trim() || headers?.get("cf-connecting-ip") || null;
115
+ }
116
+
117
+ async function updateEmailStatus(
118
+ gateway: ManagedSurveyResponseGateway,
119
+ table: SurveyTable,
120
+ id: string,
121
+ sentAt: string | null,
122
+ error: string | null,
123
+ ): Promise<void> {
124
+ try {
125
+ await gateway.updateEmailStatus({ table, id, sentAt, error });
126
+ } catch {
127
+ // The survey row is durable; email metadata remains explicitly best effort.
128
+ }
129
+ }
130
+
131
+ async function sendAndRecord(deps: ManagedSurveyResponseSubmitDeps, input: {
132
+ recipient: string; source: string; templateSlug: string; policyDecisionId: string | null; policyReason: string;
133
+ id: string; index: number; subject: string; html: string;
134
+ }): Promise<PublicIntakeDeliveryResult> {
135
+ try {
136
+ return await deps.delivery.deliver({
137
+ source: input.source,
138
+ templateSlug: input.templateSlug,
139
+ policyDecisionId: input.policyDecisionId,
140
+ dedupeKey: `${input.templateSlug}:${input.id}:${input.index}`,
141
+ purpose: "admin_notification",
142
+ triggerEvent: "survey_response_submitted",
143
+ recipientEmail: input.recipient,
144
+ aggregateType: "survey_response",
145
+ aggregateId: input.id,
146
+ metadata: { surveyResponseId: input.id, policyReason: input.policyReason },
147
+ message: {
148
+ sender: deps.fromEmail,
149
+ to: input.recipient,
150
+ subject: input.subject,
151
+ html: input.html,
152
+ },
153
+ });
154
+ } catch {
155
+ return { accepted: false, messageId: null, skip: null, error: "email_ledger_unavailable" };
156
+ }
157
+ }
158
+
@@ -0,0 +1,57 @@
1
+ import type { HttpRequest, HttpResponse } from "../../../http/index.js";
2
+ import type { BffResponder } from "../../../bff/index.js";
3
+ import {
4
+ researchSurveySubmitRequestSchema,
5
+ researchSurveySubmitResponseSchema,
6
+ } from "@openlup/core/marketing/research";
7
+ import type { MarketingResearchSurveySubmitPort } from "@openlup/core/marketing/research";
8
+
9
+ export interface ResearchSurveySubmitHandlerDeps {
10
+ respond: BffResponder;
11
+ submitPort: MarketingResearchSurveySubmitPort;
12
+ }
13
+
14
+ export function createResearchSurveySubmitHandler({
15
+ respond,
16
+ submitPort,
17
+ }: ResearchSurveySubmitHandlerDeps) {
18
+ const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = respond;
19
+ return async function handler(req: HttpRequest, res: HttpResponse): Promise<void> {
20
+ if (req.method !== "POST") {
21
+ sendMethodNotAllowed(res, ["POST"]);
22
+ return;
23
+ }
24
+
25
+ const request = researchSurveySubmitRequestSchema.safeParse(req.body);
26
+ if (!request.success) {
27
+ sendBffError(res, "BAD_REQUEST", "Invalid research survey response request", {
28
+ details: request.error.flatten(),
29
+ });
30
+ return;
31
+ }
32
+
33
+ try {
34
+ const result = await submitPort.submitSurveyResponse(request.data, {
35
+ headers: headersFromRequest(req),
36
+ });
37
+ const response = researchSurveySubmitResponseSchema.safeParse(result);
38
+ if (!response.success) {
39
+ sendBffError(res, "INVALID_RESPONSE", "Research survey submit returned invalid response");
40
+ return;
41
+ }
42
+
43
+ sendBffSuccess(res, response.data);
44
+ } catch {
45
+ sendBffError(res, "UPSTREAM_UNAVAILABLE", "Research survey submit failed");
46
+ }
47
+ };
48
+ }
49
+
50
+ function headersFromRequest(req: HttpRequest): Headers {
51
+ const headers = new Headers();
52
+ for (const [key, value] of Object.entries(req.headers)) {
53
+ if (typeof value === "string") headers.set(key, value);
54
+ else if (Array.isArray(value)) headers.set(key, value.join(", "));
55
+ }
56
+ return headers;
57
+ }
@@ -0,0 +1,3 @@
1
+ import { z } from "zod";
2
+ z.config({ jitless: true });
3
+ export { z };