@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,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
|
+
}
|
|
@@ -34,6 +34,13 @@ export interface PlatformMigrationSource {
|
|
|
34
34
|
migrations: readonly PlatformMigrationFile[];
|
|
35
35
|
objectInventoryPayload?: string;
|
|
36
36
|
}): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Optional trusted-source observation/refusal after exact-prefix validation, under the
|
|
39
|
+
* migration session lock. Runs once per operation before any pending SQL or dry-run result.
|
|
40
|
+
* The canonical source uses read-only observations; the callback shares the caller's existing
|
|
41
|
+
* database capability and is not a sandbox or a provisioning/per-unit execution hook.
|
|
42
|
+
*/
|
|
43
|
+
assertSession?(client: PoolClient, applied: readonly (PlatformMigrationManifestEntry & { position: number })[]): Promise<void>;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface PostgresMigrationRunnerOptions {
|
|
@@ -60,6 +67,9 @@ type LedgerExistenceRow = { ledger_exists: unknown };
|
|
|
60
67
|
type AdvisoryUnlockRow = { unlocked: unknown };
|
|
61
68
|
const ACQUIRE_MIGRATION_LOCK = "SELECT pg_advisory_lock(hashtextextended($1, 0))";
|
|
62
69
|
const RELEASE_MIGRATION_LOCK = "SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS unlocked";
|
|
70
|
+
const ACQUIRE_CATALOG_BOUND_MIGRATION_LOCK = "SELECT pg_catalog.pg_advisory_lock(pg_catalog.hashtextextended($1::pg_catalog.text, 0::pg_catalog.int8))";
|
|
71
|
+
const RELEASE_CATALOG_BOUND_MIGRATION_LOCK = "SELECT pg_catalog.pg_advisory_unlock(pg_catalog.hashtextextended($1::pg_catalog.text, 0::pg_catalog.int8)) AS unlocked";
|
|
72
|
+
const CATALOG_BOUND_LEDGER_EXISTENCE = "SELECT pg_catalog.to_regclass($1::pg_catalog.text) IS NOT NULL AS ledger_exists";
|
|
63
73
|
|
|
64
74
|
export function createPostgresMigrationRunner(
|
|
65
75
|
env: PostgresMigrationRunnerEnv,
|
|
@@ -79,8 +89,10 @@ export function createPostgresMigrationRunner(
|
|
|
79
89
|
async status(): Promise<{ pending: string[]; applied: string[] }> {
|
|
80
90
|
assertConnectionString(env);
|
|
81
91
|
const plan = loadPlan(root, options.source);
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
const catalogBoundSession = options.source.assertSession !== undefined;
|
|
93
|
+
return withMigrationSession(await getPool(), options.lockName, catalogBoundSession, async (client) => {
|
|
94
|
+
const applied = await readAppliedPrefix(client, plan.units, catalogBoundSession);
|
|
95
|
+
await options.source.assertSession?.(client, applied.map((unit) => ({ file: unit.catalogPath, sha256: unit.sha256, position: unit.position })));
|
|
84
96
|
if (applied.length === plan.units.length) await assertObjectInventory(client, plan, options);
|
|
85
97
|
return {
|
|
86
98
|
pending: plan.units.slice(applied.length).map((unit) => unit.catalogPath),
|
|
@@ -92,8 +104,10 @@ export function createPostgresMigrationRunner(
|
|
|
92
104
|
async apply(opts?: { dryRun?: boolean }): Promise<{ applied: string[] }> {
|
|
93
105
|
assertConnectionString(env);
|
|
94
106
|
const plan = loadPlan(root, options.source);
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
const catalogBoundSession = options.source.assertSession !== undefined;
|
|
108
|
+
return withMigrationSession(await getPool(), options.lockName, catalogBoundSession, async (client) => {
|
|
109
|
+
const already = await readAppliedPrefix(client, plan.units, catalogBoundSession);
|
|
110
|
+
await options.source.assertSession?.(client, already.map((unit) => ({ file: unit.catalogPath, sha256: unit.sha256, position: unit.position })));
|
|
97
111
|
const pending = plan.units.slice(already.length);
|
|
98
112
|
if (opts?.dryRun) {
|
|
99
113
|
if (pending.length === 0) await assertObjectInventory(client, plan, options);
|
|
@@ -115,7 +129,7 @@ export function createPostgresMigrationRunner(
|
|
|
115
129
|
throw new Error(`platform migration ${unit.catalogPath} failed: ${errorMessage(error)}`);
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
|
-
const complete = await readAppliedPrefix(client, plan.units);
|
|
132
|
+
const complete = await readAppliedPrefix(client, plan.units, catalogBoundSession);
|
|
119
133
|
if (complete.length !== plan.units.length) throw new Error("platform migration ledger is incomplete after apply");
|
|
120
134
|
await assertObjectInventory(client, plan, options);
|
|
121
135
|
return { applied: appliedNow };
|
|
@@ -127,6 +141,7 @@ export function createPostgresMigrationRunner(
|
|
|
127
141
|
async function withMigrationSession<T>(
|
|
128
142
|
pool: Pool,
|
|
129
143
|
lockName: string,
|
|
144
|
+
catalogBoundSession: boolean,
|
|
130
145
|
operation: (client: PoolClient) => Promise<T>,
|
|
131
146
|
): Promise<T> {
|
|
132
147
|
const client = await pool.connect();
|
|
@@ -135,7 +150,7 @@ async function withMigrationSession<T>(
|
|
|
135
150
|
let operationFailure: unknown;
|
|
136
151
|
let result!: T;
|
|
137
152
|
try {
|
|
138
|
-
await client.query(ACQUIRE_MIGRATION_LOCK, [lockName]);
|
|
153
|
+
await client.query(catalogBoundSession ? ACQUIRE_CATALOG_BOUND_MIGRATION_LOCK : ACQUIRE_MIGRATION_LOCK, [lockName]);
|
|
139
154
|
locked = true;
|
|
140
155
|
result = await operation(client);
|
|
141
156
|
} catch (error) {
|
|
@@ -146,7 +161,7 @@ async function withMigrationSession<T>(
|
|
|
146
161
|
let unlockFailure: unknown;
|
|
147
162
|
if (locked) {
|
|
148
163
|
try {
|
|
149
|
-
const unlocked = await client.query<AdvisoryUnlockRow>(RELEASE_MIGRATION_LOCK, [lockName]);
|
|
164
|
+
const unlocked = await client.query<AdvisoryUnlockRow>(catalogBoundSession ? RELEASE_CATALOG_BOUND_MIGRATION_LOCK : RELEASE_MIGRATION_LOCK, [lockName]);
|
|
150
165
|
if (unlocked.rows[0]?.unlocked !== true) throw new Error("PostgreSQL did not release the advisory lock");
|
|
151
166
|
} catch (error) {
|
|
152
167
|
unlockFailure = error;
|
|
@@ -194,9 +209,9 @@ function loadPlan(root: string, source: PlatformMigrationSource): LoadedPlan {
|
|
|
194
209
|
return Object.freeze({ manifest, migrations: catalog.migrations, units: Object.freeze(units) });
|
|
195
210
|
}
|
|
196
211
|
|
|
197
|
-
async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[]): Promise<readonly PlannedMigration[]> {
|
|
212
|
+
async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[], catalogBoundSession: boolean): Promise<readonly PlannedMigration[]> {
|
|
198
213
|
const exists = await client.query<LedgerExistenceRow>(
|
|
199
|
-
"SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
|
|
214
|
+
catalogBoundSession ? CATALOG_BOUND_LEDGER_EXISTENCE : "SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
|
|
200
215
|
[LEDGER],
|
|
201
216
|
);
|
|
202
217
|
if (exists.rows[0]?.ledger_exists !== true) return [];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "./validation.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin-facing projection of the platform alert ledger.
|
|
5
|
+
*
|
|
6
|
+
* Only what an operator needs to read; the ledger itself stays service-role only
|
|
7
|
+
* (platform_alerts REVOKEs ALL from `authenticated`), so this shape is served by
|
|
8
|
+
* /api/bff/admin/platform/alerts rather than by PostgREST.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const adminAlertSeveritySchema = z.enum(["p0", "p1", "p2", "p3"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Audience split, derived from `platform_alerts.owner`:
|
|
15
|
+
* commerce -> a business operator can act on it
|
|
16
|
+
* platform -> engineering owns it; shown for context so the health pill is explainable
|
|
17
|
+
*/
|
|
18
|
+
export const adminAlertLaneSchema = z.enum(["commerce", "platform"]);
|
|
19
|
+
|
|
20
|
+
export const adminAlertHealthSchema = z.enum(["ok", "degraded", "down", "unknown"]);
|
|
21
|
+
|
|
22
|
+
const isoDateTimeSchema = z.string().datetime({ offset: true });
|
|
23
|
+
|
|
24
|
+
export const adminAlertSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z.string().min(1).max(128),
|
|
27
|
+
dedupeKey: z.string().min(1).max(256),
|
|
28
|
+
lane: adminAlertLaneSchema,
|
|
29
|
+
// Free text on purpose: platform_alerts.owner has no CHECK constraint, so an
|
|
30
|
+
// enum here would throw the whole admin shell on the first new owner string.
|
|
31
|
+
// The open-endedness is absorbed by `lane`.
|
|
32
|
+
owner: z.string().trim().min(1).max(128),
|
|
33
|
+
severity: adminAlertSeveritySchema,
|
|
34
|
+
status: z.enum(["open", "acknowledged"]),
|
|
35
|
+
// Server-decided so every consumer agrees; the client never re-derives it.
|
|
36
|
+
pageable: z.boolean(),
|
|
37
|
+
title: z.string().trim().min(1).max(512),
|
|
38
|
+
message: z.string().trim().max(4096),
|
|
39
|
+
supportCode: z.string().trim().max(128).nullable(),
|
|
40
|
+
runbookUrl: z.string().trim().max(1024).nullable(),
|
|
41
|
+
firstSeenAt: isoDateTimeSchema,
|
|
42
|
+
lastSeenAt: isoDateTimeSchema,
|
|
43
|
+
snoozedUntil: isoDateTimeSchema.nullable(),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
|
|
47
|
+
export const adminAlertsHeartbeatSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
lastSuccessAt: isoDateTimeSchema.nullable(),
|
|
50
|
+
staleAfterSeconds: z.number().int().positive(),
|
|
51
|
+
stale: z.boolean(),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
|
|
55
|
+
export const adminAlertsSummarySchema = z
|
|
56
|
+
.object({
|
|
57
|
+
/** Drives the bell badge. */
|
|
58
|
+
commercePageableCount: z.number().int().min(0),
|
|
59
|
+
commerceTotalCount: z.number().int().min(0),
|
|
60
|
+
platformPageableCount: z.number().int().min(0),
|
|
61
|
+
/** Visible controlled exceptions; excluded from paging and health degradation. */
|
|
62
|
+
snoozedCount: z.number().int().min(0),
|
|
63
|
+
maxOpenSeverity: adminAlertSeveritySchema.nullable(),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
|
|
67
|
+
export const adminAlertsOverviewResponseSchema = z
|
|
68
|
+
.object({
|
|
69
|
+
health: adminAlertHealthSchema,
|
|
70
|
+
summary: adminAlertsSummarySchema,
|
|
71
|
+
heartbeat: adminAlertsHeartbeatSchema,
|
|
72
|
+
alerts: z.array(adminAlertSchema).max(50),
|
|
73
|
+
// Snoozed rows are returned separately from the active-list cap so a
|
|
74
|
+
// controlled exception never disappears behind routine diagnostics.
|
|
75
|
+
snoozedAlerts: z.array(adminAlertSchema).max(200),
|
|
76
|
+
generatedAt: isoDateTimeSchema,
|
|
77
|
+
})
|
|
78
|
+
.strict();
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdminAlert,
|
|
3
|
+
AdminAlertHealth,
|
|
4
|
+
AdminAlertLane,
|
|
5
|
+
AdminAlertSeverity,
|
|
6
|
+
AdminAlertsHeartbeat,
|
|
7
|
+
AdminAlertsOverviewResponse,
|
|
8
|
+
AdminAlertsSummary,
|
|
9
|
+
} from "./contracts/adminAlertsContracts.js";
|
|
10
|
+
import { DEFAULT_PAGING_MIN_SEVERITY, shouldPage } from "./alertPagingPolicy.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pure projection of platform_alerts rows onto the admin shell's badges.
|
|
14
|
+
*
|
|
15
|
+
* All filtering lives here rather than in SQL so the `now` boundary is injectable
|
|
16
|
+
* and every rule is unit-testable without a database.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Raw ledger row as selected from public.platform_alerts. */
|
|
20
|
+
export type PlatformAlertLedgerRow = {
|
|
21
|
+
id: string;
|
|
22
|
+
dedupe_key: string;
|
|
23
|
+
owner: string;
|
|
24
|
+
severity: string;
|
|
25
|
+
status: string;
|
|
26
|
+
title: string;
|
|
27
|
+
message: string | null;
|
|
28
|
+
support_code: string | null;
|
|
29
|
+
runbook_url: string | null;
|
|
30
|
+
first_seen_at: string;
|
|
31
|
+
last_seen_at: string;
|
|
32
|
+
snoozed_until: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type WatchdogHeartbeatRow = {
|
|
36
|
+
last_success_at: string | null;
|
|
37
|
+
} | null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generic scheduled-job alerts inherit `owner` from the job catalog entry, so a
|
|
41
|
+
* commerce-owned job leaks engineer-only noise ("this cron failed") into what
|
|
42
|
+
* should be an operator's queue. They still count toward platform health.
|
|
43
|
+
*/
|
|
44
|
+
export const GENERIC_JOB_DEDUPE_PREFIXES = [
|
|
45
|
+
"job_failed:",
|
|
46
|
+
"job_missed:",
|
|
47
|
+
"job_never_succeeded:",
|
|
48
|
+
"job_stuck_running:",
|
|
49
|
+
"job_ledger_missing:",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How old a watchdog heartbeat may be before the health pill stops trusting it.
|
|
54
|
+
*
|
|
55
|
+
* The development-preview reference uses a conservative six-hour stale window
|
|
56
|
+
* independent from any scheduler cadence. It is a policy default, not a
|
|
57
|
+
* scheduling SLA; adopters must review it against their expected maximum
|
|
58
|
+
* heartbeat delay and queue jitter.
|
|
59
|
+
*/
|
|
60
|
+
export const WATCHDOG_STALE_AFTER_SECONDS = 6 * 60 * 60;
|
|
61
|
+
|
|
62
|
+
const SEVERITY_RANK: Record<AdminAlertSeverity, number> = { p0: 0, p1: 1, p2: 2, p3: 3 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derived from the paging policy's DEFAULT floor, not from the live threshold:
|
|
66
|
+
* this is a pure projection with no access to PLATFORM_ALERT_PAGING_MIN_SEVERITY,
|
|
67
|
+
* so an operator who overrides that variable makes panel and pager disagree
|
|
68
|
+
* again. Threading the parsed floor through the admin BFF is wave-3 work.
|
|
69
|
+
*/
|
|
70
|
+
function isPageableSeverity(severity: AdminAlertSeverity): boolean {
|
|
71
|
+
return shouldPage(severity, DEFAULT_PAGING_MIN_SEVERITY);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isGenericJobAlert(dedupeKey: string): boolean {
|
|
75
|
+
return GENERIC_JOB_DEDUPE_PREFIXES.some((prefix) => dedupeKey.startsWith(prefix));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Display bucket. `commerce` means "a business operator can act on this".
|
|
80
|
+
*
|
|
81
|
+
* `commerce/platform` is deliberately excluded: despite the prefix it owns
|
|
82
|
+
* engineering-run plumbing (outbox-dispatch, outbox-prune, abandoned-cart,
|
|
83
|
+
* review-request, reorder-reminder).
|
|
84
|
+
*/
|
|
85
|
+
export function classifyLane(owner: string, dedupeKey: string): AdminAlertLane {
|
|
86
|
+
if (isGenericJobAlert(dedupeKey)) return "platform";
|
|
87
|
+
if (!owner.startsWith("commerce/")) return "platform";
|
|
88
|
+
if (owner === "commerce/platform") return "platform";
|
|
89
|
+
return "commerce";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isSnoozed(snoozedUntil: string | null, now: Date): boolean {
|
|
93
|
+
if (!snoozedUntil) return false;
|
|
94
|
+
const until = Date.parse(snoozedUntil);
|
|
95
|
+
return Number.isFinite(until) && until > now.getTime();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unknown severities are treated as p0 — mirrors alertPagingPolicy's fail-safe,
|
|
100
|
+
* where an unrecognised severity always pages rather than silently disappearing.
|
|
101
|
+
*/
|
|
102
|
+
function normalizeSeverity(severity: string): AdminAlertSeverity {
|
|
103
|
+
return severity in SEVERITY_RANK ? (severity as AdminAlertSeverity) : "p0";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeIso(value: string): string {
|
|
107
|
+
const parsed = Date.parse(value);
|
|
108
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Unresolved ledger state, including a controlled snoozed exception. */
|
|
112
|
+
function isOpen(row: PlatformAlertLedgerRow): boolean {
|
|
113
|
+
if (row.status !== "open" && row.status !== "acknowledged") return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Live enough to affect the health badge and paging. */
|
|
118
|
+
function isLive(row: PlatformAlertLedgerRow, now: Date): boolean {
|
|
119
|
+
return isOpen(row) && !isSnoozed(row.snoozed_until, now);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pageable means "this would wake someone up": live and above the paging floor.
|
|
124
|
+
* Acknowledged rows stay pageable on purpose — acknowledged is not resolved, the
|
|
125
|
+
* system is still broken.
|
|
126
|
+
*/
|
|
127
|
+
export function isPageable(row: PlatformAlertLedgerRow, now: Date): boolean {
|
|
128
|
+
return isLive(row, now) && isPageableSeverity(normalizeSeverity(row.severity));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function toAdminAlert(row: PlatformAlertLedgerRow, now: Date): AdminAlert {
|
|
132
|
+
return {
|
|
133
|
+
id: row.id,
|
|
134
|
+
dedupeKey: row.dedupe_key,
|
|
135
|
+
lane: classifyLane(row.owner, row.dedupe_key),
|
|
136
|
+
owner: row.owner,
|
|
137
|
+
severity: normalizeSeverity(row.severity),
|
|
138
|
+
status: row.status === "acknowledged" ? "acknowledged" : "open",
|
|
139
|
+
pageable: isPageable(row, now),
|
|
140
|
+
title: row.title,
|
|
141
|
+
message: row.message ?? "",
|
|
142
|
+
supportCode: row.support_code,
|
|
143
|
+
runbookUrl: row.runbook_url,
|
|
144
|
+
firstSeenAt: normalizeIso(row.first_seen_at),
|
|
145
|
+
lastSeenAt: normalizeIso(row.last_seen_at),
|
|
146
|
+
snoozedUntil: row.snoozed_until ? normalizeIso(row.snoozed_until) : null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function worstSeverity(alerts: readonly AdminAlert[]): AdminAlertSeverity | null {
|
|
151
|
+
let worst: AdminAlertSeverity | null = null;
|
|
152
|
+
for (const alert of alerts) {
|
|
153
|
+
if (!worst || SEVERITY_RANK[alert.severity] < SEVERITY_RANK[worst]) worst = alert.severity;
|
|
154
|
+
}
|
|
155
|
+
return worst;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `commercePageableCount` and `platformPageableCount` partition the pageable set,
|
|
160
|
+
* so no alert that would page is ever invisible in the popover.
|
|
161
|
+
*/
|
|
162
|
+
export function summarizeAlerts(alerts: readonly AdminAlert[]): AdminAlertsSummary {
|
|
163
|
+
const pageable = alerts.filter((alert) => alert.pageable);
|
|
164
|
+
return {
|
|
165
|
+
commercePageableCount: pageable.filter((alert) => alert.lane === "commerce").length,
|
|
166
|
+
commerceTotalCount: alerts.filter((alert) => alert.lane === "commerce").length,
|
|
167
|
+
platformPageableCount: pageable.filter((alert) => alert.lane === "platform").length,
|
|
168
|
+
snoozedCount: 0,
|
|
169
|
+
maxOpenSeverity: worstSeverity(alerts),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function buildHeartbeat(row: WatchdogHeartbeatRow, now: Date): AdminAlertsHeartbeat {
|
|
174
|
+
const lastSuccessAt = row?.last_success_at ?? null;
|
|
175
|
+
const parsed = lastSuccessAt ? Date.parse(lastSuccessAt) : Number.NaN;
|
|
176
|
+
const stale =
|
|
177
|
+
!Number.isFinite(parsed) || now.getTime() - parsed > WATCHDOG_STALE_AFTER_SECONDS * 1000;
|
|
178
|
+
return {
|
|
179
|
+
lastSuccessAt: Number.isFinite(parsed) ? new Date(parsed).toISOString() : null,
|
|
180
|
+
staleAfterSeconds: WATCHDOG_STALE_AFTER_SECONDS,
|
|
181
|
+
stale,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Staleness beats severity: if the watchdog has not reported recently we do not
|
|
187
|
+
* know the current state, and "unknown" is the only honest answer. There is no
|
|
188
|
+
* path here that renders a green "systems ok" from missing data.
|
|
189
|
+
*/
|
|
190
|
+
export function deriveHealth(
|
|
191
|
+
summary: AdminAlertsSummary,
|
|
192
|
+
heartbeat: AdminAlertsHeartbeat,
|
|
193
|
+
): AdminAlertHealth {
|
|
194
|
+
if (heartbeat.stale) return "unknown";
|
|
195
|
+
if (summary.maxOpenSeverity === "p0") return "down";
|
|
196
|
+
if (summary.maxOpenSeverity === "p1") return "degraded";
|
|
197
|
+
return "ok";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Alerts returned to the client; counts above stay accurate even when this is truncated. */
|
|
201
|
+
export const ADMIN_ALERTS_LIST_LIMIT = 50;
|
|
202
|
+
|
|
203
|
+
function compareForDisplay(a: AdminAlert, b: AdminAlert): number {
|
|
204
|
+
const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
205
|
+
if (bySeverity !== 0) return bySeverity;
|
|
206
|
+
return Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function buildAlertsOverview(
|
|
210
|
+
rows: readonly PlatformAlertLedgerRow[],
|
|
211
|
+
heartbeatRow: WatchdogHeartbeatRow,
|
|
212
|
+
now: Date,
|
|
213
|
+
): AdminAlertsOverviewResponse {
|
|
214
|
+
const openRows = rows.filter(isOpen);
|
|
215
|
+
const alerts = openRows
|
|
216
|
+
.filter((row) => isLive(row, now))
|
|
217
|
+
.map((row) => toAdminAlert(row, now))
|
|
218
|
+
.sort(compareForDisplay);
|
|
219
|
+
const snoozedAlerts = openRows
|
|
220
|
+
.filter((row) => isSnoozed(row.snoozed_until, now))
|
|
221
|
+
.map((row) => toAdminAlert(row, now))
|
|
222
|
+
.sort(compareForDisplay);
|
|
223
|
+
const summary = {
|
|
224
|
+
...summarizeAlerts(alerts),
|
|
225
|
+
snoozedCount: snoozedAlerts.length,
|
|
226
|
+
};
|
|
227
|
+
const heartbeat = buildHeartbeat(heartbeatRow, now);
|
|
228
|
+
return {
|
|
229
|
+
health: deriveHealth(summary, heartbeat),
|
|
230
|
+
summary,
|
|
231
|
+
heartbeat,
|
|
232
|
+
alerts: alerts.slice(0, ADMIN_ALERTS_LIST_LIMIT),
|
|
233
|
+
// Deliberately separate from the active list's limit: controlled exceptions
|
|
234
|
+
// are relevant audit context, but must not turn the health pill red.
|
|
235
|
+
snoozedAlerts,
|
|
236
|
+
generatedAt: now.toISOString(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AlertSeverity } from "./contracts/observabilityContracts.js";
|
|
2
|
+
const ALERT_SEVERITIES: readonly AlertSeverity[] = ["p0", "p1", "p2", "p3"];
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Paging policy for platform watchdog alerts.
|
|
6
|
+
*
|
|
7
|
+
* The alert ledger records every severity, but only sufficiently severe alerts
|
|
8
|
+
* should page an on-call channel (e.g. ntfy). Lower-severity alerts stay
|
|
9
|
+
* ledger-only so real incidents are not buried under routine noise.
|
|
10
|
+
*
|
|
11
|
+
* The tiering is settled: **p0 pages** (customers cannot buy, renewals cannot
|
|
12
|
+
* charge, the platform or database is down); **p1 is panel-urgent** and turns
|
|
13
|
+
* the admin health pill `degraded` without waking anyone; **p2/p3** are the
|
|
14
|
+
* panel list and the ledger. An unknown severity still pages: the fail-open
|
|
15
|
+
* rule is unchanged, because silence must never be the result of a
|
|
16
|
+
* classification error.
|
|
17
|
+
*
|
|
18
|
+
* Lower rank == more severe.
|
|
19
|
+
*/
|
|
20
|
+
const SEVERITY_RANK: Record<AlertSeverity, number> = { p0: 0, p1: 1, p2: 2, p3: 3 };
|
|
21
|
+
|
|
22
|
+
/** Alerts at or above this severity page by default; below it are ledger-only. */
|
|
23
|
+
export const DEFAULT_PAGING_MIN_SEVERITY: AlertSeverity = "p0";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether an alert of `severity` should page given the `minSeverity` threshold.
|
|
27
|
+
*
|
|
28
|
+
* Fail-safe: an unknown/unparseable severity always pages (treated as most
|
|
29
|
+
* urgent). Silence must never be the result of a classification error.
|
|
30
|
+
*/
|
|
31
|
+
export function shouldPage(
|
|
32
|
+
severity: string,
|
|
33
|
+
minSeverity: AlertSeverity = DEFAULT_PAGING_MIN_SEVERITY,
|
|
34
|
+
): boolean {
|
|
35
|
+
const rank = SEVERITY_RANK[severity as AlertSeverity];
|
|
36
|
+
if (rank === undefined) return true;
|
|
37
|
+
return rank <= SEVERITY_RANK[minSeverity];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a paging threshold from configuration (e.g. an env var), falling back to
|
|
42
|
+
* {@link DEFAULT_PAGING_MIN_SEVERITY} for any missing or invalid value.
|
|
43
|
+
*/
|
|
44
|
+
export function parsePagingMinSeverity(value: string | null | undefined): AlertSeverity {
|
|
45
|
+
const normalized = value?.trim().toLowerCase();
|
|
46
|
+
if (normalized && (ALERT_SEVERITIES as readonly string[]).includes(normalized)) {
|
|
47
|
+
return normalized as AlertSeverity;
|
|
48
|
+
}
|
|
49
|
+
return DEFAULT_PAGING_MIN_SEVERITY;
|
|
50
|
+
}
|