Skip to content

Commit 713ca1c

Browse files
author
Rajat
committed
Latest changes
1 parent 6d49133 commit 713ca1c

43 files changed

Lines changed: 338 additions & 161 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ARCHITECTURE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,12 @@ Phase 5 — **done**:
228228
Idempotent, keyed by a consumer-supplied `externalId` (e.g.
229229
`courselit:<domainId>`) rather than the owner's email, since two of a
230230
consumer's own tenants may share an owner email (which would otherwise
231-
incorrectly merge them into one team).
231+
incorrectly merge them into one team). Ownership is still assigned: the
232+
request body includes `ownerEmail`, which is resolved via
233+
`findOrCreateBareAccount` (email lowercased; account created if missing);
234+
that account becomes the team's `ownerAccountId` and its sole
235+
`team_members` row with role `owner`. There is no fixed platform/system
236+
owner account — whichever email the consumer sends is the owner.
232237
- `src/bootstrap.ts`: a _separate_, boot-time-only convenience directly
233238
ported from MediaLit's `createAdminUser()` — if `SUPER_ADMIN_EMAIL` is
234239
set and no account exists for it yet, creates one (with its default team

apps/api/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ SUPPRESSION_HASH_KEY=
4848
# Platform mail transport for system email such as login OTPs. Campaign,
4949
# broadcast, sequence and ESP test mail require each team to configure its own
5050
# ESP in settings.
51+
# EMAIL_HOST is required in production. EMAIL_USER/EMAIL_PASS are optional
52+
# (leave empty for auth-less sinks like Mailpit).
5153
EMAIL_HOST=
5254
EMAIL_PORT=587
5355
EMAIL_USER=

apps/api/src/auth/better-auth.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ async function sendOtpEmail(email: string, otp: string) {
6464
return;
6565
}
6666

67-
if (!process.env.EMAIL_HOST || !process.env.EMAIL_USER) {
67+
// Platform SMTP for login OTPs. Host is required; user/pass are optional so
68+
// auth-less sinks (Mailpit, local smtp4dev) work without dummy credentials.
69+
if (!process.env.EMAIL_HOST) {
6870
logger.error(
6971
{ email },
7072
"Cannot send OTP email: SMTP is not configured",
@@ -75,10 +77,12 @@ async function sendOtpEmail(email: string, otp: string) {
7577
const transporter = createTransport({
7678
host: process.env.EMAIL_HOST,
7779
port: Number(process.env.EMAIL_PORT) || 587,
78-
auth: {
79-
user: process.env.EMAIL_USER,
80-
pass: process.env.EMAIL_PASS,
81-
},
80+
auth: process.env.EMAIL_USER
81+
? {
82+
user: process.env.EMAIL_USER,
83+
pass: process.env.EMAIL_PASS || "",
84+
}
85+
: undefined,
8286
});
8387

8488
await transporter.sendMail({

apps/api/src/automation/process-ongoing-sequence.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ async function cleanUpResources(
169169
// once every recipient has been delivered to. Multi-step "sequence" type
170170
// automations stay "active" indefinitely so future contacts can still be
171171
// enrolled by their trigger (see `automation/fire-event.ts`).
172+
// Zero-recipient broadcasts never create ongoing rows; they complete in
173+
// `processRule` instead (see automation/process-rules.ts).
172174
if (completed && sequenceType === "broadcast" && publicSequenceId) {
173175
const remaining = await countOngoingSequencesForSequence(
174176
ongoingSequence.sequenceId,

apps/api/src/automation/process-rules.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
88
getPublicIds: vi.fn(),
99
getSequence: vi.fn(),
1010
lockBroadcast: vi.fn(),
11+
markBroadcastSent: vi.fn(),
1112
captureError: vi.fn(),
1213
captureEvent: vi.fn(),
1314
}));
@@ -20,6 +21,7 @@ vi.mock("./queries", () => ({
2021
getMatchingPublicContactIds: mocks.getPublicIds,
2122
getSequenceRowById: mocks.getSequence,
2223
lockBroadcast: mocks.lockBroadcast,
24+
markBroadcastSent: mocks.markBroadcastSent,
2325
}));
2426
vi.mock("../observability/posthog", () => ({
2527
captureError: mocks.captureError,
@@ -44,9 +46,11 @@ beforeEach(() => {
4446
mocks.getInternalIds.mockResolvedValue(["contact-internal"]);
4547
mocks.getPublicIds.mockResolvedValue(["cnt_public"]);
4648
mocks.lockBroadcast.mockResolvedValue(undefined);
49+
mocks.markBroadcastSent.mockResolvedValue(undefined);
4750
mocks.getSequence.mockResolvedValue({
4851
id: "sequence-internal",
4952
sequenceId: "seq_public",
53+
type: "broadcast",
5054
filter: { aggregator: "and", filters: [] },
5155
});
5256
});
@@ -84,12 +88,23 @@ describe("scheduled broadcast rule processing", () => {
8488
expect(mocks.lockBroadcast).toHaveBeenCalledWith("sequence-internal", [
8589
"cnt_public",
8690
]);
91+
expect(mocks.markBroadcastSent).not.toHaveBeenCalled();
8792
expect(order).toEqual(["enroll", "lock", "delete"]);
8893
});
8994

90-
it("handles an empty audience and still consumes the due rule", async () => {
95+
it("completes an empty-audience broadcast after lock and still consumes the rule", async () => {
9196
mocks.getInternalIds.mockResolvedValue([]);
9297
mocks.getPublicIds.mockResolvedValue([]);
98+
const order: string[] = [];
99+
mocks.lockBroadcast.mockImplementation(async () => {
100+
order.push("lock");
101+
});
102+
mocks.markBroadcastSent.mockImplementation(async () => {
103+
order.push("complete");
104+
});
105+
mocks.deleteRule.mockImplementation(async () => {
106+
order.push("delete");
107+
});
93108

94109
await processRule(rule);
95110

@@ -100,6 +115,34 @@ describe("scheduled broadcast rule processing", () => {
100115
"sequence-internal",
101116
[],
102117
);
118+
expect(mocks.markBroadcastSent).toHaveBeenCalledWith("seq_public");
119+
expect(mocks.captureEvent).toHaveBeenCalledWith(
120+
expect.objectContaining({
121+
event: "broadcast_sent",
122+
source: "automation.process_rules",
123+
properties: expect.objectContaining({
124+
sequence_id: "seq_public",
125+
recipients_count: 0,
126+
}),
127+
}),
128+
);
129+
expect(mocks.deleteRule).toHaveBeenCalledWith("rule_1");
130+
expect(order).toEqual(["lock", "complete", "delete"]);
131+
});
132+
133+
it("does not mark non-broadcast sequences completed on empty match", async () => {
134+
mocks.getInternalIds.mockResolvedValue([]);
135+
mocks.getPublicIds.mockResolvedValue([]);
136+
mocks.getSequence.mockResolvedValue({
137+
id: "sequence-internal",
138+
sequenceId: "seq_public",
139+
type: "sequence",
140+
filter: { aggregator: "and", filters: [] },
141+
});
142+
143+
await processRule(rule);
144+
145+
expect(mocks.markBroadcastSent).not.toHaveBeenCalled();
103146
expect(mocks.deleteRule).toHaveBeenCalledWith("rule_1");
104147
});
105148

apps/api/src/automation/process-rules.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getMatchingPublicContactIds,
99
getSequenceRowById,
1010
lockBroadcast,
11+
markBroadcastSent,
1112
} from "./queries";
1213
import type { ContactFilterWithAggregator } from "../contacts/segment";
1314

@@ -16,6 +17,11 @@ import type { ContactFilterWithAggregator } from "../contacts/segment";
1617
* `DATE_OCCURRED` rules (used to schedule broadcasts). Tag/subscriber based
1718
* triggers are handled immediately by `automation/fire-event.ts` instead of
1819
* being polled here.
20+
*
21+
* Empty-audience broadcasts: after lock, mark completed here via
22+
* `markBroadcastSent`. The sequence/mail workers only complete broadcasts when
23+
* the last `ongoing_sequences` row finishes, so a zero-recipient fire would
24+
* otherwise stay `active` forever.
1925
*/
2026
export async function processRules() {
2127
// eslint-disable-next-line no-constant-condition
@@ -99,6 +105,22 @@ export async function processRule(rule: {
99105
rule.teamId,
100106
sequenceRow.filter as ContactFilterWithAggregator | null,
101107
);
108+
// lockBroadcast must run before markBroadcastSent: the latter jsonb_set's
109+
// `{broadcast,sentAt}` and needs `report.broadcast` to already exist.
102110
await lockBroadcast(sequenceRow.id, publicContactIds);
111+
112+
if (sequenceRow.type === "broadcast" && contactIds.length === 0) {
113+
await markBroadcastSent(sequenceRow.sequenceId);
114+
captureEvent({
115+
event: "broadcast_sent",
116+
source: "automation.process_rules",
117+
teamId: rule.teamId,
118+
properties: {
119+
sequence_id: sequenceRow.sequenceId,
120+
recipients_count: 0,
121+
},
122+
});
123+
}
124+
103125
await deleteRule(rule.ruleId);
104126
}

apps/api/src/config/strings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export const responses = {
66
no_published_emails: "This sequence has no published emails",
77
sequence_details_missing: "Sequence details are missing",
88
sequence_not_active: "This sequence is not active",
9+
/** Broadcast start rejected when the current filter matches nobody. */
10+
broadcast_no_recipients:
11+
"This broadcast has no matching recipients. Add contacts or widen the audience filter before sending.",
912
mail_already_sent: "This broadcast has already been sent",
1013
cannot_delete_last_email: "Cannot delete the last email in a sequence",
1114
mandatory_tags_missing:

apps/api/src/sequences/queries.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,37 @@ describe("sequence queries", () => {
244244
).resolves.toMatchObject({ status: "active" });
245245
});
246246

247+
it("rejects starting a broadcast with no matching recipients", async () => {
248+
const { team } = await seedTeamAndContact(tdb);
249+
await tdb.delete(contacts).where(eq(contacts.teamId, team.id));
250+
const template = await makeTemplate(team.id);
251+
const broadcast = await createSequence({
252+
teamId: team.id,
253+
type: "broadcast",
254+
templateId: template.templateId,
255+
});
256+
await tdb
257+
.update(sequenceEmails)
258+
.set({ published: true })
259+
.where(eq(sequenceEmails.id, broadcast.emails[0].id));
260+
261+
await expect(
262+
startSequence({
263+
teamId: team.id,
264+
sequenceId: broadcast.sequenceId,
265+
}),
266+
).rejects.toThrow(responses.broadcast_no_recipients);
267+
268+
await expect(
269+
getSequenceBySequenceId(team.id, broadcast.sequenceId),
270+
).resolves.toMatchObject({ status: "draft" });
271+
const createdRules = await tdb
272+
.select()
273+
.from(rules)
274+
.where(eq(rules.sequenceId, broadcast.id));
275+
expect(createdRules).toHaveLength(0);
276+
});
277+
247278
it("does not start or create a rule when no ESP is configured", async () => {
248279
const { team } = await seedTeamAndContact(tdb);
249280
const template = await makeTemplate(team.id);

apps/api/src/sequences/queries.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { syncEmailContentMediaReferences } from "../media/email-content";
3030
import { deleteMediaReferencesForResource } from "../media/queries";
3131
import { getEspConfigById, resolveEspConfig } from "../settings/esp/queries";
3232
import { assertMailingAddressConfigured } from "../settings/general/queries";
33+
import { getMatchingContactIds } from "../automation/queries";
3334

3435
export type Sequence = typeof sequences.$inferSelect;
3536
export type SequenceEmail = typeof sequenceEmails.$inferSelect;
@@ -613,6 +614,19 @@ export async function startSequence({
613614

614615
// Broadcasts don't require a filter: an empty/null filter means the
615616
// whole audience (buildContactFilterCondition returns no condition).
617+
// Refuse to start a broadcast that currently matches nobody — otherwise
618+
// the schedule would fire, lock an empty snapshot, and (without the
619+
// empty-audience completion path) look stuck in "sending". Multi-step
620+
// sequences are allowed with an empty list; they enroll later via triggers.
621+
if (sequence.type === "broadcast") {
622+
const recipientIds = await getMatchingContactIds(
623+
teamId,
624+
sequence.filter as ContactFilterWithAggregator | null,
625+
);
626+
if (recipientIds.length === 0) {
627+
throw new Error(responses.broadcast_no_recipients);
628+
}
629+
}
616630

617631
await addRule({
618632
teamId,

apps/web/app/(dashboard)/account/page.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { useEffect, useState } from "react";
44
import { useRouter, useSearchParams } from "next/navigation";
55
import { CreditCard, Sparkles, UserRound } from "lucide-react";
6+
import { Loader } from "@codelitdev/design-system";
7+
import { Loading } from "@/components/dashboard/loading";
68
import { PageHeader } from "@/components/dashboard/page-header";
79
import { ScrollablePage } from "@/components/dashboard/scrollable-page";
810
import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context";
@@ -186,6 +188,9 @@ export default function AccountPage() {
186188
type="submit"
187189
disabled={isSavingProfile}
188190
>
191+
{isSavingProfile ? (
192+
<Loader size={16} />
193+
) : null}
189194
{isSavingProfile
190195
? "Saving…"
191196
: "Save changes"}
@@ -234,9 +239,13 @@ export default function AccountPage() {
234239
<p className="text-muted-foreground">
235240
Email
236241
</p>
237-
<p className="font-medium">
238-
{account?.email || "Loading…"}
239-
</p>
242+
{account ? (
243+
<p className="font-medium">
244+
{account.email}
245+
</p>
246+
) : (
247+
<Loading />
248+
)}
240249
</div>
241250
</CardContent>
242251
</Card>

0 commit comments

Comments
 (0)