Weekly digest
A per-user weekly activity digest as one defineJourney() + ctx.digest — a rolling 7-day window that collapses a burst of activity into one replay-safe email, the Object.groupBy "batch" recipe, and the cron fan-out as the fixed-day alternative.
A digest collapses a week of activity into one email. There are two shapes, and Hogsend has a primitive for each:
- Rolling, per-user — the window opens on a user's own activity and each user gets a digest a week after they were last active. This is a journey:
defineJourney()+ctx.digest(). The digest primitive absorbs the whole week's events into one execution and records the flush, so it is replay-safe with no idempotency bookkeeping of your own. - Scheduled, whole-audience — every active user at the same instant (Monday 09:00), decided by one query. That has no per-user trigger and no per-user flow, so it is not a journey — it's a cron Hatchet task in your
src/workflows/. It's the alternative below.
Reach for the rolling digest first: it's a fraction of the code and the engine owns the reliability. Use the cron sweep when you specifically want a fixed newsletter-style day for everyone.
| Concern | How the rolling digest expresses it |
|---|---|
| Collapse a week of activity into one send | ctx.digest({ window: days(7) }) |
| A fresh digest each active week | entryLimit: "unlimited" |
| Group the week into sections | Object.groupBy(digest.events, …) — plain TypeScript |
| Never double-send on a replay | the recorded flush + the auto-derived send key (nothing to author) |
| Respect unsubscribes after the wait | ctx.guard.isSubscribed() before the send |
| Don't fight the digest with a min-gap | suppress: days(0) |
The journey
// src/journeys/weekly-digest.ts
import { days, defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";
export const weeklyDigest = defineJourney({
meta: {
id: "weekly-digest",
name: "Retention — Weekly activity digest",
enabled: true,
// Any report activity opens a window; the rest of the week folds in.
trigger: { event: Events.REPORT_CREATED },
entryLimit: "unlimited", // rolling: a fresh window opens after each flush
// ctx.digest already collapses the week into one send, so the per-journey
// min-gap must be off — a suppress >= the window would gap out each new
// window's email against the previous one's.
suppress: days(0),
exitOn: [{ event: Events.USER_DELETED }],
},
run: async (user, ctx) => {
// The first report enrolls; every report.created in the next 7 days is
// absorbed by the active-enrollment guard and returned here at flush.
// One execution, one email — not one per report.
const digest = await ctx.digest({ window: days(7), label: "weekly" });
// A 7-day window is a long wait, and unsubscribe doesn't exit the journey.
if (!(await ctx.guard.isSubscribed())) return;
// The "batch" recipe: ctx.digest only collects the window; grouping is
// plain TypeScript over digest.events.
const byProject = Object.groupBy(
digest.events,
(e) => String(e.properties?.projectId ?? "unknown"),
);
const projects = Object.entries(byProject).map(([projectId, events]) => ({
projectId,
count: events?.length ?? 0,
}));
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.RETENTION_WEEKLY_DIGEST, // "retention-weekly-digest"
subject: "Your week in review",
journeyName: user.journeyName,
props: { totalReports: digest.count, projects },
});
},
});That's the whole flow — no aggregate SQL, no contact lookup, no weekKey to hand-roll. ctx.digest sleeps the window out durably, scans the user's report.created rows once, and records the result, so a worker crash and replay returns the verbatim-same set instead of rescanning. The post-digest sendEmail is auto-keyed to the digest site, so the replay short-circuits to the existing email_sends row rather than mailing twice.
Rolling vs one window. entryLimit: "unlimited" is what makes it rolling — after a window flushes, the next report.created opens a fresh one, so an active user gets a digest roughly weekly and a silent one gets nothing. Switch to entryLimit: "once" and the journey digests exactly one window ever (a single onboarding-week recap, say). The timing is per user, not a fixed calendar day — the window counts from each user's first activity, not from a server clock.
The batch is yours. There is deliberately no batch primitive: digest.events is a flat, chronological array and Object.groupBy (or a reduce, or two passes) turns it into whatever sections your template wants. count, truncated (more than maxEvents matched), and flushedAt come back on the result for the header line.
Add the events and template keys
// src/journeys/constants/index.ts
export const Events = {
REPORT_CREATED: "report.created",
USER_DELETED: "user.deleted",
} as const;
export const Templates = {
RETENTION_WEEKLY_DIGEST: "retention-weekly-digest",
} as const;The retention-weekly-digest template ships in the example app (see it rendered) — copy it into your src/emails/ with a registry.ts entry and a templates.d.ts augmentation (Email guide); after that, the props bag above is type-checked. Register the journey by adding weeklyDigest to your journeys array, exactly as in Lifecycle journeys.
Alternative: a scheduled fan-out (cron task)
When you want every active user to get the digest at the same fixed moment — a Monday-morning newsletter cadence, not a per-user rolling one — the trigger is a clock and the audience is a query. That's not a journey (a journey run is born from one user's event and owns one user's state); it's a cron hatchet.task() in your src/workflows/ that sweeps the whole audience in one aggregate query and sends one preference-checked, idempotent email per active user.
// src/workflows/weekly-digest.ts
import { contacts, userEvents } from "@hogsend/db";
import { hatchet } from "@hogsend/engine";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { getContainer } from "../container.js";
import { Events, Templates } from "../journeys/constants/index.js";
const WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export const weeklyDigestTask = hatchet.task({
name: "weekly-digest",
// Mondays at 09:00 UTC — cron expressions evaluate in UTC, not per-user time.
onCrons: ["0 9 * * 1"],
retries: 1,
executionTimeout: "30m",
fn: async () => {
const { db, emailService, logger } = getContainer();
const since = new Date(Date.now() - WINDOW_MS);
// One key per (user, weekly run): retries and re-runs can't double-send.
const weekKey = new Date().toISOString().slice(0, 10);
// One aggregate query: per-user counts over the window. Users with no
// qualifying events never appear — the empty digest is structurally
// impossible, not a filter you have to remember.
const activity = await db
.select({
userId: userEvents.userId,
reportsCreated: sql<number>`count(*) filter (where ${userEvents.event} = ${Events.REPORT_CREATED})`,
reportsShared: sql<number>`count(*) filter (where ${userEvents.event} = ${Events.REPORT_SHARED})`,
})
.from(userEvents)
.where(
and(
gte(userEvents.occurredAt, since),
inArray(userEvents.event, [
Events.REPORT_CREATED,
Events.REPORT_SHARED,
]),
),
)
.groupBy(userEvents.userId);
let sent = 0;
let skipped = 0;
for (const row of activity) {
// Identity is resolved server-side from the contacts row — the userId
// on events is the external id, never an email address.
const contact = await db.query.contacts.findFirst({
where: eq(contacts.externalId, row.userId),
});
if (!contact?.email) {
skipped++;
continue;
}
const result = await emailService.send({
template: Templates.RETENTION_WEEKLY_DIGEST, // "retention-weekly-digest"
to: contact.email,
userId: row.userId,
subject: "Your week in review",
props: {
reportsCreated: Number(row.reportsCreated),
reportsShared: Number(row.reportsShared),
weekOf: weekKey,
},
// NO skipPreferenceCheck — a digest is exactly the mail that
// preferences exist to control. Unsubscribed/suppressed recipients
// come back as a status, not an error.
idempotencyKey: `digest:${row.userId}:${weekKey}`,
});
if (result.status === "sent") sent++;
else skipped++;
}
logger.info("weekly-digest complete", { sent, skipped, weekKey });
return { sent, skipped, week: weekKey };
},
});The two shapes differ where it counts:
- Timing — the journey's window opens on each user's own activity (per-user, no fixed day); the cron fires the same UTC instant for everyone. Per-recipient local timing on the cron is impossible; use the journey (or
ctx.when) when the local moment matters. - Idempotency — the journey gets exactly-once for free from the recorded flush and the auto-derived send key; the cron must carry an explicit
idempotencyKey: "digest:<userId>:<weekKey>"so a crash at recipient 4,000 of 9,000 finishes the list on retry instead of re-mailing the front half. - Empty digests — the journey never enrolls a user with no activity (no first event, no window); the cron's
GROUP BYnever returns them. Both make the empty digest structurally impossible.
Register the cron task via extraWorkflows (journeys and tasks register through different arrays):
// src/workflows/index.ts — list only YOUR tasks; built-ins register themselves
import { weeklyDigestTask } from "./weekly-digest.js";
export const extraWorkflows = [weeklyDigestTask];// src/worker.ts
import { createWorker } from "@hogsend/engine";
import { getContainer } from "./container.js";
import { journeys } from "./journeys/index.js";
import { extraWorkflows } from "./workflows/index.js";
const client = getContainer();
const worker = createWorker({
container: client,
journeys,
extraWorkflows, // NOT `workflows`
});
await worker.start();- Set
suppress: days(0)on a rolling digest. The digest already collapses the week's sends into one; asuppress≥ the window would gap out each new window's email against the previous one's and silently drop it. entryLimitpicks the cadence."unlimited"re-enrolls from the next event for a rolling digest;"once"digests exactly one window ever. Both are one line, nolast_digested_atcolumn.- The window is never tier-gated. A digest window has no plan ceiling other than the journey execution limit (720h / 30 days).
- Straggler band. An event landing between the flush scan and the journey completing counts toward the next window, not this one — an accepted caveat matching Novu's digest semantics.
- The cron is UTC.
0 9 * * 1is Monday 09:00 UTC for everyone; a fixed local morning is a journey concern.
Related: Marketing campaigns is the broadcast alternative when every recipient gets identical content (one hs.campaigns.send to a list, no per-user query), NPS survey uses the same journey + typed-template shape for feedback, and Win-back and sunset handles the users a digest stops reaching. The ctx.digest() reference documents the window, replay, and batch semantics in full.
NPS survey
A recurring in-email NPS survey as one defineJourney() — entryLimit once_per_period for the 90-day cadence, three semantic-link score bands, a detractor flag for human follow-up, and a referral ask for promoters.
Anniversary emails
A signup-anniversary journey with entryLimit once_per_period + entryPeriod days(365), a dormancy gate via ctx.history.hasEvent, and ctx.when.nextLocal + ctx.sleepUntil to land the send at 09:00 in the user's own timezone.