Skip to content

Weekly digest: Slack, webhook, and email summary of what shipped, what performed, and what needs attention #64

Description

@tessak22

Overview

Build a weekly digest system that delivers a summary of Quiver activity to the team via webhook — Slack, Discord, email, or any HTTP endpoint. The digest answers the question: "What happened in Quiver this week?" without anyone having to log in.

This is the team's heartbeat. It surfaces what shipped, what performed, what needs attention, and what changed — so the whole team stays aligned on marketing momentum even if they're not in the app every day.


What the digest contains

Every digest covers the past 7 days and includes these sections, in order:

1. What shipped

Artifacts that moved to live status in the past 7 days.

🚀 What shipped this week
• Cold Email Sequence v2 (cold_email) — Developer Outreach campaign
• Q2 Landing Page (landing_page) — Product Launch campaign
• April Newsletter (newsletter) — Content calendar

2. Performance highlights

Performance log entries recorded in the past 7 days with whatWorked or notable metrics.

📈 Performance this week
• ICP Outreach Email: 34% open rate, 12 replies — "Technical framing worked well"
• LinkedIn Thread: 2.4k impressions, 18 reposts

Only show entries that have at least one metric or a whatWorked note. Skip reminder-only entries.

3. Close the loop queue

Artifacts that are live but have no performance results logged yet, grouped by how overdue they are.

🔁 Needs results logged
• Developer FAQ Page — live 18 days ago (overdue)
• Cold Email Sequence v1 — live 9 days ago
• Product Hunt Post — live 3 days ago

Only include if queue is non-empty.

4. Context changes

Context version updates approved in the past 7 days.

📝 Context updated
• Positioning statement updated — "Tightened around structured output layer framing"
• ICP definition updated — "Narrowed to seed-Series A AI-native startups"

Only include if any updates happened.

5. Pending proposals

Count of AI-proposed context updates awaiting review.

⏳ 3 context update proposals waiting for review
→ {APP_URL}/context

Only include if count > 0.

6. Active campaigns snapshot

Current status of all active campaigns — artifact count and last activity date.

📋 Active campaigns
• Developer Outreach — 4 artifacts, last activity 2 days ago
• Q2 Product Launch — 7 artifacts, last activity today

7. AI-generated summary (optional)

A 2-3 sentence natural language summary of the week generated by Claude. Uses the same data above. Example:

"Strong week for outreach — the cold email sequence is live and the ICP targeting work is paying off with above-average open rates. Three artifacts are overdue for results logging, including the FAQ page from 18 days ago. One context update is waiting for review."

Only generated if DIGEST_AI_SUMMARY=true in env. Off by default to keep the digest lightweight and free of AI costs for teams that don't want it.


Delivery mechanisms

Support three delivery targets. Each is optional and independently configurable. A team can use all three, one, or none.

Slack webhook

Posts to a Slack channel via incoming webhook URL. Format: structured Slack Block Kit message with sections matching the digest sections above.

Generic webhook (HTTP POST)

Posts JSON payload to any URL — Discord (via Discord webhook), Zapier, Make, email via a webhook service, or any custom endpoint. Content-Type: application/json. Format defined below.

Email (via Resend)

Sends HTML email to one or more addresses. Uses RESEND_API_KEY if configured. Clean HTML template, no external CSS frameworks.


Schedule

Default: Monday mornings at 8am in the team's configured timezone. Covers the previous 7 days (Monday to Sunday).

Configurable per delivery target:

  • Day of week: any day
  • Time: any hour (0-23)
  • Timezone: IANA timezone string (e.g. America/Chicago)
  • Frequency: weekly (only option in v1)

New DB table

⚠️ Approval gate: Show schema addition and wait for approval before running migration.

Add to prisma/schema.prisma as part of migration 0004_digest:

model DigestConfig {
  id            String   @id @default(uuid())
  enabled       Boolean  @default(false)

  // Slack
  slackEnabled      Boolean  @default(false)
  slackWebhookUrl   String?

  // Generic webhook
  webhookEnabled    Boolean  @default(false)
  webhookUrl        String?
  webhookSecret     String?  // optional HMAC secret for request signing

  // Email via Resend
  emailEnabled      Boolean  @default(false)
  emailRecipients   String[] // array of email addresses

  // Schedule
  dayOfWeek     Int      @default(1)  // 0=Sunday, 1=Monday ... 6=Saturday
  hourOfDay     Int      @default(8)  // 0-23 in timezone
  timezone      String   @default("America/New_York")

  // AI summary
  aiSummaryEnabled  Boolean  @default(false)

  // Meta
  lastSentAt    DateTime?
  createdBy     String
  updatedAt     DateTime  @updatedAt
  createdAt     DateTime  @default(now())

  @@map("digest_config")
}

