Skip to content
Merged
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
18 changes: 17 additions & 1 deletion layers/admin/server/api/admin/weekly.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ interface WindowRow {
trending_rows: number
first_claimed_at: number
last_sent_at: number | null
clicks: number
unsubscribes: number
share_clicks: number
}

interface ProblemRow {
Expand Down Expand Up @@ -56,8 +58,12 @@ export interface AdminWeeklyResponse {
trendingRows: number
firstClaimedAt: number
lastAcceptedAt: number | null
/** Aggregate link clicks for this issue, share links excluded. */
clicks: number
clicksPerAccepted: number | null
unsubscribes: number
unsubscribeRate: number | null
shareClicks: number
}>
/** Rows without a resolved provider outcome, newest first. */
problems: Array<{
Expand Down Expand Up @@ -94,6 +100,11 @@ export default defineApiHandler({
SUM(trending_count) AS trending_rows,
MIN(claimed_at) AS first_claimed_at,
MAX(r.sent_at) AS last_sent_at,
(SELECT COALESCE(SUM(c.clicks), 0)
FROM email_click_counts c
WHERE c.campaign = 'weekly'
AND c.issue = r.window_end
AND c.placement != 'share') AS clicks,
(SELECT COUNT(DISTINCT e.user_id)
FROM email_preference_events e
WHERE e.list = 'weekly'
Expand All @@ -105,7 +116,12 @@ export default defineApiHandler({
AND er.sent_at IS NOT NULL
AND e.occurred_at >= er.sent_at
AND e.occurred_at < er.sent_at + 604800
)) AS unsubscribes
)) AS unsubscribes,
(SELECT COALESCE(SUM(c.clicks), 0)
FROM email_click_counts c
WHERE c.campaign = 'weekly'
AND c.issue = r.window_end
AND c.placement = 'share') AS share_clicks
FROM weekly_runs r
GROUP BY r.window_end
ORDER BY r.window_end DESC
Expand Down
20 changes: 19 additions & 1 deletion layers/admin/server/utils/weekly-engagement.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
export interface WeeklyEngagementCounts {
accepted: number
/** Counted clicks on the issue's links, share links excluded. */
clicks: number
unsubscribes: number
/** Counted clicks on the share link. */
share_clicks: number
}

export interface WeeklyEngagementSummary {
clicks: number
/**
* Clicks per accepted email. Counts are aggregate, so one reader can click
* twice; this is not a unique click rate.
*/
clicksPerAccepted: number | null
unsubscribes: number
unsubscribeRate: number | null
shareClicks: number
}

function perAccepted(count: number, accepted: number): number | null {
return accepted > 0 ? count / accepted : null
}

export function summarizeWeeklyEngagement(
counts: WeeklyEngagementCounts,
): WeeklyEngagementSummary {
return {
clicks: counts.clicks,
clicksPerAccepted: perAccepted(counts.clicks, counts.accepted),
unsubscribes: counts.unsubscribes,
unsubscribeRate: counts.accepted > 0 ? counts.unsubscribes / counts.accepted : null,
unsubscribeRate: perAccepted(counts.unsubscribes, counts.accepted),
shareClicks: counts.share_clicks,
}
}
39 changes: 39 additions & 0 deletions layers/identity/server/api/e/[campaign].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getQuery, getRouterParam, sendRedirect, setHeader } from 'h3'
import { defineApiHandler } from '#shared/server/handler'
import { parseEmailClick, recordEmailClick } from '../../utils/email-clicks'

/**
* Counts a click from the weekly or digest email, then sends the reader on.
*
* Only the aggregate counter changes. The link names no reader, and nothing
* from the request is stored beyond the campaign, issue, placement, and path.
* The wide event carries the campaign and placement, never the path.
*
* Counting is best effort. A reader who clicked is owed the page, so a failed
* write is reported and the redirect still happens.
*/
export default defineApiHandler({
handler: async ({ event, platform }) => {
const click = parseEmailClick(getRouterParam(event, 'campaign'), getQuery(event))

if (click._tag === 'counted') {
const { campaign, placement } = click.key
await recordEmailClick(platform.db, click.key, new Date()).then(
() => emitOperationalEvent(createWideEvent({ operation: 'email-click', outcome: 'success', reason: `${campaign}/${placement}` }), 'info'),
(error: unknown) => emitOperationalEvent(createWideEvent({
operation: 'email-click',
outcome: 'failed',
reason: `${campaign}/${placement}: ${error instanceof Error ? error.name : 'unknown'}`,
})),
)
}
else {
emitOperationalEvent(createWideEvent({ operation: 'email-click', outcome: 'skipped', reason: click.reason }), 'info')
}

// A click is a one-off, and a cached redirect would hide every later one.
setHeader(event, 'cache-control', 'private, no-store')
// An invalid target names no page. Home is the safe landing.
return sendRedirect(event, click._tag === 'invalid' ? '/' : click.to, 302)
},
})
28 changes: 0 additions & 28 deletions layers/identity/server/api/e/weekly.get.ts

This file was deleted.

8 changes: 8 additions & 0 deletions layers/identity/server/utils/daily-health-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function summary(overrides: Partial<DailyHealthCheckSummary> = {}): DailyHealthC
newUsers24h: 1,
digestsSent24h: 4,
digestsFailed24h: 0,
emailClicks: { fromDay: '2026-07-21', weekly: 2, digest: 5 },
},
pipeline: {
syncJobs: [{ name: 'sync-github-skills', status: 'ok', lastRunAt: 1_774_473_600, stale: false, error: null }],
Expand Down Expand Up @@ -332,6 +333,7 @@ describe('buildDailyHealthCheck', () => {
CREATE TABLE skill_subscriptions (owner TEXT, repo TEXT);
CREATE TABLE collection_skills_v2 (owner TEXT, repo TEXT);
CREATE TABLE activity (owner TEXT, repo TEXT, name TEXT, occurred_at INTEGER);
CREATE TABLE email_click_counts (day TEXT, campaign TEXT, issue INTEGER, placement TEXT, path TEXT, clicks INTEGER);
CREATE TABLE digest_runs (
status TEXT,
sent_at INTEGER,
Expand Down Expand Up @@ -399,6 +401,11 @@ describe('buildDailyHealthCheck', () => {
INSERT INTO collections_v2 VALUES (NULL);
INSERT INTO user_starred_repos VALUES ('owner', 'repo');
INSERT INTO activity VALUES ('owner', 'repo', 'skill', ${nowSec - 60});
-- Counters are per UTC day. The window starts on 07-21, so 07-20 is out.
INSERT INTO email_click_counts VALUES ('2026-07-20', 'digest', 1, 'overflow', '/me/likes', 40);
INSERT INTO email_click_counts VALUES ('2026-07-21', 'digest', 1, 'overflow', '/me/likes', 2);
INSERT INTO email_click_counts VALUES ('2026-07-22', 'digest', 1, 'footer', '/', 3);
INSERT INTO email_click_counts VALUES ('2026-07-22', 'weekly', 2, 'cta', '/skills', 1);
INSERT INTO digest_runs VALUES ('sent', ${nowSec - 60}, ${nowSec - 60}, ${nowSec - 60}, ${nowSec - 60});
-- The discovery cursor is a single fixed row in production, and the
-- health query reads it by key. Without it the query returns no row at
Expand Down Expand Up @@ -470,6 +477,7 @@ describe('buildDailyHealthCheck', () => {
expect(built.warnings).toEqual([])
expect(built.inventory).toMatchObject({ skills: 1, repos: 2, users: 1, watchedRepos: 1 })
expect(built.activity).toMatchObject({ newSkills24h: 1, repoChanges24h: 1, digestsSent24h: 1 })
expect(built.activity.emailClicks).toEqual({ fromDay: '2026-07-21', weekly: 1, digest: 5 })
expect(built.pipeline).toMatchObject({
newlyBrokenReposTotal24h: 1,
newlyBrokenReposImpacted24h: 0,
Expand Down
27 changes: 27 additions & 0 deletions layers/identity/server/utils/daily-health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ export interface DailyHealthCheckSummary {
newUsers24h: number
digestsSent24h: number
digestsFailed24h: number
/**
* Aggregate email link clicks, per campaign.
*
* Counters are kept per UTC day, so this sums whole days from `fromDay`
* onwards rather than an exact 24 hours. Digest clicks are the Loop 2
* evidence bar.
*/
emailClicks: {
fromDay: string
weekly: number
digest: number
}
/**
* The most recent weekly run, not a 24 hour count.
*
Expand Down Expand Up @@ -783,6 +795,17 @@ async function loadInventory(db: D1Database): Promise<DailyHealthCheckSummary['i
}
}

async function loadEmailClicks(db: D1Database, fromDay: string): Promise<DailyHealthCheckSummary['activity']['emailClicks']> {
const row = await first<{ weekly: number | null, digest: number | null }>(db, `
SELECT
SUM(clicks) FILTER (WHERE campaign = 'weekly') AS weekly,
SUM(clicks) FILTER (WHERE campaign = 'digest') AS digest
FROM email_click_counts
WHERE day >= ?1
`, [fromDay])
return { fromDay, weekly: numberValue(row.weekly), digest: numberValue(row.digest) }
}

async function loadActivity(
db: D1Database,
sinceSec: number,
Expand All @@ -799,13 +822,16 @@ async function loadActivity(
+ (SELECT COUNT(*) FROM digest_runs
WHERE status IN ('sending', 'uncertain'))) AS digests_failed_24h
`, [sinceSec])
const fromDay = new Date(sinceSec * 1000).toISOString().slice(0, 10)
const emailClicks = await capture(warnings, 'email clicks', { fromDay, weekly: 0, digest: 0 }, () => loadEmailClicks(db, fromDay))
const weekly = await capture(warnings, 'weekly delivery', null, () => loadWeeklyRun(db))
return {
newSkills24h: numberValue(row.new_skills_24h),
repoChanges24h: numberValue(row.repo_changes_24h),
newUsers24h: numberValue(row.new_users_24h),
digestsSent24h: numberValue(row.digests_sent_24h),
digestsFailed24h: numberValue(row.digests_failed_24h),
emailClicks,
weekly,
}
}
Expand Down Expand Up @@ -1153,6 +1179,7 @@ export async function buildDailyHealthCheck(
newUsers24h: 0,
digestsSent24h: 0,
digestsFailed24h: 0,
emailClicks: { fromDay: since.toISOString().slice(0, 10), weekly: 0, digest: 0 },
weekly: null,
}, () => loadActivity(db, sinceSec, warnings))
const pipeline = await capture(warnings, 'pipeline', {
Expand Down
1 change: 1 addition & 0 deletions layers/identity/server/utils/digest-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ export async function runDigestDeliveryForUser(
const rendered = deps.render({
login: user.login,
recipientName: user.name ?? null,
countClicks: true,
windowStart: selection.windowStart,
windowEnd: selection.windowEnd,
unsubscribeUrl,
Expand Down
3 changes: 3 additions & 0 deletions layers/identity/server/utils/digest-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface DigestRepoEntry {
export interface DigestRenderInput {
login: string
recipientName?: string | null
/** Route same-site links through the aggregate click counter. Real sends only. */
countClicks?: boolean
windowStart: number
windowEnd: number
entries: DigestRepoEntry[]
Expand Down Expand Up @@ -56,6 +58,7 @@ export function renderDigest(input: DigestRenderInput): DigestRender {
return renderWeekly({
edition: 'digest',
recipientName: input.recipientName ?? null,
countClicks: input.countClicks ?? false,
windowStart: input.windowStart,
windowEnd: input.windowEnd,
likedChanges,
Expand Down
Loading