Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -90553,6 +90553,7 @@
{
"name": "type",
"in": "query",
"required": true,
"schema": {
"type": "string",
"enum": [
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"nodemailer": "^10.0.1",
"pg": "8.23.0",
"pino": "9.6.0",
"sendlit": "^0.5.0",
"swagger-ui-express": "5.0.1",
"zod": "3.25.76"
},
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/contact-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dispatch } from "./dispatch.js";
import { createPgliteRuntime, freezeRuntimeClock, type Runtime } from "./runtime.js";
import { createSchool } from "./schools.js";
import { seedWorld, type SeededWorld } from "./seed.js";
import { jsonResponse } from "./sendlit-test-support.js";
import {
deleteSendLitTag,
getSendLitTagReferences,
Expand Down Expand Up @@ -57,7 +58,7 @@ function mockSendLit(
}

const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status });
jsonResponse(body, status);

describe("SendLit tag client", () => {
it("uses the right paths, methods and encoding", async () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/contacts-paging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js";
import { createSchool } from "./schools.js";
import { seedWorld } from "./seed.js";
import { encryptIntegrationSecret } from "./utils/integration-secrets.js";
import { jsonResponse } from "./sendlit-test-support.js";

describe("contact list paging", () => {
it("asks SendLit for the page by its number, so no contact is skipped", async () => {
Expand Down Expand Up @@ -49,7 +50,7 @@ describe("contact list paging", () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
requested.push(new URL(String(input)));
return new Response(JSON.stringify({ items: [], total: 120 }));
return jsonResponse({ items: [], total: 120 });
}) as typeof fetch;

try {
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3084,8 +3084,16 @@ export async function dispatch(
if (!context.permissions.has("contacts:read")) {
return errorResponse(createPlatformError("forbidden"));
}
const type = query.get("type");
if (type !== "broadcast" && type !== "sequence") {
return errorResponse(
createPlatformError("validation_failed", {
safeDetails: { reason: "type must be broadcast or sequence" },
}),
);
}
const result = await listSequences(deps.db, context.tenantId!, deps.clock, {
type: query.get("type") ?? undefined,
type,
status: query.get("status") ?? undefined,
search: query.get("search") ?? undefined,
cursor: query.get("cursor") ?? undefined,
Expand Down
30 changes: 25 additions & 5 deletions apps/api/src/integration-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,16 @@ describe("CourseLit SendLit provisioning", () => {
externalId: input.externalId,
name: input.name,
created: true,
deliverySource: { type: "organization" },
apiKey: "sl_live_key_999",
};
},
rotateKey: async () => ({ keyId: "key_1", key: "sl_live_rotated_key" }),
rotateKey: async () => ({
name: "courselit",
keyId: "key_1",
keyPrefix: "sl_live_rotat",
key: "sl_live_rotated_key",
}),
updateTeamQuota: async (teamId, quota) => {
quotas.push({ teamId, monthlyLimit: quota.monthlyLimit });
},
Expand All @@ -150,7 +156,7 @@ describe("CourseLit SendLit provisioning", () => {
clock,
runtime.logger,
sendLitOperations,
{ server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_key" },
{ server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_live_test" },
);
expect(processed).toBe(true);
// Its email goes out under the school's name, not the shared account's.
Expand Down Expand Up @@ -216,16 +222,22 @@ describe("CourseLit SendLit provisioning", () => {
externalId: input.externalId,
name: input.name,
created: true,
deliverySource: { type: "organization" },
// SendLit hands over the key only here, when it creates the team.
apiKey: "sl_live_key_once",
};
},
rotateKey: async () => ({ keyId: "key_1", key: "sl_live_rotated_key" }),
rotateKey: async () => ({
name: "courselit",
keyId: "key_1",
keyPrefix: "sl_live_rotat",
key: "sl_live_rotated_key",
}),
updateTeamQuota: async () => {
if (quotaFails) throw new Error("SendLit API error 403: organization_scope_required");
},
};
const config = { server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_key" };
const config = { server: "http://127.0.0.1:4101", provisioningApiKey: "sl_org_live_test" };
const integration = async () =>
(
await runtime.db
Expand Down Expand Up @@ -353,9 +365,15 @@ describe("CourseLit SendLit provisioning", () => {
externalId: created.value.id,
name: "Test",
created: true,
deliverySource: { type: "organization" },
apiKey: "sl_live_key",
}),
rotateKey: async () => ({ keyId: "kid_1", key: "sl_live_key" }),
rotateKey: async () => ({
name: "courselit",
keyId: "kid_1",
keyPrefix: "sl_live_key",
key: "sl_live_key",
}),
listContacts: async () => ({ items: [], total: 0 }),
createContact: async (_key, input) => {
syncAttempts += 1;
Expand All @@ -370,6 +388,7 @@ describe("CourseLit SendLit provisioning", () => {
subscribed: true,
tags: [],
customFields: {},
unsubscribeToken: "tok_1",
createdAt: clock.now().toISOString(),
updatedAt: clock.now().toISOString(),
};
Expand All @@ -383,6 +402,7 @@ describe("CourseLit SendLit provisioning", () => {
subscribed: input.subscribed ?? true,
tags: input.tags ?? [],
customFields: input.customFields ?? {},
unsubscribeToken: "tok_1",
createdAt: clock.now().toISOString(),
updatedAt: clock.now().toISOString(),
};
Expand Down
12 changes: 5 additions & 7 deletions apps/api/src/integration-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
rotateSendLitTeamKey,
updateSendLitTeamQuota,
SendLitApiError,
type SendLitCustomFields,
type SendLitUpdateContactInput,
type SendLitConfig,
type SendLitContact,
sendLitConfig,
Expand Down Expand Up @@ -434,7 +436,7 @@ async function syncSendLitContactJob(
schoolAccount.learnerRegisteredAt ?? schoolAccount.createdAt
).toISOString();

const mergedCustomFields: Record<string, unknown> = {
const mergedCustomFields: SendLitCustomFields = {
...(currentContact?.customFields ?? {}),
[COURSELIT_PRODUCT_IDS_FIELD]: productIds,
[COURSELIT_IS_COMMUNITY_MEMBER_FIELD]: isCommunityMember ? "true" : "false",
Expand All @@ -443,11 +445,7 @@ async function syncSendLitContactJob(
[COURSELIT_SIGNED_UP_FIELD]: signedUp,
};

const updateBody: {
name: string;
customFields: Record<string, unknown>;
subscribed?: boolean;
} = {
const updateBody: SendLitUpdateContactInput = {
name: schoolAccount.displayName,
customFields: mergedCustomFields,
};
Expand Down Expand Up @@ -732,7 +730,7 @@ async function failJob(
error instanceof SendLitApiError &&
(error.status === 402 ||
error.status === 403 ||
error.message.includes("plan_limit_exceeded"))
error.code === "plan_limit_exceeded")
) {
message = "sendlit_quota_exceeded";
actionRequired = true;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/learner-profile-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function contact(
subscribed: true,
tags: [],
customFields: {},
unsubscribeToken: "tok_1",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
...input,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/learner-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("learner SendLit profile mapping", () => {
[LEARNER_AVATAR_URL_FIELD]: "https://media.example/avatar.webp",
},
tags: ["learner"],
unsubscribeToken: "tok_1",
createdAt: null,
updatedAt: null,
});
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/learner-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SendLitApiError,
type SendLitConfig,
type SendLitContact,
type SendLitCustomFields,
updateSendLitContact,
} from "./sendlit-client.js";
import type { AppDb } from "./types.js";
Expand Down Expand Up @@ -102,10 +103,10 @@ export async function readLearnerProfile(
}

function profileCustomFields(
existing: Record<string, unknown>,
existing: SendLitCustomFields,
input: { bio: string; avatarMediaId: string | null; avatarUrl: string | null },
) {
const customFields = { ...existing };
const customFields: SendLitCustomFields = { ...existing };
if (input.bio.trim()) customFields[LEARNER_BIO_FIELD] = input.bio.trim();
else delete customFields[LEARNER_BIO_FIELD];
if (input.avatarMediaId) {
Expand Down
15 changes: 10 additions & 5 deletions apps/api/src/mail-automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,10 @@ describe("section drip emails", () => {
const sent: Array<{ to: string; subject?: string; html?: string }> = [];
const sendEmail = async (
_key: string,
input: { to: string; subject?: string; html?: string },
input: { to: string; subject: string; html?: string },
) => {
sent.push(input);
return { txeId: `txe_${sent.length}` };
return { txeId: `txe_${sent.length}`, status: "queued" as const };
};

const beforeUnlock = freezeRuntimeClock(new Date("2026-03-01T12:00:00.000Z"));
Expand Down Expand Up @@ -412,17 +412,22 @@ describe("section drip emails", () => {
let attempts = 0;
let quotaLeft = false;
const sent: string[] = [];
const sendEmail = async (_key: string, input: { to: string }) => {
const sendEmail = async (
_key: string,
input: { to: string; subject: string },
) => {
attempts += 1;
if (!quotaLeft) {
throw new SendLitApiError(
"SendLit request failed with HTTP 429: organization_team_quota_exhausted",
"SendLit API error 429: Team quota exhausted",
429,
true,
"organization_team_quota_exhausted",
"organization_team_quota_exhausted",
);
}
sent.push(input.to);
return { txeId: "txe_1" };
return { txeId: "txe_1", status: "queued" as const };
};
const warnings: unknown[] = [];
const logger = { warn: (...args: unknown[]) => warnings.push(args), info: () => {} };
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/marketing-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { seedWorld } from "./seed.js";
import { provisionSendLitTeam, updateSendLitTeamQuota } from "./sendlit-client.js";
import { planLimits } from "./plan-limits-config.js";

const config = { server: "http://sendlit.test", provisioningApiKey: "sl_org_key" };
const config = { server: "http://sendlit.test", provisioningApiKey: "sl_org_live_test" };

describe("marketingQuotaFor", () => {
it("follows the plan limits", () => {
Expand Down Expand Up @@ -149,7 +149,14 @@ function capture() {
body: init?.body ? JSON.parse(String(init.body)) : {},
});
return new Response(
JSON.stringify({ teamId: "t1", externalId: "e1", name: "n", created: true, apiKey: "k" }),
JSON.stringify({
teamId: "t1",
externalId: "e1",
name: "n",
created: true,
deliverySource: { type: "organization" },
apiKey: "k",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
Expand Down
36 changes: 18 additions & 18 deletions apps/api/src/sendlit-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
updateSendLitGeneralSettings,
updateSendLitSequence,
} from "./sendlit-client.js";
import { jsonResponse } from "./sendlit-test-support.js";

describe("sendlit-client", () => {
it("uses configured server and organization key in development", () => {
Expand Down Expand Up @@ -67,7 +68,7 @@ describe("sendlit-client", () => {
expect(String(input)).toBe("http://127.0.0.1:4101/provisioning/teams");
expect(init?.method).toBe("POST");
const headers = init?.headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer test-org-key");
expect(headers.authorization).toBe("Bearer sl_org_live_test");
expect(headers["content-type"]).toBe("application/json");

const body = JSON.parse(init?.body as string);
Expand All @@ -91,7 +92,7 @@ describe("sendlit-client", () => {
{
config: {
server: "http://127.0.0.1:4101",
provisioningApiKey: "test-org-key",
provisioningApiKey: "sl_org_live_test",
},
fetcher,
},
Expand Down Expand Up @@ -226,19 +227,18 @@ describe("sendlit-client", () => {
);
}
if (url.includes("/templates") && init?.method === "GET") {
return new Response(
JSON.stringify({
items: [
{
templateId: "tpl_1",
title: "Default Template",
purpose: "marketing",
content: { content: [] },
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
// SendLit lists templates as a bare array.
return jsonResponse([
{
templateId: "tpl_1",
title: "Default Template",
purpose: "marketing",
content: { style: {}, meta: {}, content: [] },
requiredVariables: [],
createdAt: null,
updatedAt: null,
},
]);
}
throw new Error(`Unhandled request: ${url}`);
};
Expand Down Expand Up @@ -294,19 +294,19 @@ describe("sendlit-client", () => {
const body = String(input).endsWith("/events")
? { eventId: "evt_1", contactId: "cnt_1", duplicate: false, contactCreated: false }
: { sent: ["me@example.com"], lastTestSentAt: "2026-03-01T00:00:00.000Z" };
return new Response(JSON.stringify(body), { status: 200 });
return jsonResponse(body);
};
const config = { server: "http://sendlit.test", provisioningApiKey: null };

const test = await sendSendLitSequenceEmailTest(
"sl_team",
"seq 1",
"seq_1",
"eml_1",
{ to: ["me@example.com"] },
{ config, fetcher },
);
expect(test.sent).toEqual(["me@example.com"]);
expect(calls[0]!.url).toBe("http://sendlit.test/sequences/seq%201/emails/eml_1/test");
expect(calls[0]!.url).toBe("http://sendlit.test/sequences/seq_1/emails/eml_1/test");
expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ to: ["me@example.com"] });
expect((calls[0]!.init?.headers as Record<string, string>)["x-sendlit-apikey"]).toBe(
"sl_team",
Expand Down
Loading
Loading