One row per workspace. Created on first save of digest settings.


New lib files

lib/digest/data.ts

Fetches all data needed to build the digest. No AI calls here — pure DB queries. Exportable and testable independently.

/**
 * Digest Data Layer — lib/digest/data.ts
 *
 * What it does: Fetches all data needed to assemble a weekly digest.
 * What it reads from: All Quiver DB tables via Prisma.
 * What it produces: A typed DigestData object covering the past 7 days.
 * Edge cases:
 *   - Empty sections return empty arrays (never null).
 *   - Reminder entries are filtered out of performance logs.
 *   - Only published/live artifacts appear in shipped list.
 */

export interface DigestData {
  period: { from: Date; to: Date }
  shippedArtifacts: Array<{
    id: string
    title: string
    type: string
    campaignName: string
  }>
  performanceHighlights: Array<{
    artifactTitle: string | null
    campaignName: string
    metrics: Record<string, unknown> | null
    whatWorked: string | null
    recordedAt: Date
  }>
  closeTheLoopQueue: Array<{
    artifactId: string
    artifactTitle: string
    campaignName: string
    wentLiveAt: Date
    daysOverdue: number
  }>
  contextChanges: Array<{
    version: number
    changeSummary: string | null
    updateSource: string | null
    createdAt: Date
  }>
  pendingProposalsCount: number
  activeCampaigns: Array<{
    id: string
    name: string
    artifactCount: number
    lastActivityAt: Date | null
  }>
}

export async function getDigestData(from: Date, to: Date): Promise<DigestData>

Implementation notes:

  • shippedArtifacts: artifacts where status = 'live' and updatedAt is within the period
  • performanceHighlights: PerformanceLog entries where recordedAt is within period, qualitativeNotes does NOT start with REMINDER_PREFIX, and at least one of metrics or whatWorked is non-null
  • closeTheLoopQueue: call getReminders() from lib/db/artifacts.ts — it already handles this query. Compute daysOverdue as Math.max(0, daysSince(reminder.recordedAt))
  • contextChanges: ContextVersion rows where createdAt is within period and updateSource is not null
  • pendingProposalsCount: count of PerformanceLog rows where contextUpdateStatus = 'pending'
  • activeCampaigns: campaigns where status = 'active', include _count.artifacts and most recent artifact updatedAt as lastActivityAt

lib/digest/format.ts

Converts DigestData into formatted payloads for each delivery target. No DB calls, no AI calls — pure formatting.

// Plain text version (used in email plain-text fallback and generic webhook)
export function formatDigestText(data: DigestData, appUrl: string): string

// Slack Block Kit JSON
export function formatDigestSlack(data: DigestData, appUrl: string): object

// HTML for email
export function formatDigestHtml(data: DigestData, appUrl: string): string

// JSON payload for generic webhook
export function formatDigestJson(data: DigestData, appUrl: string): object

Slack Block Kit format:

  • Header block: "Quiver weekly digest — [date range]"
  • Divider
  • One section block per non-empty digest section
  • Each section uses mrkdwn for formatting
  • Footer: link back to Quiver dashboard
  • Empty sections are omitted entirely — a quiet week should produce a short digest, not empty sections

Generic webhook JSON shape:

{
  "period": { "from": "ISO", "to": "ISO" },
  "shipped": [...],
  "performance": [...],
  "closeTheLoop": [...],
  "contextChanges": [...],
  "pendingProposals": 3,
  "activeCampaigns": [...],
  "summary": "AI summary string or null",
  "digestUrl": "https://yourapp.com/dashboard"
}

HTML email format:

  • Clean, minimal HTML. Inline styles only — no <style> tags (email client compatibility)
  • White background, dark text, clear section headings
  • Each section is a <table> with a heading row and data rows
  • Footer with unsubscribe note: "To stop receiving this digest, update your settings at {appUrl}/settings"

lib/digest/send.ts

Handles delivery to each configured target.

/**
 * Digest Delivery — lib/digest/send.ts
 *
 * What it does: Sends a formatted digest to all configured delivery targets.
 * What it reads from: DigestConfig (from DB), formatted payloads from format.ts.
 * What it produces: Delivery results per target.
 * Edge cases:
 *   - One target failing does not block other targets.
 *   - Webhook signing uses HMAC-SHA256 if webhookSecret is set.
 *   - Resend not available: email target silently skipped with error logged.
 */

export interface SendResult {
  slack: { sent: boolean; error?: string }
  webhook: { sent: boolean; error?: string }
  email: { sent: boolean; error?: string }
}

export async function sendDigest(
  config: DigestConfig,
  data: DigestData,
  appUrl: string
): Promise<SendResult>

