settld 0.2.4 → 0.2.6
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/Dockerfile +2 -2
- package/docs/CONFIG.md +12 -0
- package/docs/README.md +3 -0
- package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
- package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
- package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
- package/package.json +4 -1
- package/packages/api-sdk/README.md +71 -0
- package/packages/api-sdk/src/client.js +1021 -0
- package/packages/api-sdk/src/express-middleware.js +163 -0
- package/packages/api-sdk/src/index.d.ts +1662 -0
- package/packages/api-sdk/src/index.js +10 -0
- package/packages/api-sdk/src/webhook-signature.js +182 -0
- package/packages/api-sdk/src/x402-autopay.js +210 -0
- package/scripts/ci/cli-pack-smoke.mjs +2 -0
- package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
- package/scripts/setup/login.mjs +73 -2
- package/scripts/setup/onboard.mjs +173 -28
- package/scripts/setup/onboarding-failure-taxonomy.mjs +107 -0
- package/scripts/setup/onboarding-state-machine.mjs +102 -0
- package/services/magic-link/README.md +352 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/trust.json +11 -0
- package/services/magic-link/src/audit-log.js +24 -0
- package/services/magic-link/src/buyer-auth.js +251 -0
- package/services/magic-link/src/buyer-notifications.js +402 -0
- package/services/magic-link/src/buyer-users.js +129 -0
- package/services/magic-link/src/decision-otp.js +187 -0
- package/services/magic-link/src/decisions.js +92 -0
- package/services/magic-link/src/email-resend.js +89 -0
- package/services/magic-link/src/ingest-keys.js +137 -0
- package/services/magic-link/src/maintenance.js +95 -0
- package/services/magic-link/src/onboarding-email-sequence.js +331 -0
- package/services/magic-link/src/payment-triggers.js +733 -0
- package/services/magic-link/src/pdf.js +149 -0
- package/services/magic-link/src/policy.js +69 -0
- package/services/magic-link/src/redaction.js +6 -0
- package/services/magic-link/src/render-model.js +70 -0
- package/services/magic-link/src/retention-gc.js +158 -0
- package/services/magic-link/src/run-records.js +496 -0
- package/services/magic-link/src/s3.js +171 -0
- package/services/magic-link/src/server.js +15849 -0
- package/services/magic-link/src/settlement-decisions.js +84 -0
- package/services/magic-link/src/smtp.js +217 -0
- package/services/magic-link/src/storage-cli.js +88 -0
- package/services/magic-link/src/storage-format.js +59 -0
- package/services/magic-link/src/tenant-billing.js +115 -0
- package/services/magic-link/src/tenant-onboarding.js +467 -0
- package/services/magic-link/src/tenant-settings.js +1140 -0
- package/services/magic-link/src/usage.js +80 -0
- package/services/magic-link/src/verify-queue.js +179 -0
- package/services/magic-link/src/verify-worker.js +157 -0
- package/services/magic-link/src/webhook-retries.js +542 -0
- package/services/magic-link/src/webhooks.js +218 -0
- package/src/api/app.js +135 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { sendSmtpMail } from "./smtp.js";
|
|
6
|
+
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isPlainObject(v) {
|
|
12
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeEmailLower(value) {
|
|
16
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
17
|
+
if (!raw || raw.length > 320 || /\s/.test(raw)) return null;
|
|
18
|
+
const parts = raw.split("@");
|
|
19
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onboardingEmailSequencePath({ dataDir, tenantId }) {
|
|
24
|
+
return path.join(dataDir, "tenants", tenantId, "onboarding_email_sequence.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onboardingEmailOutboxPath({ dataDir, tenantId, stepKey, recipient, sentAt }) {
|
|
28
|
+
const hash = crypto.createHash("sha256").update(`${tenantId}\n${stepKey}\n${recipient}\n${sentAt}`, "utf8").digest("hex").slice(0, 16);
|
|
29
|
+
return path.join(dataDir, "onboarding-email-outbox", tenantId, stepKey, `${sentAt.replaceAll(":", "-")}_${hash}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ONBOARDING_EMAIL_SEQUENCE_VERSION = "MagicLinkOnboardingEmailSequence.v1";
|
|
33
|
+
|
|
34
|
+
const ONBOARDING_EMAIL_STEPS = Object.freeze([
|
|
35
|
+
Object.freeze({
|
|
36
|
+
stepKey: "welcome",
|
|
37
|
+
label: "Welcome + launch checklist",
|
|
38
|
+
trigger: (profile) => (typeof profile?.createdAt === "string" && profile.createdAt ? profile.createdAt : null),
|
|
39
|
+
subject: (ctx) => `Welcome to Settld, ${ctx.tenantName}`,
|
|
40
|
+
text: (ctx) =>
|
|
41
|
+
[
|
|
42
|
+
`Welcome to Settld, ${ctx.tenantName}.`,
|
|
43
|
+
"",
|
|
44
|
+
"Your onboarding workspace is ready.",
|
|
45
|
+
`Open: ${ctx.onboardingUrl}`,
|
|
46
|
+
"",
|
|
47
|
+
"Goal for today: complete your first verified live settlement in under 10 minutes.",
|
|
48
|
+
"1) Upload a sample bundle",
|
|
49
|
+
"2) Validate policy",
|
|
50
|
+
"3) Run first live settlement",
|
|
51
|
+
"",
|
|
52
|
+
"If you need examples, use the Python quickstart in docs/QUICKSTART_SDK_PYTHON.md.",
|
|
53
|
+
"",
|
|
54
|
+
"— Settld"
|
|
55
|
+
].join("\n")
|
|
56
|
+
}),
|
|
57
|
+
Object.freeze({
|
|
58
|
+
stepKey: "sample_verified_nudge",
|
|
59
|
+
label: "Sample verified -> go live nudge",
|
|
60
|
+
trigger: (profile) =>
|
|
61
|
+
profile?.firstSampleVerifiedAt && !profile?.firstUploadAt
|
|
62
|
+
? String(profile.firstSampleVerifiedAt)
|
|
63
|
+
: null,
|
|
64
|
+
subject: () => "Sample verified. Push your first live settlement now.",
|
|
65
|
+
text: (ctx) =>
|
|
66
|
+
[
|
|
67
|
+
"Your sample verification passed.",
|
|
68
|
+
"",
|
|
69
|
+
"Next step: run your first live settlement now.",
|
|
70
|
+
`Open onboarding: ${ctx.onboardingUrl}`,
|
|
71
|
+
`Pricing reference: ${ctx.pricingUrl}`,
|
|
72
|
+
"",
|
|
73
|
+
"Keep momentum: teams that run live within the same session activate fastest.",
|
|
74
|
+
"",
|
|
75
|
+
"— Settld"
|
|
76
|
+
].join("\n")
|
|
77
|
+
}),
|
|
78
|
+
Object.freeze({
|
|
79
|
+
stepKey: "first_settlement_completed",
|
|
80
|
+
label: "First settlement completed + referral invite",
|
|
81
|
+
trigger: (profile) => (typeof profile?.firstVerifiedAt === "string" && profile.firstVerifiedAt ? profile.firstVerifiedAt : null),
|
|
82
|
+
subject: () => "First settlement complete. Invite another team.",
|
|
83
|
+
text: (ctx) =>
|
|
84
|
+
[
|
|
85
|
+
"Your first verified settlement is complete.",
|
|
86
|
+
"",
|
|
87
|
+
"Next steps:",
|
|
88
|
+
"1) Add your second endpoint/workflow",
|
|
89
|
+
"2) Enable policy presets for default guardrails",
|
|
90
|
+
"3) Invite one peer team and track referral conversion",
|
|
91
|
+
"",
|
|
92
|
+
`Track referral events via onboarding API: POST /v1/tenants/${ctx.tenantId}/onboarding/events`,
|
|
93
|
+
"eventType values: referral_link_shared, referral_signup",
|
|
94
|
+
"",
|
|
95
|
+
"— Settld"
|
|
96
|
+
].join("\n")
|
|
97
|
+
})
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function defaultSequenceState({ tenantId }) {
|
|
101
|
+
return {
|
|
102
|
+
schemaVersion: ONBOARDING_EMAIL_SEQUENCE_VERSION,
|
|
103
|
+
tenantId,
|
|
104
|
+
steps: {},
|
|
105
|
+
updatedAt: nowIso()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function loadOnboardingEmailSequenceStateBestEffort({ dataDir, tenantId }) {
|
|
110
|
+
const fp = onboardingEmailSequencePath({ dataDir, tenantId });
|
|
111
|
+
try {
|
|
112
|
+
const raw = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
113
|
+
if (!isPlainObject(raw)) return defaultSequenceState({ tenantId });
|
|
114
|
+
const stepsIn = isPlainObject(raw.steps) ? raw.steps : {};
|
|
115
|
+
const steps = {};
|
|
116
|
+
for (const row of ONBOARDING_EMAIL_STEPS) {
|
|
117
|
+
const step = isPlainObject(stepsIn[row.stepKey]) ? stepsIn[row.stepKey] : null;
|
|
118
|
+
if (!step || typeof step.sentAt !== "string" || step.sentAt.trim() === "") continue;
|
|
119
|
+
steps[row.stepKey] = {
|
|
120
|
+
sentAt: step.sentAt,
|
|
121
|
+
triggerAt: typeof step.triggerAt === "string" ? step.triggerAt : null,
|
|
122
|
+
deliveryMode: typeof step.deliveryMode === "string" ? step.deliveryMode : null,
|
|
123
|
+
recipients: Array.isArray(step.recipients) ? step.recipients.map((x) => String(x ?? "").trim().toLowerCase()).filter(Boolean) : []
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: ONBOARDING_EMAIL_SEQUENCE_VERSION,
|
|
128
|
+
tenantId,
|
|
129
|
+
steps,
|
|
130
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return defaultSequenceState({ tenantId });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function saveOnboardingEmailSequenceState({ dataDir, tenantId, state }) {
|
|
138
|
+
const fp = onboardingEmailSequencePath({ dataDir, tenantId });
|
|
139
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
140
|
+
const next = {
|
|
141
|
+
...defaultSequenceState({ tenantId }),
|
|
142
|
+
...(isPlainObject(state) ? state : {}),
|
|
143
|
+
tenantId,
|
|
144
|
+
updatedAt: nowIso()
|
|
145
|
+
};
|
|
146
|
+
await fs.writeFile(fp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeDeliveryMode(rawMode, { smtpConfigured }) {
|
|
151
|
+
const raw = String(rawMode ?? "").trim().toLowerCase();
|
|
152
|
+
if (!raw) return smtpConfigured ? "smtp" : "record";
|
|
153
|
+
if (raw === "record" || raw === "log" || raw === "smtp") return raw;
|
|
154
|
+
throw new Error("MAGIC_LINK_ONBOARDING_EMAIL_DELIVERY_MODE must be record|log|smtp");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function recipientsFromProfile(profile) {
|
|
158
|
+
const out = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
const add = (emailRaw) => {
|
|
161
|
+
const email = normalizeEmailLower(emailRaw);
|
|
162
|
+
if (!email) return;
|
|
163
|
+
if (seen.has(email)) return;
|
|
164
|
+
seen.add(email);
|
|
165
|
+
out.push(email);
|
|
166
|
+
};
|
|
167
|
+
add(profile?.contactEmail ?? null);
|
|
168
|
+
add(profile?.billingEmail ?? null);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function evaluateSteps({ profile }) {
|
|
173
|
+
const list = [];
|
|
174
|
+
for (const step of ONBOARDING_EMAIL_STEPS) {
|
|
175
|
+
const triggerAt = step.trigger(profile);
|
|
176
|
+
list.push({
|
|
177
|
+
stepKey: step.stepKey,
|
|
178
|
+
label: step.label,
|
|
179
|
+
triggerAt: triggerAt && String(triggerAt).trim() !== "" ? String(triggerAt).trim() : null
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return list;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function buildOnboardingEmailSequenceStatus({ tenantId, profile, state, enabled, deliveryMode }) {
|
|
186
|
+
const evaluated = evaluateSteps({ profile });
|
|
187
|
+
const stepsState = isPlainObject(state?.steps) ? state.steps : {};
|
|
188
|
+
const steps = evaluated.map((row) => {
|
|
189
|
+
const sent = isPlainObject(stepsState[row.stepKey]) ? stepsState[row.stepKey] : null;
|
|
190
|
+
return {
|
|
191
|
+
stepKey: row.stepKey,
|
|
192
|
+
label: row.label,
|
|
193
|
+
eligible: Boolean(row.triggerAt),
|
|
194
|
+
triggerAt: row.triggerAt,
|
|
195
|
+
sentAt: sent && typeof sent.sentAt === "string" ? sent.sentAt : null
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
const totalSteps = steps.length;
|
|
199
|
+
const sentSteps = steps.filter((row) => typeof row.sentAt === "string" && row.sentAt).length;
|
|
200
|
+
const nextStep = steps.find((row) => row.eligible && !row.sentAt) ?? null;
|
|
201
|
+
return {
|
|
202
|
+
schemaVersion: "MagicLinkOnboardingEmailSequenceStatus.v1",
|
|
203
|
+
tenantId,
|
|
204
|
+
enabled: Boolean(enabled),
|
|
205
|
+
deliveryMode: deliveryMode ?? null,
|
|
206
|
+
totalSteps,
|
|
207
|
+
sentSteps,
|
|
208
|
+
completionPct: totalSteps > 0 ? Math.round((sentSteps / totalSteps) * 10000) / 100 : 0,
|
|
209
|
+
nextStepKey: nextStep ? nextStep.stepKey : null,
|
|
210
|
+
steps
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function dispatchOnboardingEmailSequenceBestEffort({
|
|
215
|
+
dataDir,
|
|
216
|
+
tenantId,
|
|
217
|
+
profile,
|
|
218
|
+
enabled = true,
|
|
219
|
+
deliveryMode = null,
|
|
220
|
+
smtpConfig = null,
|
|
221
|
+
publicBaseUrl = null
|
|
222
|
+
} = {}) {
|
|
223
|
+
if (!enabled) return { ok: true, skipped: true, reason: "disabled" };
|
|
224
|
+
if (!isPlainObject(profile)) return { ok: true, skipped: true, reason: "missing_profile" };
|
|
225
|
+
const t = String(tenantId ?? profile.tenantId ?? "").trim();
|
|
226
|
+
if (!t) return { ok: true, skipped: true, reason: "missing_tenant" };
|
|
227
|
+
|
|
228
|
+
const resolvedMode = normalizeDeliveryMode(deliveryMode, { smtpConfigured: Boolean(smtpConfig?.host && smtpConfig?.from) });
|
|
229
|
+
const recipients = recipientsFromProfile(profile);
|
|
230
|
+
if (recipients.length === 0) return { ok: true, skipped: true, reason: "no_recipients" };
|
|
231
|
+
|
|
232
|
+
const onboardingUrl = publicBaseUrl
|
|
233
|
+
? `${String(publicBaseUrl).replace(/\/+$/, "")}/v1/tenants/${encodeURIComponent(t)}/onboarding`
|
|
234
|
+
: `/v1/tenants/${encodeURIComponent(t)}/onboarding`;
|
|
235
|
+
const pricingUrl = publicBaseUrl ? `${String(publicBaseUrl).replace(/\/+$/, "")}/pricing` : "/pricing";
|
|
236
|
+
const tenantName = typeof profile?.name === "string" && profile.name.trim() ? profile.name.trim() : t;
|
|
237
|
+
|
|
238
|
+
const state = await loadOnboardingEmailSequenceStateBestEffort({ dataDir, tenantId: t });
|
|
239
|
+
const nextState = {
|
|
240
|
+
schemaVersion: ONBOARDING_EMAIL_SEQUENCE_VERSION,
|
|
241
|
+
tenantId: t,
|
|
242
|
+
steps: isPlainObject(state.steps) ? { ...state.steps } : {},
|
|
243
|
+
updatedAt: nowIso()
|
|
244
|
+
};
|
|
245
|
+
const dispatched = [];
|
|
246
|
+
|
|
247
|
+
for (const step of ONBOARDING_EMAIL_STEPS) {
|
|
248
|
+
const triggerAt = step.trigger(profile);
|
|
249
|
+
if (!triggerAt) continue;
|
|
250
|
+
const existing = isPlainObject(nextState.steps[step.stepKey]) ? nextState.steps[step.stepKey] : null;
|
|
251
|
+
if (existing && typeof existing.sentAt === "string" && existing.sentAt.trim() !== "") continue;
|
|
252
|
+
|
|
253
|
+
const sentAt = nowIso();
|
|
254
|
+
const subject = step.subject({ tenantId: t, tenantName, onboardingUrl, pricingUrl });
|
|
255
|
+
const text = step.text({ tenantId: t, tenantName, onboardingUrl, pricingUrl });
|
|
256
|
+
const deliveries = [];
|
|
257
|
+
|
|
258
|
+
for (const recipient of recipients) {
|
|
259
|
+
if (resolvedMode === "record") {
|
|
260
|
+
const fp = onboardingEmailOutboxPath({ dataDir, tenantId: t, stepKey: step.stepKey, recipient, sentAt });
|
|
261
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
262
|
+
await fs.writeFile(
|
|
263
|
+
fp,
|
|
264
|
+
JSON.stringify(
|
|
265
|
+
{
|
|
266
|
+
schemaVersion: "MagicLinkOnboardingEmailOutbox.v1",
|
|
267
|
+
tenantId: t,
|
|
268
|
+
stepKey: step.stepKey,
|
|
269
|
+
label: step.label,
|
|
270
|
+
triggerAt,
|
|
271
|
+
sentAt,
|
|
272
|
+
recipient,
|
|
273
|
+
subject,
|
|
274
|
+
text
|
|
275
|
+
},
|
|
276
|
+
null,
|
|
277
|
+
2
|
|
278
|
+
) + "\n",
|
|
279
|
+
"utf8"
|
|
280
|
+
);
|
|
281
|
+
deliveries.push({ ok: true, recipient, mode: "record", outboxPath: fp });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (resolvedMode === "log") {
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.log(`onboarding-sequence tenant=${t} step=${step.stepKey} recipient=${recipient} subject=${subject}`);
|
|
287
|
+
deliveries.push({ ok: true, recipient, mode: "log" });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
await sendSmtpMail({
|
|
292
|
+
host: smtpConfig?.host,
|
|
293
|
+
port: smtpConfig?.port,
|
|
294
|
+
secure: Boolean(smtpConfig?.secure),
|
|
295
|
+
starttls: smtpConfig?.starttls === undefined ? true : Boolean(smtpConfig?.starttls),
|
|
296
|
+
auth: smtpConfig?.user && smtpConfig?.pass ? { user: smtpConfig.user, pass: smtpConfig.pass } : null,
|
|
297
|
+
from: smtpConfig?.from,
|
|
298
|
+
to: recipient,
|
|
299
|
+
subject,
|
|
300
|
+
text
|
|
301
|
+
});
|
|
302
|
+
deliveries.push({ ok: true, recipient, mode: "smtp" });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
deliveries.push({ ok: false, recipient, mode: "smtp", error: err?.message ?? String(err ?? "smtp failed") });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!deliveries.some((row) => row.ok === true)) continue;
|
|
309
|
+
nextState.steps[step.stepKey] = {
|
|
310
|
+
sentAt,
|
|
311
|
+
triggerAt,
|
|
312
|
+
deliveryMode: resolvedMode,
|
|
313
|
+
recipients: deliveries.filter((row) => row.ok).map((row) => row.recipient)
|
|
314
|
+
};
|
|
315
|
+
dispatched.push({
|
|
316
|
+
stepKey: step.stepKey,
|
|
317
|
+
triggerAt,
|
|
318
|
+
sentAt,
|
|
319
|
+
deliveries
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const saved = await saveOnboardingEmailSequenceState({ dataDir, tenantId: t, state: nextState });
|
|
324
|
+
return {
|
|
325
|
+
ok: true,
|
|
326
|
+
tenantId: t,
|
|
327
|
+
deliveryMode: resolvedMode,
|
|
328
|
+
dispatched,
|
|
329
|
+
state: saved
|
|
330
|
+
};
|
|
331
|
+
}
|