Implementation notes:

  • Slack: fetch(config.slackWebhookUrl, { method: 'POST', body: JSON.stringify(slackPayload) })
  • Webhook: POST JSON payload. If webhookSecret is set, add X-Quiver-Signature header: HMAC-SHA256(JSON.stringify(payload), secret) encoded as hex
  • Email: Use resend.emails.send() with HTML body and text fallback. from: should be Quiver Digest <digest@{configured_domain}> — admin sets the from domain in settings
  • Each target wrapped in try/catch — failure is logged to stderr, result records the error, execution continues

lib/digest/ai-summary.ts

Optional AI summary generation. Only called if config.aiSummaryEnabled = true.

export async function generateDigestSummary(data: DigestData): Promise<string | null>

Makes a single non-streaming call to sendMessage() from lib/ai/client.ts. Prompt:

You are writing a brief weekly summary for a marketing team.
In 2-3 sentences, summarize this week's marketing activity based on the data below.
Be specific about what happened. Mention the most important signal.
Do not use bullet points. Write in plain prose.

[JSON.stringify(data, null, 2)]

On error or timeout: return null. Never throw — a failed summary should not block digest delivery.


API routes

app/api/digest/config/route.ts

GET — fetch current digest config (admin only)

  • Returns config row or { configured: false } if none exists
  • Masks webhookSecret — return "***" if set, omit entirely if not
  • Never returns slackWebhookUrl or webhookUrl in full — return last 6 characters only for display: "...abc123"

POST — create or update digest config (admin only)

  • Body: all DigestConfig fields except id, createdAt, updatedAt, lastSentAt
  • Upsert: update if exists, create if not
  • Validate: dayOfWeek 0-6, hourOfDay 0-23, valid IANA timezone string, valid URLs for webhook and Slack
  • Returns saved config (with masked secrets)

app/api/digest/send/route.ts

POST — trigger digest immediately (admin only, manual send for testing)

  • No body required
  • Fetches config, builds digest data for past 7 days, sends to all enabled targets
  • Returns { sent: true, results: SendResult, preview: DigestData }
  • Rate limited: max 3 manual sends per hour per user

app/api/digest/preview/route.ts

GET — preview digest data without sending (admin only)

  • Returns DigestData for the past 7 days as JSON
  • Used by the settings UI to show a live preview before enabling

app/api/cron/digest/route.ts

Cron endpoint called by Vercel cron scheduler.

// vercel.json cron entry:
// { "path": "/api/cron/digest", "schedule": "0 * * * *" }  // runs every hour

The cron runs every hour and checks whether it's time to send based on DigestConfig.dayOfWeek, hourOfDay, and timezone:

  1. Fetch digest config where enabled = true
  2. For each config, convert current UTC time to the configured timezone
  3. If current day-of-week and hour match the config, and lastSentAt is not within the past 23 hours (prevents double-sends), send the digest
  4. Update lastSentAt after successful send

Auth: validate CRON_SECRET header matches process.env.CRON_SECRET. Return 401 if missing or wrong.

Update vercel.json to add the cron entry.


Settings UI

Add a Digest section to app/(app)/settings/page.tsx (admin only), below the existing settings sections.

Layout

Two subsections: Configuration and Preview.

Configuration subsection:

  • Master enable/disable toggle: "Send weekly digest"
  • Schedule row: day of week select (Mon–Sun), time select (12am–11pm in 1-hour increments), timezone select (list of major IANA timezones — not all 500+, just the ~40 commonly used ones)
  • AI summary toggle: "Include AI-generated summary" with note: "Uses one AI call per digest. Adds ~$0.01 per week."

Delivery targets — three collapsible sections, each with an enable toggle:

Slack:

  • Webhook URL input (full URL on entry, masked on display after save)
  • Instructions: "Create an Incoming Webhook in your Slack workspace. [How to set up →]"

Webhook:

  • URL input (masked after save)
  • Secret input (optional, masked after save): "Used to sign requests with HMAC-SHA256 via X-Quiver-Signature header"

Email:

  • Recipients input: comma-separated email addresses
  • From domain input: the domain to send from (requires Resend DNS setup)
  • Note: "Requires RESEND_API_KEY in your environment."

Action buttons:

  • "Save settings"
  • "Send test digest now" — calls POST /api/digest/send, shows results inline
  • "Preview digest data" — calls GET /api/digest/preview, shows the DigestData as a formatted preview in the UI

Preview subsection:
Shows a rendered preview of what the digest will look like based on current data. Renders the HTML email format inline. Updates when "Preview digest data" is clicked. Shows a note: "This is how your digest will look based on the past 7 days."


Environment variables

Add to .env.example:

# Digest (optional)
RESEND_API_KEY=        # required for email delivery
CRON_SECRET=           # required — validates cron endpoint requests

CRON_SECRET should already exist from issue #22 (settings). Verify before adding.


MCP tool — add to mcp/tools/workspace.ts

get_digest_preview

  • Description: Get a preview of what this week's Quiver digest would contain — what shipped, performance highlights, close-the-loop queue, context changes, and active campaigns.
  • Input: none
  • Output: Full DigestData object
  • Implementation: Call getDigestData() from lib/digest/data.ts with a 7-day window

send_digest_now

  • Description: Trigger the weekly digest to be sent immediately to all configured delivery targets.
  • Input: none
  • Output: { sent: boolean, results: SendResult }
  • Implementation: Fetch config, call sendDigest() from lib/digest/send.ts

types/index.ts additions

export interface DigestData {
  period: { from: Date; to: Date }
  shippedArtifacts: Array<{ id: string; title: string; type: string; campaignName: string }>
  performanceHighlights: Array<{ artifactTitle: string | null; campaignName: string; metrics: Record<string, unknown> | null; whatWorked: string | null; recordedAt: Date }>
  closeTheLoopQueue: Array<{ artifactId: string; artifactTitle: string; campaignName: string; wentLiveAt: Date; daysOverdue: number }>
  contextChanges: Array<{ version: number; changeSummary: string | null; updateSource: string | null; createdAt: Date }>
  pendingProposalsCount: number
  activeCampaigns: Array<{ id: string; name: string; artifactCount: number; lastActivityAt: Date | null }>
}

Acceptance criteria

Database

  • DigestConfig model added to schema, migration 0004_digest runs clean
  • One-row-per-workspace design enforced via upsert in API route

Lib layer

  • lib/digest/data.tsgetDigestData() returns correct data for all sections, empty arrays for empty sections
  • lib/digest/data.ts — reminder entries filtered from performance highlights using REMINDER_PREFIX
  • lib/digest/format.ts — all four formatters implemented (text, Slack, HTML, JSON)
  • lib/digest/format.ts — empty sections omitted from formatted output
  • lib/digest/format.ts — HTML uses inline styles only, no <style> tags
  • lib/digest/send.ts — one target failing does not block others
  • lib/digest/send.ts — webhook signing with HMAC-SHA256 when secret is set
  • lib/digest/ai-summary.ts — returns null on error, never throws
  • All digest lib files have zero Next.js-specific imports (must be usable from MCP)

API routes

  • GET /api/digest/config masks secrets, admin only
  • POST /api/digest/config validates all fields, upserts correctly
  • POST /api/digest/send triggers immediate delivery, rate limited
  • GET /api/digest/preview returns DigestData without sending
  • POST /api/cron/digest validates CRON_SECRET, checks timing logic, updates lastSentAt
  • Vercel cron entry added to vercel.json (runs hourly)

Settings UI

  • Digest section in settings, admin only
  • Master enable toggle, schedule config, AI summary toggle
  • Three collapsible delivery target sections
  • Webhook URL and Slack URL masked after save (show last 6 chars only)
  • "Send test digest now" shows inline results
  • "Preview digest data" renders the HTML digest inline in settings

MCP

  • get_digest_preview tool added to mcp/tools/workspace.ts
  • send_digest_now tool added to mcp/tools/workspace.ts
  • Both registered in mcp/index.ts

Types

  • DigestData interface added to types/index.ts

Dependencies


Notes for the builder

  • lib/digest/ has zero Next.js imports. These files must be importable from the MCP server. Use lib/ai/client.ts for AI calls — it has no Next.js dependencies. Use Prisma directly for DB calls.
  • The cron runs hourly, not weekly. It checks each hour whether it's time to send based on the config. This is the standard Vercel cron pattern for timezone-aware scheduling — a daily or weekly cron would miss timezone edge cases.
  • lastSentAt prevents double-sends. The 23-hour guard on lastSentAt means even if the cron fires twice in the same hour, the digest only sends once per configured cycle.
  • Secret masking is non-negotiable. Webhook URLs and the Slack webhook URL contain credentials. Never return them in full from the API after initial save. Last 6 chars only.
  • Empty digest is valid. A team with no activity in the past 7 days should receive a short digest saying so, not an error. The formatter handles this: if all sections are empty, output "Quiet week — nothing to report."
  • Resend is optional. If RESEND_API_KEY is not set, email delivery silently skips with an error in SendResult. Don't throw.
  • Test it with a real Slack webhook. The Slack Block Kit format has quirks — test with curl against a real Slack incoming webhook before marking complete.

Metadata

Metadata

Assignees

No one assigned

    Labels

    infrastructureProject scaffold, DB, config, envperformancePerformance log, feedback loops, learninguiFrontend components and pages

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions