From c5f3750d04575a6a146f7dc200c1ac375d491199 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Tue, 25 Aug 2026 01:28:17 +0530 Subject: [PATCH] feat: add configurable repository digest alerts Signed-off-by: Arnab Nandy --- .env.example | 4 + README.md | 5 + db/migrations/0003_weekly_digest.sql | 15 + db/migrations/0004_repository_digest.sql | 27 ++ .../0005_repository_digest_frequency.sql | 4 + db/migrations/0006_alert_email.sql | 1 + doc/architecture.md | 46 +++ doc/contributing.md | 1 + doc/setup.md | 26 ++ package-lock.json | 21 ++ package.json | 2 + src/app/api/cron/weekly-digest/route.ts | 82 +++++ src/app/api/digest-preference/route.ts | 120 +++++++ src/app/api/digest-trigger/route.ts | 65 ++++ src/app/api/repositories/route.ts | 29 ++ .../api/repository-digest-template/route.ts | 153 +++++++++ .../issues/components/issue-finder.tsx | 292 +++++++++++++++--- .../components/repository-digest-card.tsx | 245 +++++++++++++++ .../issues/lib/digest-preference-cloud.ts | 55 ++++ .../issues/lib/repository-digest-cloud.ts | 42 +++ src/features/issues/server/digest-delivery.ts | 195 ++++++++++++ src/features/issues/server/github-search.ts | 81 ++++- .../issues/server/repository-digest.ts | 68 ++++ src/features/issues/server/weekly-digest.ts | 207 +++++++++++++ src/features/issues/types/search.ts | 20 ++ src/lib/auth-schema.ts | 83 +++++ .../app/api/cron/weekly-digest/route.test.ts | 231 ++++++++++++++ tests/app/api/digest-preference/route.test.ts | 153 +++++++++ tests/app/api/digest-trigger/route.test.ts | 110 +++++++ tests/app/api/repositories/route.test.ts | 52 ++++ .../repository-digest-template/route.test.ts | 230 ++++++++++++++ .../issues/components/issue-finder.test.tsx | 99 ++++++ .../repository-digest-card.test.tsx | 151 +++++++++ .../lib/digest-preference-cloud.test.ts | 95 ++++++ .../lib/repository-digest-cloud.test.ts | 61 ++++ .../issues/server/digest-delivery.test.ts | 271 ++++++++++++++++ .../issues/server/digest-schedule.test.ts | 24 ++ .../issues/server/github-search.test.ts | 89 +++++- .../issues/server/repository-digest.test.ts | 72 +++++ .../issues/server/weekly-digest.test.ts | 253 +++++++++++++++ vercel.json | 8 + 41 files changed, 3749 insertions(+), 39 deletions(-) create mode 100644 db/migrations/0003_weekly_digest.sql create mode 100644 db/migrations/0004_repository_digest.sql create mode 100644 db/migrations/0005_repository_digest_frequency.sql create mode 100644 db/migrations/0006_alert_email.sql create mode 100644 src/app/api/cron/weekly-digest/route.ts create mode 100644 src/app/api/digest-preference/route.ts create mode 100644 src/app/api/digest-trigger/route.ts create mode 100644 src/app/api/repositories/route.ts create mode 100644 src/app/api/repository-digest-template/route.ts create mode 100644 src/features/issues/components/repository-digest-card.tsx create mode 100644 src/features/issues/lib/digest-preference-cloud.ts create mode 100644 src/features/issues/lib/repository-digest-cloud.ts create mode 100644 src/features/issues/server/digest-delivery.ts create mode 100644 src/features/issues/server/repository-digest.ts create mode 100644 src/features/issues/server/weekly-digest.ts create mode 100644 tests/app/api/cron/weekly-digest/route.test.ts create mode 100644 tests/app/api/digest-preference/route.test.ts create mode 100644 tests/app/api/digest-trigger/route.test.ts create mode 100644 tests/app/api/repositories/route.test.ts create mode 100644 tests/app/api/repository-digest-template/route.test.ts create mode 100644 tests/features/issues/components/repository-digest-card.test.tsx create mode 100644 tests/features/issues/lib/digest-preference-cloud.test.ts create mode 100644 tests/features/issues/lib/repository-digest-cloud.test.ts create mode 100644 tests/features/issues/server/digest-delivery.test.ts create mode 100644 tests/features/issues/server/digest-schedule.test.ts create mode 100644 tests/features/issues/server/repository-digest.test.ts create mode 100644 tests/features/issues/server/weekly-digest.test.ts create mode 100644 vercel.json diff --git a/.env.example b/.env.example index 56848a3..a82fb65 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ BETTER_AUTH_URL=http://localhost:3000 OAUTH_PROXY_SECRET=generate_another_random_secret_with_at_least_32_characters GITHUB_CLIENT_ID=your_github_oauth_client_id GITHUB_CLIENT_SECRET=your_github_oauth_client_secret +CRON_SECRET=generate_a_random_secret_for_cron_requests +SMTP_USER=openissue.project@gmail.com +SMTP_APP_PASSWORD=your_google_app_password +DIGEST_FROM_EMAIL=OpenIssue.dev diff --git a/README.md b/README.md index e3add83..1ac92a4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ - Sorts and ranks results using activity, repository, assignment, and discussion signals - Supports reusable saved searches without requiring an account - Adds GitHub sign-in for cloud-backed saved searches that survive cleared browser storage +- Sends optional weekly email digests based on cloud-backed saved searches +- Supports an editable repository-alert template with up to five repositories and five recent issues from each - Provides light, dark, and system themes with a responsive interface ## Quick start @@ -41,6 +43,9 @@ flowchart LR SyncClient --> SavedRoute["/api/saved-searches"] SavedRoute --> BetterAuth SavedRoute --> Drizzle[Drizzle ORM] + Cron[Weekly cron] --> SearchService + Cron --> Email[Gmail SMTP] + Drizzle --> Cron BetterAuth --> Drizzle Drizzle <--> Turso[(Turso / libSQL)] ``` diff --git a/db/migrations/0003_weekly_digest.sql b/db/migrations/0003_weekly_digest.sql new file mode 100644 index 0000000..beb6ae1 --- /dev/null +++ b/db/migrations/0003_weekly_digest.sql @@ -0,0 +1,15 @@ +ALTER TABLE "user" ADD COLUMN "weekly_digest_enabled" integer DEFAULT 0 NOT NULL; +ALTER TABLE "user" ADD COLUMN "weekly_digest_last_sent_at" integer; + +CREATE TABLE IF NOT EXISTS "digest_trend_snapshot" ( + "id" text PRIMARY KEY NOT NULL, + "search_key" text NOT NULL, + "week_start" integer NOT NULL, + "issue_count" integer NOT NULL, + "top_repository" text, + "top_repository_issue_count" integer DEFAULT 0 NOT NULL, + "created_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "digest_trend_snapshot_search_week_uidx" + ON "digest_trend_snapshot" ("search_key", "week_start"); diff --git a/db/migrations/0004_repository_digest.sql b/db/migrations/0004_repository_digest.sql new file mode 100644 index 0000000..0f5141d --- /dev/null +++ b/db/migrations/0004_repository_digest.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS "repository_digest_template" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL UNIQUE, + "name" text DEFAULT 'Repository alerts' NOT NULL, + "enabled" integer DEFAULT 1 NOT NULL, + "created_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + "updated_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY ("user_id") REFERENCES "user"("id") ON UPDATE no action ON DELETE cascade +); + +CREATE UNIQUE INDEX IF NOT EXISTS "repository_digest_template_user_id_uidx" + ON "repository_digest_template" ("user_id"); + +CREATE TABLE IF NOT EXISTS "repository_digest_repository" ( + "id" text PRIMARY KEY NOT NULL, + "template_id" text NOT NULL, + "repository_full_name" text NOT NULL, + "repository_url" text NOT NULL, + "position" integer NOT NULL, + "last_issue_ids" text DEFAULT '[]' NOT NULL, + FOREIGN KEY ("template_id") REFERENCES "repository_digest_template"("id") ON UPDATE no action ON DELETE cascade +); + +CREATE UNIQUE INDEX IF NOT EXISTS "repository_digest_repository_template_repo_uidx" + ON "repository_digest_repository" ("template_id", "repository_full_name"); +CREATE INDEX IF NOT EXISTS "repository_digest_repository_template_position_idx" + ON "repository_digest_repository" ("template_id", "position"); diff --git a/db/migrations/0005_repository_digest_frequency.sql b/db/migrations/0005_repository_digest_frequency.sql new file mode 100644 index 0000000..2466ac9 --- /dev/null +++ b/db/migrations/0005_repository_digest_frequency.sql @@ -0,0 +1,4 @@ +ALTER TABLE "repository_digest_template" + ADD COLUMN "frequency" text DEFAULT 'weekly' NOT NULL; +ALTER TABLE "repository_digest_template" + ADD COLUMN "last_sent_at" integer; diff --git a/db/migrations/0006_alert_email.sql b/db/migrations/0006_alert_email.sql new file mode 100644 index 0000000..6daf192 --- /dev/null +++ b/db/migrations/0006_alert_email.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "alert_email" text; diff --git a/doc/architecture.md b/doc/architecture.md index a10851d..171d641 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -18,6 +18,8 @@ flowchart LR AuthAPI["/api/auth/*"] BetterAuth[Better Auth] SavedAPI["/api/saved-searches"] + DigestAPI["/api/digest-preference"] + DigestCron["Weekly digest cron"] Drizzle[Drizzle ORM] end @@ -25,6 +27,7 @@ flowchart LR GitHubAPI[GitHub Search and REST APIs] GitHubOAuth[GitHub OAuth] Turso[(Turso / libSQL)] + EmailAPI[Gmail SMTP] end UI -->|Search filters| SearchAPI @@ -40,6 +43,11 @@ flowchart LR Local -->|Signed-in synchronization| SavedAPI SavedAPI -->|Validate session| BetterAuth SavedAPI --> Drizzle + UI --> DigestAPI + DigestAPI --> Drizzle + DigestCron --> Drizzle + DigestCron --> SearchService + DigestCron --> EmailAPI Drizzle <--> Turso ``` @@ -80,3 +88,41 @@ Saved searches use a hybrid persistence model: The database contains Better Auth's `user`, `session`, `account`, and `verification` tables plus `saved_search`. Saved searches reference `user.id` with cascading deletion and store the selected filter values and creation timestamp. Schema definitions live in `src/lib/auth-schema.ts`; executable SQL is versioned under `db/migrations/`. + +## Weekly digest + +Signed-in users can enable or disable a weekly digest. The preference and last +successful delivery timestamp are stored on the user record. A protected Vercel +Cron route runs each Monday, loads each opted-in user's saved searches, reuses +the existing GitHub search and ranking service, deduplicates the highest-ranked +issues, and sends a concise email through Gmail SMTP. Successful delivery updates +the timestamp so a retried cron invocation does not send a duplicate digest. +GitHub searches are constrained to the previous completed UTC Monday–Sunday week. +The job stores one aggregate snapshot per normalized search and week, allowing a +later digest to describe activity as rising, falling, or steady. The first +observation is explicitly presented as a baseline. + +Digest issue links open GitHub directly. Saved-search links include the existing +filter query parameters; the issue finder reads those parameters and runs the +linked search on load. + +Authenticated users can also request their own digest immediately from the +saved-search card. The manual route uses the same delivery pipeline and six-day +cooldown as the scheduled job, so a successful manual delivery counts as that +week's digest and subsequent requests during the delivery window are rejected. + +Repository alerts are stored as one editable template per user with at most five +ordered repositories and a daily, weekly, or fortnightly frequency. GitHub +repository search powers the autocomplete. The cron runs daily, evaluates the +repository template's independent last-delivery timestamp, and continues to +send saved-search recommendations on Mondays. During +delivery, the service fetches the five newest open issues for every selection and +includes their title, summary, labels, creation date, assignment state, comment +count, and direct link. The delivered issue IDs are persisted only after a +successful email; a repository-only digest is not sent when every selection is +unchanged. + +Users may store one optional alternate alert email on their account. Recipient +resolution happens in the shared delivery service, so saved-search and repository +alerts both prefer that address and fall back to the GitHub-linked email when it +is cleared. diff --git a/doc/contributing.md b/doc/contributing.md index b199e3b..5c457d1 100644 --- a/doc/contributing.md +++ b/doc/contributing.md @@ -30,6 +30,7 @@ The test suite uses Vitest. Coverage thresholds are configured in `vitest.config | `src/features/issues/` | Issue-search UI, ranking, persistence, types, and GitHub integration | | `src/lib/` | Authentication, database client, and database schema | | `db/migrations/` | Ordered Turso SQL migrations | +| `src/app/api/cron/` | Protected scheduled jobs | | `tests/` | Unit, component, and route-handler tests | ## Database changes diff --git a/doc/setup.md b/doc/setup.md index f55fa33..52c5fb5 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -29,6 +29,10 @@ Configure these values in `.env.local`: | `OAUTH_PROXY_SECRET` | Shared secret used by Better Auth's OAuth proxy for preview deployments | | `GITHUB_CLIENT_ID` | GitHub OAuth app client ID | | `GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret | +| `CRON_SECRET` | Bearer secret used to authorize the weekly Vercel Cron request | +| `SMTP_USER` | Gmail address used to deliver weekly digest emails | +| `SMTP_APP_PASSWORD` | Google App Password for authenticated Gmail SMTP | +| `DIGEST_FROM_EMAIL` | Display name and Gmail sender address for digest emails | Never commit `.env.local` or paste real tokens into issues, pull requests, or logs. @@ -38,9 +42,27 @@ Run the SQL migrations in filename order against the Turso database: 1. `db/migrations/0001_better_auth.sql` 2. `db/migrations/0002_saved_search.sql` +3. `db/migrations/0003_weekly_digest.sql` +4. `db/migrations/0004_repository_digest.sql` +5. `db/migrations/0005_repository_digest_frequency.sql` +6. `db/migrations/0006_alert_email.sql` The first migration creates Better Auth's user, session, account, and verification tables. The second creates user-owned saved searches. Migration files intentionally contain structure only—never credentials or production data. +The third migration adds the weekly digest preference and last-delivery timestamp +to users and creates shared weekly GitHub activity snapshots. The fourth stores +each user's repository-alert template, selected repositories, display order, and +the last delivered issue IDs used to skip unchanged repository-only digests. +The fifth adds the user-selected daily, weekly, or fortnightly repository-alert +frequency and its independent successful-delivery timestamp. +The sixth adds an optional account-level alert email; when set, every digest is +sent there instead of the GitHub-linked address. `vercel.json` +invokes `/api/cron/weekly-digest` daily at 09:00 UTC; saved-search recommendations +remain restricted to Mondays. Enable 2-Step Verification for the Gmail sender, create a dedicated +Google App Password, and configure `SMTP_USER`, `SMTP_APP_PASSWORD`, and +`DIGEST_FROM_EMAIL` before enabling digests in production. Store the App +Password only in protected environment variables; never commit it. + ## GitHub OAuth Create a GitHub OAuth app and configure these callback URLs: @@ -60,3 +82,7 @@ After deploying, verify: 2. GitHub sign-in returns to the application. 3. A signed-in saved search is restored after clearing local storage. 4. Removing that search prevents it from returning after refresh. +5. Enabling and disabling the weekly digest persists after refresh. +6. An authorized manual request to the digest cron route sends a digest only to opted-in users with saved searches. +7. A signed-in user with a cloud saved search can use **Send digest now** once per weekly delivery window. +8. A signed-in user can save, reopen, revise, enable, or disable a repository-alert template containing at most five autocomplete-selected repositories and select daily, weekly, or fortnightly delivery. diff --git a/package-lock.json b/package-lock.json index fe1ba1b..b1d7582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "lucide-react": "^1.31.0", "next": "16.3.1", "next-themes": "^0.4.6", + "nodemailer": "^9.0.5", "radix-ui": "^1.6.7", "react": "19.2.8", "react-dom": "19.2.8", @@ -31,6 +32,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/node": "^26", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.1.10", @@ -4734,6 +4736,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -10537,6 +10549,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", diff --git a/package.json b/package.json index e637581..f6e905c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lucide-react": "^1.31.0", "next": "16.3.1", "next-themes": "^0.4.6", + "nodemailer": "^9.0.5", "radix-ui": "^1.6.7", "react": "19.2.8", "react-dom": "19.2.8", @@ -38,6 +39,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/node": "^26", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.1.10", diff --git a/src/app/api/cron/weekly-digest/route.ts b/src/app/api/cron/weekly-digest/route.ts new file mode 100644 index 0000000..3736e1e --- /dev/null +++ b/src/app/api/cron/weekly-digest/route.ts @@ -0,0 +1,82 @@ +import { eq, or } from "drizzle-orm"; +import { + deliverWeeklyDigest, + getDigestContext, + getRepositoryAlertSchedule, + isRepositoryAlertDue, +} from "@/features/issues/server/digest-delivery"; +import { repositoryDigestTemplate, user } from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; + +const SIX_DAYS_IN_MS = 6 * 24 * 60 * 60 * 1000; + +export async function GET(request: Request) { + if ( + !process.env.CRON_SECRET || + request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}` + ) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + const database = getDatabase(); + const now = new Date(); + const cutoff = new Date(now.getTime() - SIX_DAYS_IN_MS); + const context = await getDigestContext(database); + const recipients = await database + .select({ + id: user.id, + email: user.email, + alertEmail: user.alertEmail, + weeklyDigestLastSentAt: user.weeklyDigestLastSentAt, + }) + .from(user) + .leftJoin( + repositoryDigestTemplate, + eq(repositoryDigestTemplate.userId, user.id), + ) + .where( + or( + eq(user.weeklyDigestEnabled, true), + eq(repositoryDigestTemplate.enabled, true), + ), + ); + let sent = 0; + let failed = 0; + const baseUrl = process.env.BETTER_AUTH_URL ?? "https://openissue-dev.vercel.app"; + + for (const recipient of recipients) { + try { + const repositorySchedule = await getRepositoryAlertSchedule( + database, + recipient.id, + ); + const includeSavedSearches = + now.getUTCDay() === 1 && + (!recipient.weeklyDigestLastSentAt || + recipient.weeklyDigestLastSentAt <= cutoff); + const includeRepositoryAlerts = Boolean( + repositorySchedule?.enabled && + isRepositoryAlertDue( + repositorySchedule.frequency, + repositorySchedule.lastSentAt, + now, + ), + ); + + if ( + (includeSavedSearches || includeRepositoryAlerts) && + (await deliverWeeklyDigest(database, recipient, context, baseUrl, { + includeSavedSearches, + includeRepositoryAlerts, + })) + ) { + sent += 1; + } + } catch (error) { + failed += 1; + console.error("Unable to send weekly digest.", error); + } + } + + return Response.json({ recipients: recipients.length, sent, failed }); +} diff --git a/src/app/api/digest-preference/route.ts b/src/app/api/digest-preference/route.ts new file mode 100644 index 0000000..3401fff --- /dev/null +++ b/src/app/api/digest-preference/route.ts @@ -0,0 +1,120 @@ +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { user } from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; + +async function getSession(request: Request) { + return auth.api.getSession({ headers: request.headers }); +} + +type PreferenceUpdates = { + weeklyDigestEnabled?: boolean; + weeklyDigestLastSentAt?: null; + alertEmail?: string | null; +}; + +function normalizeAlertEmail(value: string) { + const email = value.trim().toLowerCase(); + if (!email) return null; + if (email.length > 254 || [...email].some((character) => character.trim() === "")) { + return undefined; + } + + const at = email.indexOf("@"); + const lastAt = email.lastIndexOf("@"); + const dot = email.lastIndexOf("."); + if (at <= 0 || at !== lastAt || dot <= at + 1 || dot === email.length - 1) { + return undefined; + } + + return email; +} + +function getPreferenceUpdates(input: { + enabled?: unknown; + alertEmail?: unknown; +} | null): { updates?: PreferenceUpdates; error?: string } { + const enabled = input?.enabled; + const rawAlertEmail = input?.alertEmail; + if (enabled !== undefined && typeof enabled !== "boolean") { + return { error: "Invalid digest preference." }; + } + if (rawAlertEmail !== undefined && typeof rawAlertEmail !== "string") { + return { error: "Invalid alert email." }; + } + if (enabled === undefined && rawAlertEmail === undefined) { + return { error: "No preference supplied." }; + } + + const updates: PreferenceUpdates = {}; + if (typeof rawAlertEmail === "string") { + const alertEmail = normalizeAlertEmail(rawAlertEmail); + if (alertEmail === undefined) return { error: "Enter a valid alert email." }; + updates.alertEmail = alertEmail; + } + if (typeof enabled === "boolean") { + updates.weeklyDigestEnabled = enabled; + if (!enabled) updates.weeklyDigestLastSentAt = null; + } + return { updates }; +} + +export async function GET(request: Request) { + const session = await getSession(request); + + if (!session) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + const [preference] = await getDatabase() + .select({ + enabled: user.weeklyDigestEnabled, + alertEmail: user.alertEmail, + }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1); + + return Response.json({ + enabled: preference?.enabled ?? false, + alertEmail: preference?.alertEmail ?? null, + }); +} + +export async function PATCH(request: Request) { + const session = await getSession(request); + + if (!session) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + let body: unknown; + + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid request body." }, { status: 400 }); + } + + const input = body as { + enabled?: unknown; + alertEmail?: unknown; + } | null; + const { updates, error } = getPreferenceUpdates(input); + if (error || !updates) { + return Response.json({ error }, { status: 400 }); + } + + await getDatabase() + .update(user) + .set(updates) + .where(eq(user.id, session.user.id)); + + return Response.json({ + enabled: + typeof updates.weeklyDigestEnabled === "boolean" + ? updates.weeklyDigestEnabled + : undefined, + alertEmail: updates.alertEmail, + }); +} diff --git a/src/app/api/digest-trigger/route.ts b/src/app/api/digest-trigger/route.ts new file mode 100644 index 0000000..d26f1a9 --- /dev/null +++ b/src/app/api/digest-trigger/route.ts @@ -0,0 +1,65 @@ +import { eq } from "drizzle-orm"; +import { + deliverWeeklyDigest, + getDigestContext, +} from "@/features/issues/server/digest-delivery"; +import { auth } from "@/lib/auth"; +import { user } from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; + +const SIX_DAYS_IN_MS = 6 * 24 * 60 * 60 * 1000; + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + const database = getDatabase(); + const [recipient] = await database + .select({ + id: user.id, + email: user.email, + alertEmail: user.alertEmail, + lastSentAt: user.weeklyDigestLastSentAt, + }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1); + + if (!recipient) { + return Response.json({ error: "Account not found." }, { status: 404 }); + } + + if ( + recipient.lastSentAt && + recipient.lastSentAt.getTime() > Date.now() - SIX_DAYS_IN_MS + ) { + return Response.json( + { error: "A weekly digest was already sent recently." }, + { status: 429 }, + ); + } + + try { + const sent = await deliverWeeklyDigest( + database, + recipient, + await getDigestContext(database), + process.env.BETTER_AUTH_URL ?? "https://openissue-dev.vercel.app", + ); + + if (!sent) { + return Response.json( + { error: "Save a search or repository alert before sending a digest." }, + { status: 400 }, + ); + } + + return Response.json({ sent: true }); + } catch (error) { + console.error("Unable to manually send weekly digest.", error); + return Response.json({ error: "Unable to send the weekly digest." }, { status: 502 }); + } +} diff --git a/src/app/api/repositories/route.ts b/src/app/api/repositories/route.ts new file mode 100644 index 0000000..f942b30 --- /dev/null +++ b/src/app/api/repositories/route.ts @@ -0,0 +1,29 @@ +import { auth } from "@/lib/auth"; +import { searchGitHubRepositories } from "@/features/issues/server/github-search"; + +export async function GET(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + const query = new URL(request.url).searchParams.get("query")?.trim() ?? ""; + + if (query.length < 2 || query.length > 100) { + return Response.json( + { error: "Enter at least two characters." }, + { status: 400 }, + ); + } + + try { + return Response.json({ repositories: await searchGitHubRepositories(query) }); + } catch (error) { + console.error("Unable to search GitHub repositories.", error); + return Response.json( + { error: "Unable to search GitHub repositories." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/repository-digest-template/route.ts b/src/app/api/repository-digest-template/route.ts new file mode 100644 index 0000000..4c3339d --- /dev/null +++ b/src/app/api/repository-digest-template/route.ts @@ -0,0 +1,153 @@ +import { randomUUID } from "node:crypto"; +import { asc, eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { + repositoryDigestRepository, + repositoryDigestTemplate, +} from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; + +const MAX_REPOSITORIES = 5; +const FREQUENCIES = new Set(["daily", "weekly", "fortnightly"]); +const REPOSITORY_NAME_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +type RepositoryInput = { fullName: string; url: string }; + +function isRepository(value: unknown): value is RepositoryInput { + if (!value || typeof value !== "object") return false; + const repository = value as Partial; + return ( + typeof repository.fullName === "string" && + REPOSITORY_NAME_PATTERN.test(repository.fullName) && + typeof repository.url === "string" && + repository.url === `https://github.com/${repository.fullName}` + ); +} + +async function getTemplate(userId: string) { + const database = getDatabase(); + const [template] = await database + .select() + .from(repositoryDigestTemplate) + .where(eq(repositoryDigestTemplate.userId, userId)) + .limit(1); + + if (!template) return null; + + const repositories = await database + .select({ + fullName: repositoryDigestRepository.repositoryFullName, + url: repositoryDigestRepository.repositoryUrl, + }) + .from(repositoryDigestRepository) + .where(eq(repositoryDigestRepository.templateId, template.id)) + .orderBy(asc(repositoryDigestRepository.position)); + + return { + name: template.name, + enabled: template.enabled, + frequency: template.frequency, + repositories, + }; +} + +export async function GET(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) return Response.json({ error: "Unauthorized." }, { status: 401 }); + + return Response.json({ template: await getTemplate(session.user.id) }); +} + +export async function PUT(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) return Response.json({ error: "Unauthorized." }, { status: 401 }); + + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid request body." }, { status: 400 }); + } + + const input = body as { + name?: unknown; + enabled?: unknown; + frequency?: unknown; + repositories?: unknown; + }; + const name = typeof input.name === "string" ? input.name.trim() : ""; + const repositories = input.repositories; + + if ( + !name || + name.length > 100 || + typeof input.enabled !== "boolean" || + typeof input.frequency !== "string" || + !FREQUENCIES.has(input.frequency) || + !Array.isArray(repositories) || + repositories.length > MAX_REPOSITORIES || + !repositories.every(isRepository) || + new Set(repositories.map((repository) => repository.fullName.toLowerCase())).size !== + repositories.length + ) { + return Response.json({ error: "Invalid repository digest template." }, { status: 400 }); + } + + const database = getDatabase(); + const validRepositories = repositories as RepositoryInput[]; + const frequency = input.frequency as "daily" | "weekly" | "fortnightly"; + const [existingRow] = await database + .select({ id: repositoryDigestTemplate.id }) + .from(repositoryDigestTemplate) + .where(eq(repositoryDigestTemplate.userId, session.user.id)) + .limit(1); + const templateId = existingRow?.id ?? randomUUID(); + const existingRepositories = existingRow + ? await database + .select({ + fullName: repositoryDigestRepository.repositoryFullName, + lastIssueIds: repositoryDigestRepository.lastIssueIds, + }) + .from(repositoryDigestRepository) + .where(eq(repositoryDigestRepository.templateId, templateId)) + : []; + const issueIdsByRepository = new Map( + existingRepositories.map((repository) => [ + repository.fullName.toLowerCase(), + repository.lastIssueIds, + ]), + ); + + await database + .insert(repositoryDigestTemplate) + .values({ + id: templateId, + userId: session.user.id, + name, + enabled: input.enabled, + frequency, + }) + .onConflictDoUpdate({ + target: repositoryDigestTemplate.userId, + set: { name, enabled: input.enabled, frequency, updatedAt: new Date() }, + }); + await database + .delete(repositoryDigestRepository) + .where(eq(repositoryDigestRepository.templateId, templateId)); + + if (validRepositories.length) { + await database.insert(repositoryDigestRepository).values( + validRepositories.map((repository, position) => ({ + id: randomUUID(), + templateId, + repositoryFullName: repository.fullName, + repositoryUrl: repository.url, + position, + lastIssueIds: + issueIdsByRepository.get(repository.fullName.toLowerCase()) ?? "[]", + })), + ); + } + + return Response.json({ template: await getTemplate(session.user.id) }); +} diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 46a8601..68ef98d 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -2,7 +2,7 @@ import { FormEvent, useEffect, useMemo, useState } from "react"; import Image from "next/image"; -import { Bookmark, Search, Trash2 } from "lucide-react"; +import { Bookmark, Mail, Search, Trash2 } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; import { AuthControls } from "@/components/auth-controls"; import { Badge } from "@/components/ui/badge"; @@ -18,6 +18,13 @@ import { deleteCloudSavedSearch, syncSavedSearches, } from "@/features/issues/lib/saved-search-cloud"; +import { + getDigestPreference, + getAlertEmail, + triggerWeeklyDigest, + updateAlertEmail, + updateDigestPreference, +} from "@/features/issues/lib/digest-preference-cloud"; import { Card, CardContent, @@ -36,6 +43,7 @@ import { import { IssueCard } from "@/features/issues/components/issue-card"; import { LoadingResults } from "@/features/issues/components/loading-results"; import { Metric } from "@/features/issues/components/metric"; +import { RepositoryDigestCard } from "@/features/issues/components/repository-digest-card"; import { HACKTOBERFEST_OPTIONS, LABEL_OPTIONS, @@ -48,6 +56,126 @@ import { mergeRankedIssues, rankIssues } from "@/features/issues/lib/ranking"; import type { SearchResponse, Issue } from "@/features/issues/types/search"; import { authClient } from "@/lib/auth-client"; +function DigestControls({ + linkedEmail, + alertEmail, + digestEnabled, + digestStatus, + isPending, + onAlertEmailChange, + onPreferenceChange, + onSaveAlertEmail, + onTrigger, +}: Readonly<{ + linkedEmail?: string | null; + alertEmail: string; + digestEnabled: boolean; + digestStatus: string | null; + isPending: boolean; + onAlertEmailChange: (value: string) => void; + onPreferenceChange: () => void; + onSaveAlertEmail: () => void; + onTrigger: () => void; +}>) { + return ( +
+

+ Get recommended issues from your saved searches every Monday. +

+ +
+ onAlertEmailChange(event.target.value)} + placeholder={linkedEmail ?? "Alternate alert email"} + aria-label="Alternate alert email" + /> + +

+ Leave blank to use your GitHub-linked email for all alerts. +

+
+ + {digestStatus ? ( +

{digestStatus}

+ ) : null} +
+ ); +} + +function SearchSummary({ + error, + data, +}: Readonly<{ error: string | null; data: SearchResponse | null }>) { + return ( + <> + {error ? ( + + + Search failed + {error} + + + ) : null} + {data ? ( +
+
+

Ranked issues

+

{data.query}

+
+
+ + {compactNumber(data.candidateCount)} ranked candidates + + + {compactNumber(data.totalCount)} raw GitHub matches + +
+
+ ) : ( + + + Ready when you are + + Run a search to pull live issue data from GitHub. + + + + )} + + ); +} + export function IssueFinder() { const { data: session, isPending: isSessionPending } = authClient.useSession(); const [tech, setTech] = useState("Java"); @@ -65,6 +193,10 @@ export function IssueFinder() { const [savedSearches, setSavedSearches] = useState([]); const [savedSearchName, setSavedSearchName] = useState(""); + const [digestEnabled, setDigestEnabled] = useState(false); + const [isDigestPending, setIsDigestPending] = useState(false); + const [digestStatus, setDigestStatus] = useState(null); + const [alertEmail, setAlertEmail] = useState(""); useEffect(() => { // Hydration must start with the server's empty snapshot before reading browser storage. @@ -72,6 +204,30 @@ export function IssueFinder() { setSavedSearches(getSavedSearches()); }, []); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const linkedSearch = { + tech: params.get("tech")?.trim() ?? "", + label: params.get("label") ?? "help-wanted", + sort: params.get("sort") ?? "updated", + linkedPr: params.get("linkedPr") ?? "any", + hacktoberfest: params.get("hacktoberfest") ?? "any", + }; + + if (!linkedSearch.tech) return; + + // Hydration must use the server defaults before applying URL filters. + // eslint-disable-next-line react-hooks/set-state-in-effect + setTech(linkedSearch.tech); + setLabel(linkedSearch.label); + setSort(linkedSearch.sort); + setLinkedPr(linkedSearch.linkedPr); + setHacktoberfest(linkedSearch.hacktoberfest); + void searchIssues(undefined, linkedSearch); + // The URL is an initial navigation input, not reactive component state. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (isSessionPending || !session?.user.id) return; @@ -97,6 +253,27 @@ export function IssueFinder() { }; }, [isSessionPending, session?.user.id]); + useEffect(() => { + if (isSessionPending || !session?.user.id) return; + + let cancelled = false; + + void Promise.all([getDigestPreference(), getAlertEmail()]) + .then(([enabled, savedAlertEmail]) => { + if (!cancelled) { + setDigestEnabled(enabled); + setAlertEmail(savedAlertEmail); + } + }) + .catch(() => { + // Saved searches remain usable if the preference cannot be loaded. + }); + + return () => { + cancelled = true; + }; + }, [isSessionPending, session?.user.id]); + const selectedLabel = useMemo( () => LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0], @@ -204,6 +381,67 @@ export function IssueFinder() { }); } + async function handleDigestPreference() { + setIsDigestPending(true); + + try { + const enabled = await updateDigestPreference(!digestEnabled); + setDigestEnabled(enabled); + setError(null); + } catch (preferenceError) { + setError( + preferenceError instanceof Error + ? preferenceError.message + : "Unable to update the weekly digest preference.", + ); + } finally { + setIsDigestPending(false); + } + } + + async function handleDigestTrigger() { + setIsDigestPending(true); + setDigestStatus(null); + + try { + await triggerWeeklyDigest(); + setDigestStatus("Weekly digest sent. Check your inbox."); + setError(null); + } catch (triggerError) { + setError( + triggerError instanceof Error + ? triggerError.message + : "Unable to send the weekly digest.", + ); + } finally { + setIsDigestPending(false); + } + } + + async function handleAlertEmail() { + setIsDigestPending(true); + setDigestStatus(null); + + try { + const savedAlertEmail = await updateAlertEmail(alertEmail); + setAlertEmail(savedAlertEmail); + setDigestStatus( + savedAlertEmail + ? `Alerts will be sent to ${savedAlertEmail}.` + : "Alerts will use your GitHub-linked email.", + ); + setError(null); + } catch (alertEmailError) { + setError( + alertEmailError instanceof Error + ? alertEmailError.message + : "Unable to update the alert email.", + ); + } finally { + setIsDigestPending(false); + } + } + async function searchIssues( event?: FormEvent, searchOverride?: { @@ -545,6 +783,20 @@ export function IssueFinder() { + {session?.user.id ? ( + void handleDigestPreference()} + onSaveAlertEmail={() => void handleAlertEmail()} + onTrigger={() => void handleDigestTrigger()} + /> + ) : null} + {savedSearches.length === 0 ? (

No saved searches yet. @@ -592,45 +844,11 @@ export function IssueFinder() { )} + {session?.user.id ? : null}

- {error ? ( - - - - Search failed - - {error} - - - ) : null} - - {data ? ( -
-
-

Ranked issues

-

{data.query}

-
-
- - {compactNumber(data.candidateCount)} ranked candidates - - - {compactNumber(data.totalCount)} raw GitHub matches - -
-
- ) : ( - - - Ready when you are - - Run a search to pull live issue data from GitHub. - - - - )} + {isLoading ? : null} diff --git a/src/features/issues/components/repository-digest-card.tsx b/src/features/issues/components/repository-digest-card.tsx new file mode 100644 index 0000000..4510531 --- /dev/null +++ b/src/features/issues/components/repository-digest-card.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Mail, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + getRepositoryDigestTemplate, + saveRepositoryDigestTemplate, + searchRepositories, + type RepositoryDigestTemplate, +} from "@/features/issues/lib/repository-digest-cloud"; +import type { RepositorySuggestion } from "@/features/issues/types/search"; + +const EMPTY_TEMPLATE: RepositoryDigestTemplate = { + name: "Repository alerts", + enabled: true, + frequency: "weekly", + repositories: [], +}; + +export function RepositoryDigestCard() { + const [template, setTemplate] = useState(EMPTY_TEMPLATE); + const [query, setQuery] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + void getRepositoryDigestTemplate() + .then((saved) => { + if (!cancelled && saved) setTemplate(saved); + }) + .catch(() => { + if (!cancelled) setMessage("Unable to load repository alerts."); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const trimmedQuery = query.trim(); + if (trimmedQuery.length < 2 || template.repositories.length >= 5) { + return; + } + + let cancelled = false; + const timeout = window.setTimeout(() => { + void searchRepositories(trimmedQuery) + .then((repositories) => { + if (!cancelled) { + const selected = new Set( + template.repositories.map((repository) => + repository.fullName.toLowerCase(), + ), + ); + setSuggestions( + repositories.filter( + (repository) => !selected.has(repository.fullName.toLowerCase()), + ), + ); + } + }) + .catch(() => { + if (!cancelled) setSuggestions([]); + }); + }, 300); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [query, template.repositories]); + + function addRepository(repository: RepositorySuggestion) { + setTemplate((current) => ({ + ...current, + repositories: [ + ...current.repositories, + { fullName: repository.fullName, url: repository.url }, + ], + })); + setQuery(""); + setSuggestions([]); + setMessage(null); + } + + async function saveTemplate() { + setIsSaving(true); + setMessage(null); + try { + setTemplate(await saveRepositoryDigestTemplate(template)); + setMessage("Repository alert template saved."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Unable to save template."); + } finally { + setIsSaving(false); + } + } + + return ( + + + Repository alerts + + Add up to five repositories. Each digest includes their five newest + open issues and skips unchanged results. + + + + + setTemplate((current) => ({ ...current, name: event.target.value })) + } + maxLength={100} + aria-label="Repository alert template name" + /> + + + +
+ setQuery(event.target.value)} + placeholder="Search GitHub repositories" + aria-label="Search GitHub repositories" + disabled={template.repositories.length >= 5} + autoComplete="off" + /> + {query.trim().length >= 2 && suggestions.length ? ( +
+ {suggestions.map((repository) => ( + + ))} +
+ ) : null} +
+ +
+ {template.repositories.map((repository) => ( +
+ + {repository.fullName} + + +
+ ))} +
+ + + + {message ?

{message}

: null} +
+
+ ); +} diff --git a/src/features/issues/lib/digest-preference-cloud.ts b/src/features/issues/lib/digest-preference-cloud.ts new file mode 100644 index 0000000..371b722 --- /dev/null +++ b/src/features/issues/lib/digest-preference-cloud.ts @@ -0,0 +1,55 @@ +export async function getDigestPreference(): Promise { + const response = await fetch("/api/digest-preference"); + + if (!response.ok) { + throw new Error("Unable to load the weekly digest preference."); + } + + const result = (await response.json()) as { enabled: boolean }; + return result.enabled; +} + +export async function getAlertEmail(): Promise { + const response = await fetch("/api/digest-preference"); + if (!response.ok) throw new Error("Unable to load the alert email."); + const result = (await response.json()) as { alertEmail: string | null }; + return result.alertEmail ?? ""; +} + +export async function updateAlertEmail(alertEmail: string): Promise { + const response = await fetch("/api/digest-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alertEmail }), + }); + const result = (await response.json()) as { + alertEmail?: string | null; + error?: string; + }; + if (!response.ok) throw new Error(result.error ?? "Unable to update the alert email."); + return result.alertEmail ?? ""; +} + +export async function updateDigestPreference(enabled: boolean): Promise { + const response = await fetch("/api/digest-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + + if (!response.ok) { + throw new Error("Unable to update the weekly digest preference."); + } + + const result = (await response.json()) as { enabled: boolean }; + return result.enabled; +} + +export async function triggerWeeklyDigest(): Promise { + const response = await fetch("/api/digest-trigger", { method: "POST" }); + + if (!response.ok) { + const result = (await response.json()) as { error?: string }; + throw new Error(result.error ?? "Unable to send the weekly digest."); + } +} diff --git a/src/features/issues/lib/repository-digest-cloud.ts b/src/features/issues/lib/repository-digest-cloud.ts new file mode 100644 index 0000000..0f5167f --- /dev/null +++ b/src/features/issues/lib/repository-digest-cloud.ts @@ -0,0 +1,42 @@ +import type { RepositorySuggestion } from "@/features/issues/types/search"; + +export type RepositoryDigestTemplate = { + name: string; + enabled: boolean; + frequency: "daily" | "weekly" | "fortnightly"; + repositories: Array<{ fullName: string; url: string }>; +}; + +async function jsonResponse(response: Response): Promise { + const payload = (await response.json()) as T & { error?: string }; + if (!response.ok) throw new Error(payload.error ?? "Request failed."); + return payload; +} + +export async function getRepositoryDigestTemplate() { + const payload = await jsonResponse<{ template: RepositoryDigestTemplate | null }>( + await fetch("/api/repository-digest-template"), + ); + return payload.template; +} + +export async function saveRepositoryDigestTemplate( + template: RepositoryDigestTemplate, +) { + const payload = await jsonResponse<{ template: RepositoryDigestTemplate }>( + await fetch("/api/repository-digest-template", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(template), + }), + ); + return payload.template; +} + +export async function searchRepositories(query: string) { + const params = new URLSearchParams({ query }); + const payload = await jsonResponse<{ repositories: RepositorySuggestion[] }>( + await fetch(`/api/repositories?${params}`), + ); + return payload.repositories; +} diff --git a/src/features/issues/server/digest-delivery.ts b/src/features/issues/server/digest-delivery.ts new file mode 100644 index 0000000..7008286 --- /dev/null +++ b/src/features/issues/server/digest-delivery.ts @@ -0,0 +1,195 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { and, asc, eq } from "drizzle-orm"; +import { + buildWeeklyDigest, + getWeekStart, + sendWeeklyDigest, + type DigestTrend, +} from "@/features/issues/server/weekly-digest"; +import { + digestTrendSnapshot, + repositoryDigestRepository, + repositoryDigestTemplate, + savedSearch, + user, +} from "@/lib/auth-schema"; +import { buildRepositoryDigest } from "@/features/issues/server/repository-digest"; +import type { getDatabase } from "@/lib/db"; + +type Database = ReturnType; + +export type DigestDeliveryOptions = { + includeSavedSearches?: boolean; + includeRepositoryAlerts?: boolean; +}; + +const FREQUENCY_INTERVAL_MS = { + daily: 20 * 60 * 60 * 1000, + weekly: (6 * 24 + 20) * 60 * 60 * 1000, + fortnightly: (13 * 24 + 20) * 60 * 60 * 1000, +} as const; + +export function isRepositoryAlertDue( + frequency: keyof typeof FREQUENCY_INTERVAL_MS, + lastSentAt: Date | null, + now = new Date(), +) { + return ( + !lastSentAt || + lastSentAt.getTime() <= now.getTime() - FREQUENCY_INTERVAL_MS[frequency] + ); +} + +export async function getRepositoryAlertSchedule( + database: Database, + userId: string, +) { + const [template] = await database + .select({ + enabled: repositoryDigestTemplate.enabled, + frequency: repositoryDigestTemplate.frequency, + lastSentAt: repositoryDigestTemplate.lastSentAt, + }) + .from(repositoryDigestTemplate) + .where(eq(repositoryDigestTemplate.userId, userId)) + .limit(1); + + return template ?? null; +} + +export async function getDigestContext(database: Database) { + const weekStart = getWeekStart(); + weekStart.setUTCDate(weekStart.getUTCDate() - 7); + const previousWeekStart = new Date(weekStart); + previousWeekStart.setUTCDate(previousWeekStart.getUTCDate() - 7); + const previousTrendRows = await database + .select() + .from(digestTrendSnapshot) + .where(eq(digestTrendSnapshot.weekStart, previousWeekStart)); + + return { + weekStart, + previousTrends: new Map( + previousTrendRows.map((trend) => [trend.searchKey, trend]), + ), + }; +} + +async function getDigestSources( + database: Database, + userId: string, + options: DigestDeliveryOptions, +) { + const searchesPromise = options.includeSavedSearches + ? database.select().from(savedSearch).where(eq(savedSearch.userId, userId)) + : Promise.resolve([]); + const templatePromise = options.includeRepositoryAlerts + ? database + .select({ id: repositoryDigestTemplate.id }) + .from(repositoryDigestTemplate) + .where( + and( + eq(repositoryDigestTemplate.userId, userId), + eq(repositoryDigestTemplate.enabled, true), + ), + ) + .limit(1) + : Promise.resolve([]); + const [searches, [template]] = await Promise.all([searchesPromise, templatePromise]); + const repositories = template + ? await database + .select({ + id: repositoryDigestRepository.id, + fullName: repositoryDigestRepository.repositoryFullName, + url: repositoryDigestRepository.repositoryUrl, + lastIssueIds: repositoryDigestRepository.lastIssueIds, + }) + .from(repositoryDigestRepository) + .where(eq(repositoryDigestRepository.templateId, template.id)) + .orderBy(asc(repositoryDigestRepository.position)) + : []; + + return { searches, template, repositories }; +} + +export async function deliverWeeklyDigest( + database: Database, + recipient: { id: string; email: string; alertEmail?: string | null }, + context: Awaited>, + baseUrl: string, + options: DigestDeliveryOptions = {}, +) { + const { searches, template, repositories } = await getDigestSources( + database, + recipient.id, + { + includeSavedSearches: options.includeSavedSearches ?? true, + includeRepositoryAlerts: options.includeRepositoryAlerts ?? true, + }, + ); + + if (!searches.length && !repositories.length) return false; + + const digest = searches.length + ? await buildWeeklyDigest( + searches.map((search) => ({ + ...search, + createdAt: search.createdAt.toISOString(), + })), + baseUrl, + context.previousTrends, + context.weekStart, + ) + : { subject: "Your repository alerts", html: "", issueCount: 0, trends: [] }; + const repositoryDigest = repositories.length + ? await buildRepositoryDigest(repositories) + : null; + + if (!searches.length && repositoryDigest && !repositoryDigest.changed) { + return false; + } + + for (const trend of digest.trends) { + const id = createHash("sha256") + .update(`${trend.searchKey}:${trend.weekStart.toISOString()}`) + .digest("hex"); + + await database + .insert(digestTrendSnapshot) + .values({ id, ...trend }) + .onConflictDoNothing(); + } + + await sendWeeklyDigest({ + to: recipient.alertEmail ?? recipient.email, + subject: repositoryDigest + ? `${digest.issueCount + repositoryDigest.issueCount} open-source issues for you this week` + : digest.subject, + html: repositoryDigest + ? `${digest.html}${repositoryDigest.html}` + : digest.html, + }); + for (const snapshot of repositoryDigest?.snapshots ?? []) { + await database + .update(repositoryDigestRepository) + .set({ lastIssueIds: snapshot.issueIds }) + .where(eq(repositoryDigestRepository.id, snapshot.id)); + } + const sentAt = new Date(); + if (searches.length) { + await database + .update(user) + .set({ weeklyDigestLastSentAt: sentAt }) + .where(eq(user.id, recipient.id)); + } + if (template && repositoryDigest) { + await database + .update(repositoryDigestTemplate) + .set({ lastSentAt: sentAt }) + .where(eq(repositoryDigestTemplate.id, template.id)); + } + + return true; +} diff --git a/src/features/issues/server/github-search.ts b/src/features/issues/server/github-search.ts index c44661a..317ba91 100644 --- a/src/features/issues/server/github-search.ts +++ b/src/features/issues/server/github-search.ts @@ -16,6 +16,8 @@ import type { Issue, IssueStatus, SearchResponse, + RepositoryDigestIssue, + RepositorySuggestion, } from "@/features/issues/types/search"; const PAGE_SIZE = 24; @@ -88,6 +90,14 @@ function buildLinkedPrQualifier(linkedPr: string) { return null; } +function buildUpdatedQualifier(updatedAfter?: string, updatedBefore?: string) { + if (!updatedAfter) return null; + const range = updatedBefore + ? `${updatedAfter}..${updatedBefore}` + : `>=${updatedAfter}`; + return `updated:${range}`; +} + function getRepoFullName(repositoryUrl: string) { const apiPrefix = "https://api.github.com/repos/"; @@ -221,12 +231,68 @@ async function githubFetch(url: string, token?: string, revalidate = 60) { }; } +export async function searchGitHubRepositories( + query: string, +): Promise { + const url = new URL("https://api.github.com/search/repositories"); + url.searchParams.set("q", `${query.trim()} in:name,description archived:false`); + url.searchParams.set("sort", "stars"); + url.searchParams.set("order", "desc"); + url.searchParams.set("per_page", "8"); + const result = await githubFetch( + url.toString(), + process.env.GITHUB_TOKEN, + 300, + ); + + return result.data.items.map((repository) => ({ + fullName: repository.full_name, + url: repository.html_url, + description: repository.description ?? null, + stars: repository.stargazers_count, + })); +} + +export async function getRecentRepositoryIssues( + repositoryFullName: string, +): Promise { + const url = new URL("https://api.github.com/search/issues"); + url.searchParams.set( + "q", + `repo:${repositoryFullName} is:issue is:open`, + ); + url.searchParams.set("sort", "created"); + url.searchParams.set("order", "desc"); + url.searchParams.set("per_page", "5"); + const result = await githubFetch( + url.toString(), + process.env.GITHUB_TOKEN, + 180, + ); + + return result.data.items.slice(0, 5).map((issue) => ({ + id: issue.html_url, + title: issue.title, + url: issue.html_url, + summary: (issue.body ?? "No description provided.") + .replaceAll(/\s+/g, " ") + .trim() + .slice(0, 240), + labels: issue.labels.map((label) => label.name), + createdAt: issue.created_at, + comments: issue.comments, + assigned: Boolean(issue.assignee || issue.assignees?.length), + })); +} + export async function searchGitHubIssues({ tech, label: rawLabel, sort: rawSort, linkedPr: rawLinkedPr, hacktoberfest: rawHacktoberfest, + updatedAfter, + updatedBefore, page = 1, }: { tech: string; @@ -234,6 +300,8 @@ export async function searchGitHubIssues({ sort: string | null; linkedPr: string | null; hacktoberfest?: string | null; + updatedAfter?: string; + updatedBefore?: string; page?: number; }): Promise { const label = GITHUB_LABELS[normalize(rawLabel)] ?? "help wanted"; @@ -272,12 +340,23 @@ export async function searchGitHubIssues({ queryParts.push(`label:${quoteSearchValue(label)}`); + const updatedQualifier = buildUpdatedQualifier(updatedAfter, updatedBefore); + + if (updatedQualifier) { + queryParts.push(updatedQualifier); + } + if (linkedPrQualifier) { queryParts.push(linkedPrQualifier); } const displayQuery = repoTopicQuery - ? [repoTopicQuery, `label:${quoteSearchValue(label)}`, linkedPrQualifier] + ? [ + repoTopicQuery, + `label:${quoteSearchValue(label)}`, + updatedQualifier, + linkedPrQualifier, + ] .filter(Boolean) .join(" ") : queryParts.join(" "); diff --git a/src/features/issues/server/repository-digest.ts b/src/features/issues/server/repository-digest.ts new file mode 100644 index 0000000..b5f1edc --- /dev/null +++ b/src/features/issues/server/repository-digest.ts @@ -0,0 +1,68 @@ +import "server-only"; + +import { getRecentRepositoryIssues } from "@/features/issues/server/github-search"; + +export type RepositoryDigestSelection = { + id: string; + fullName: string; + url: string; + lastIssueIds: string; +}; + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export async function buildRepositoryDigest( + repositories: RepositoryDigestSelection[], +) { + const results = await Promise.all( + repositories.map(async (repository) => ({ + repository, + issues: await getRecentRepositoryIssues(repository.fullName), + })), + ); + const snapshots = results.map(({ repository, issues }) => ({ + id: repository.id, + issueIds: JSON.stringify(issues.map((issue) => issue.id)), + })); + const changed = results.some( + ({ repository }, index) => repository.lastIssueIds !== snapshots[index].issueIds, + ); + const issueCount = results.reduce((count, result) => count + result.issues.length, 0); + const sections = results + .map(({ repository, issues }) => { + const issueItems = issues.length + ? issues + .map((issue) => { + const details = [ + new Date(issue.createdAt).toLocaleDateString("en", { + timeZone: "UTC", + dateStyle: "medium", + }), + `${issue.comments} comments`, + issue.assigned ? "assigned" : "unassigned", + issue.labels.length ? issue.labels.join(", ") : "no labels", + ].join(" · "); + + return `
  • ${escapeHtml(issue.title)}

    ${escapeHtml(issue.summary)}

    ${escapeHtml(details)}
  • `; + }) + .join("") + : "
  • No open issues found.
  • "; + + return `

    ${escapeHtml(repository.fullName)}

      ${issueItems}
    `; + }) + .join(""); + + return { + changed, + issueCount, + snapshots, + html: `

    Your repository alerts

    ${sections}`, + }; +} diff --git a/src/features/issues/server/weekly-digest.ts b/src/features/issues/server/weekly-digest.ts new file mode 100644 index 0000000..41eb61f --- /dev/null +++ b/src/features/issues/server/weekly-digest.ts @@ -0,0 +1,207 @@ +import "server-only"; + +import nodemailer from "nodemailer"; +import type { Issue } from "@/features/issues/types/search"; +import type { SavedSearch } from "@/features/issues/lib/saved-searches"; +import { searchGitHubIssues } from "@/features/issues/server/github-search"; + +const MAX_SEARCHES_PER_DIGEST = 5; +const MAX_ISSUES_PER_SEARCH = 3; +const MAX_ISSUES_PER_DIGEST = 10; + +export type DigestTrend = { + searchKey: string; + weekStart: Date; + issueCount: number; + topRepository: string | null; + topRepositoryIssueCount: number; +}; + +export function getDigestSearchKey(search: SavedSearch) { + return JSON.stringify({ + tech: search.tech.trim().toLowerCase(), + label: search.label, + sort: search.sort, + linkedPr: search.linkedPr, + hacktoberfest: search.hacktoberfest, + }); +} + +export function getWeekStart(date = new Date()) { + const weekStart = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + const daysSinceMonday = (weekStart.getUTCDay() + 6) % 7; + weekStart.setUTCDate(weekStart.getUTCDate() - daysSinceMonday); + return weekStart; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function searchUrl(baseUrl: string, search: SavedSearch) { + const url = new URL(baseUrl); + url.searchParams.set("tech", search.tech); + url.searchParams.set("label", search.label); + url.searchParams.set("sort", search.sort); + url.searchParams.set("linkedPr", search.linkedPr); + url.searchParams.set("hacktoberfest", search.hacktoberfest); + return url.toString(); +} + +function describeTrend(change: number | null) { + if (change === null) return "baseline recorded"; + if (change > 0) return `up ${change} from last week`; + if (change < 0) return `down ${Math.abs(change)} from last week`; + return "steady from last week"; +} + +export async function buildWeeklyDigest( + searches: SavedSearch[], + baseUrl: string, + previousTrends = new Map(), + weekStart = (() => { + const start = getWeekStart(); + start.setUTCDate(start.getUTCDate() - 7); + return start; + })(), +): Promise<{ + subject: string; + html: string; + issueCount: number; + trends: DigestTrend[]; +}> { + const selectedSearches = searches.slice(0, MAX_SEARCHES_PER_DIGEST); + const updatedAfter = weekStart.toISOString().slice(0, 10); + const weekEnd = new Date(weekStart); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 6); + const updatedBefore = weekEnd.toISOString().slice(0, 10); + const results = await Promise.all( + selectedSearches.map(async (search) => ({ + search, + response: await searchGitHubIssues({ + tech: search.tech, + label: search.label, + sort: search.sort, + linkedPr: search.linkedPr, + hacktoberfest: search.hacktoberfest, + updatedAfter, + updatedBefore, + }), + })), + ); + const uniqueIssues = new Map(); + + for (const result of results) { + for (const issue of result.response.issues.slice(0, MAX_ISSUES_PER_SEARCH)) { + const current = uniqueIssues.get(issue.id); + if (!current || current.qualityScore < issue.qualityScore) { + uniqueIssues.set(issue.id, issue); + } + } + } + + const issues = [...uniqueIssues.values()] + .sort((left, right) => right.qualityScore - left.qualityScore) + .slice(0, MAX_ISSUES_PER_DIGEST); + const repositoryCounts = new Map(); + + for (const issue of issues) { + repositoryCounts.set(issue.repo, (repositoryCounts.get(issue.repo) ?? 0) + 1); + } + + const repositories = [...repositoryCounts.entries()] + .sort((left, right) => right[1] - left[1]) + .slice(0, 5); + const trends = results.map(({ search, response }) => { + const weeklyRepositoryCounts = new Map(); + + for (const issue of response.issues) { + weeklyRepositoryCounts.set( + issue.repo, + (weeklyRepositoryCounts.get(issue.repo) ?? 0) + 1, + ); + } + + const [topRepository, topRepositoryIssueCount] = [ + ...weeklyRepositoryCounts.entries(), + ].sort((left, right) => right[1] - left[1])[0] ?? [null, 0]; + + return { + searchKey: getDigestSearchKey(search), + weekStart, + issueCount: response.totalCount, + topRepository, + topRepositoryIssueCount, + } satisfies DigestTrend; + }); + const issueItems = issues.length + ? issues + .map( + (issue) => + `
  • ${escapeHtml(issue.title)} in ${escapeHtml(issue.repo)}
  • `, + ) + .join("") + : "
  • No new matching issues this week.
  • "; + const repositoryItems = repositories.length + ? repositories + .map(([repository, count]) => `
  • ${escapeHtml(repository)} (${count})
  • `) + .join("") + : "
  • No repository trend yet.
  • "; + const trendItems = selectedSearches + .map((search, index) => { + const trend = trends[index]; + const previous = previousTrends.get(trend.searchKey); + const change = previous ? trend.issueCount - previous.issueCount : null; + const comparison = describeTrend(change); + const repository = trend.topRepository + ? ` Leading recommendation source: ${escapeHtml(trend.topRepository)} (${trend.topRepositoryIssueCount}).` + : ""; + + return `
  • ${escapeHtml(search.name)} — ${trend.issueCount} active opportunities; ${comparison}.${repository}
  • `; + }) + .join(""); + + return { + subject: `${issues.length} open-source opportunities for you this week`, + issueCount: issues.length, + trends, + html: `

    Your weekly OpenIssue.dev digest

    Top issues

      ${issueItems}

    Repositories appearing most often

      ${repositoryItems}

    GitHub activity trends

      ${trendItems}

    Manage or disable your weekly digest

    `, + }; +} + +export async function sendWeeklyDigest({ + to, + subject, + html, +}: { + to: string; + subject: string; + html: string; +}) { + const user = process.env.SMTP_USER; + const password = process.env.SMTP_APP_PASSWORD; + const from = process.env.DIGEST_FROM_EMAIL; + + if (!user || !password || !from) { + throw new Error("Weekly digest email is not configured."); + } + + const transport = nodemailer.createTransport({ + host: "smtp.gmail.com", + port: 465, + secure: true, + auth: { + user, + pass: password, + }, + }); + + await transport.sendMail({ from, to, subject, html }); +} diff --git a/src/features/issues/types/search.ts b/src/features/issues/types/search.ts index 5c9d8a4..00b102e 100644 --- a/src/features/issues/types/search.ts +++ b/src/features/issues/types/search.ts @@ -44,6 +44,7 @@ export type GitHubIssue = { number: number; html_url: string; title: string; + body?: string | null; comments: number; updated_at: string; created_at: string; @@ -69,6 +70,25 @@ export type GitHubRepo = { stargazers_count: number; archived: boolean; topics?: string[]; + description?: string | null; +}; + +export type RepositorySuggestion = { + fullName: string; + url: string; + description: string | null; + stars: number; +}; + +export type RepositoryDigestIssue = { + id: string; + title: string; + url: string; + summary: string; + labels: string[]; + createdAt: string; + comments: number; + assigned: boolean; }; export type GitHubTimelineEvent = { diff --git a/src/lib/auth-schema.ts b/src/lib/auth-schema.ts index 667689f..a8dd755 100644 --- a/src/lib/auth-schema.ts +++ b/src/lib/auth-schema.ts @@ -11,10 +11,17 @@ export const user = sqliteTable("user", { id: text("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull().unique(), + alertEmail: text("alert_email"), emailVerified: integer("email_verified", { mode: "boolean" }) .default(false) .notNull(), image: text("image"), + weeklyDigestEnabled: integer("weekly_digest_enabled", { mode: "boolean" }) + .default(false) + .notNull(), + weeklyDigestLastSentAt: integer("weekly_digest_last_sent_at", { + mode: "timestamp_ms", + }), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), @@ -118,6 +125,82 @@ export const savedSearch = sqliteTable( (table) => [index("saved_search_userId_idx").on(table.userId)], ); +export const digestTrendSnapshot = sqliteTable( + "digest_trend_snapshot", + { + id: text("id").primaryKey(), + searchKey: text("search_key").notNull(), + weekStart: integer("week_start", { mode: "timestamp_ms" }).notNull(), + issueCount: integer("issue_count").notNull(), + topRepository: text("top_repository"), + topRepositoryIssueCount: integer("top_repository_issue_count") + .default(0) + .notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [ + uniqueIndex("digest_trend_snapshot_search_week_uidx").on( + table.searchKey, + table.weekStart, + ), + ], +); + +export const repositoryDigestTemplate = sqliteTable( + "repository_digest_template", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + name: text("name").default("Repository alerts").notNull(), + enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(), + frequency: text("frequency", { + enum: ["daily", "weekly", "fortnightly"], + }) + .default("weekly") + .notNull(), + lastSentAt: integer("last_sent_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + uniqueIndex("repository_digest_template_user_id_uidx").on(table.userId), + ], +); + +export const repositoryDigestRepository = sqliteTable( + "repository_digest_repository", + { + id: text("id").primaryKey(), + templateId: text("template_id") + .notNull() + .references(() => repositoryDigestTemplate.id, { onDelete: "cascade" }), + repositoryFullName: text("repository_full_name").notNull(), + repositoryUrl: text("repository_url").notNull(), + position: integer("position").notNull(), + lastIssueIds: text("last_issue_ids").default("[]").notNull(), + }, + (table) => [ + uniqueIndex("repository_digest_repository_template_repo_uidx").on( + table.templateId, + table.repositoryFullName, + ), + index("repository_digest_repository_template_position_idx").on( + table.templateId, + table.position, + ), + ], +); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/tests/app/api/cron/weekly-digest/route.test.ts b/tests/app/api/cron/weekly-digest/route.test.ts new file mode 100644 index 0000000..4d42f29 --- /dev/null +++ b/tests/app/api/cron/weekly-digest/route.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + select, + from, + insert, + values, + onConflictDoNothing, + update, + set, + updateWhere, + buildWeeklyDigest, + sendWeeklyDigest, + getDigestContext, + deliverWeeklyDigest, + getRepositoryAlertSchedule, + isRepositoryAlertDue, + userRows, +} = vi.hoisted(() => ({ + select: vi.fn(), + from: vi.fn(), + insert: vi.fn(), + values: vi.fn(), + onConflictDoNothing: vi.fn(), + update: vi.fn(), + set: vi.fn(), + updateWhere: vi.fn(), + buildWeeklyDigest: vi.fn(), + sendWeeklyDigest: vi.fn(), + getDigestContext: vi.fn(), + deliverWeeklyDigest: vi.fn(), + getRepositoryAlertSchedule: vi.fn(), + isRepositoryAlertDue: vi.fn(), + userRows: { + value: [{ id: "user-1", email: "user@example.com" }] as Array<{ + id: string; + email: string; + weeklyDigestLastSentAt?: Date; + }>, + }, +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", () => ({ + getDatabase: () => ({ select, insert, update }), +})); +vi.mock("@/features/issues/server/weekly-digest", () => ({ + buildWeeklyDigest, + getWeekStart: () => new Date("2026-08-24T00:00:00.000Z"), + sendWeeklyDigest, +})); +vi.mock("@/features/issues/server/digest-delivery", () => ({ + getDigestContext, + deliverWeeklyDigest, + getRepositoryAlertSchedule, + isRepositoryAlertDue, +})); + +import { GET } from "@/app/api/cron/weekly-digest/route"; +import { digestTrendSnapshot, savedSearch, user } from "@/lib/auth-schema"; + +const search = { + id: "saved-1", + userId: "user-1", + name: "React docs", + tech: "React", + label: "documentation", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: new Date("2026-08-20T00:00:00.000Z"), +}; + +describe("weekly digest cron", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-24T09:00:00.000Z")); + vi.stubEnv("CRON_SECRET", "cron-secret"); + select.mockReturnValue({ from }); + from.mockImplementation((table) => { + const result = + table === digestTrendSnapshot + ? [] + : table === user + ? userRows.value + : table === savedSearch + ? [search] + : []; + const where = vi.fn().mockResolvedValue(result); + return { + where, + leftJoin: vi.fn().mockReturnValue({ where }), + }; + }); + insert.mockReturnValue({ values }); + values.mockReturnValue({ onConflictDoNothing }); + onConflictDoNothing.mockResolvedValue(undefined); + update.mockReturnValue({ set }); + set.mockReturnValue({ where: updateWhere }); + updateWhere.mockResolvedValue(undefined); + buildWeeklyDigest.mockResolvedValue({ + subject: "Weekly digest", + html: "

    Digest

    ", + issueCount: 1, + trends: [ + { + searchKey: "search-key", + weekStart: new Date("2026-08-17T00:00:00.000Z"), + issueCount: 12, + topRepository: "acme/repo", + topRepositoryIssueCount: 3, + }, + ], + }); + sendWeeklyDigest.mockResolvedValue(undefined); + getDigestContext.mockResolvedValue({ + weekStart: new Date("2026-08-17T00:00:00.000Z"), + previousTrends: new Map(), + }); + deliverWeeklyDigest.mockResolvedValue(true); + getRepositoryAlertSchedule.mockResolvedValue({ + enabled: true, + frequency: "daily", + lastSentAt: null, + }); + isRepositoryAlertDue.mockReturnValue(true); + userRows.value = [{ id: "user-1", email: "user@example.com" }]; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("requires the cron bearer token", async () => { + const response = await GET(new Request("http://localhost/api/cron/weekly-digest")); + + expect(response.status).toBe(401); + expect(select).not.toHaveBeenCalled(); + }); + + it("stores GitHub trends and sends the digest", async () => { + const response = await GET( + new Request("http://localhost/api/cron/weekly-digest", { + headers: { Authorization: "Bearer cron-secret" }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + recipients: 1, + sent: 1, + failed: 0, + }); + expect(deliverWeeklyDigest).toHaveBeenCalledWith( + expect.anything(), + { id: "user-1", email: "user@example.com" }, + expect.objectContaining({ previousTrends: expect.any(Map) }), + "https://openissue-dev.vercel.app", + { + includeSavedSearches: true, + includeRepositoryAlerts: true, + }, + ); + }); + + it("counts delivery failures without failing the cron response", async () => { + deliverWeeklyDigest.mockRejectedValueOnce(new Error("SMTP unavailable")); + const response = await GET( + new Request("http://localhost/api/cron/weekly-digest", { + headers: { Authorization: "Bearer cron-secret" }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + recipients: 1, + sent: 0, + failed: 1, + }); + }); + + it("skips recipients when neither alert schedule is due", async () => { + vi.setSystemTime(new Date("2026-08-25T09:00:00.000Z")); + getRepositoryAlertSchedule.mockResolvedValueOnce(null); + const response = await GET( + new Request("http://localhost/api/cron/weekly-digest", { + headers: { Authorization: "Bearer cron-secret" }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + recipients: 1, + sent: 0, + failed: 0, + }); + expect(deliverWeeklyDigest).not.toHaveBeenCalled(); + expect(isRepositoryAlertDue).not.toHaveBeenCalled(); + }); + + it("skips a recent Monday digest when repository alerts are disabled", async () => { + userRows.value = [ + { + id: "user-1", + email: "user@example.com", + weeklyDigestLastSentAt: new Date("2026-08-24T08:00:00.000Z"), + }, + ]; + getRepositoryAlertSchedule.mockResolvedValueOnce({ + enabled: false, + frequency: "weekly", + lastSentAt: null, + }); + + await GET( + new Request("http://localhost/api/cron/weekly-digest", { + headers: { Authorization: "Bearer cron-secret" }, + }), + ); + expect(deliverWeeklyDigest).not.toHaveBeenCalled(); + }); + + it("does not count an eligible delivery with no changed content", async () => { + deliverWeeklyDigest.mockResolvedValueOnce(false); + const response = await GET( + new Request("http://localhost/api/cron/weekly-digest", { + headers: { Authorization: "Bearer cron-secret" }, + }), + ); + await expect(response.json()).resolves.toMatchObject({ sent: 0, failed: 0 }); + }); +}); diff --git a/tests/app/api/digest-preference/route.test.ts b/tests/app/api/digest-preference/route.test.ts new file mode 100644 index 0000000..069d11c --- /dev/null +++ b/tests/app/api/digest-preference/route.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSession, select, from, where, limit, update, set, updateWhere } = + vi.hoisted(() => ({ + getSession: vi.fn(), + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + update: vi.fn(), + set: vi.fn(), + updateWhere: vi.fn(), + })); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/lib/db", () => ({ + getDatabase: () => ({ select, update }), +})); + +import { GET, PATCH } from "@/app/api/digest-preference/route"; + +describe("digest preference API", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + select.mockReturnValue({ from }); + from.mockReturnValue({ where }); + where.mockReturnValue({ limit }); + limit.mockResolvedValue([{ enabled: true, alertEmail: null }]); + update.mockReturnValue({ set }); + set.mockReturnValue({ where: updateWhere }); + updateWhere.mockResolvedValue(undefined); + }); + + it("requires authentication", async () => { + getSession.mockResolvedValue(null); + + expect((await GET(new Request("http://localhost"))).status).toBe(401); + expect( + ( + await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ enabled: true }), + }), + ) + ).status, + ).toBe(401); + }); + + it("loads and updates the preference", async () => { + const getResponse = await GET(new Request("http://localhost")); + const patchResponse = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ enabled: false }), + }), + ); + + await expect(getResponse.json()).resolves.toEqual({ + enabled: true, + alertEmail: null, + }); + await expect(patchResponse.json()).resolves.toEqual({ enabled: false }); + expect(set).toHaveBeenCalledWith({ + weeklyDigestEnabled: false, + weeklyDigestLastSentAt: null, + }); + }); + + it("stores and clears an alternate alert email", async () => { + const saveResponse = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ alertEmail: " Alerts@Example.com " }), + }), + ); + await expect(saveResponse.json()).resolves.toEqual({ + alertEmail: "alerts@example.com", + }); + expect(set).toHaveBeenLastCalledWith({ alertEmail: "alerts@example.com" }); + + await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ alertEmail: "" }), + }), + ); + expect(set).toHaveBeenLastCalledWith({ alertEmail: null }); + }); + + it("rejects invalid payloads", async () => { + const invalidJson = await PATCH( + new Request("http://localhost", { method: "PATCH", body: "invalid" }), + ); + const invalidPreference = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ enabled: "yes" }), + }), + ); + const invalidEmail = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ alertEmail: "not-an-email" }), + }), + ); + const nonStringEmail = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ alertEmail: 42 }), + }), + ); + const emptyPreference = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({}), + }), + ); + + expect(invalidJson.status).toBe(400); + expect(invalidPreference.status).toBe(400); + expect(invalidEmail.status).toBe(400); + expect(nonStringEmail.status).toBe(400); + expect(emptyPreference.status).toBe(400); + expect(update).not.toHaveBeenCalled(); + }); + + it.each([ + `${"a".repeat(245)}@example.com`, + "alerts @example.com", + "alerts@@example.com", + "alerts@example", + "alerts@example.", + ])("rejects structurally invalid alert email %s", async (alertEmail) => { + const response = await PATCH( + new Request("http://localhost", { + method: "PATCH", + body: JSON.stringify({ alertEmail }), + }), + ); + expect(response.status).toBe(400); + }); + + it("returns defaults when the account preference row is unavailable", async () => { + limit.mockResolvedValueOnce([]); + await expect((await GET(new Request("http://localhost"))).json()).resolves.toEqual({ + enabled: false, + alertEmail: null, + }); + }); +}); diff --git a/tests/app/api/digest-trigger/route.test.ts b/tests/app/api/digest-trigger/route.test.ts new file mode 100644 index 0000000..f2b7579 --- /dev/null +++ b/tests/app/api/digest-trigger/route.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getSession, + select, + from, + where, + limit, + getDigestContext, + deliverWeeklyDigest, +} = vi.hoisted(() => ({ + getSession: vi.fn(), + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + getDigestContext: vi.fn(), + deliverWeeklyDigest: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/lib/db", () => ({ + getDatabase: () => ({ select }), +})); +vi.mock("@/features/issues/server/digest-delivery", () => ({ + getDigestContext, + deliverWeeklyDigest, +})); + +import { POST } from "@/app/api/digest-trigger/route"; + +describe("manual digest trigger", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + select.mockReturnValue({ from }); + from.mockReturnValue({ where }); + where.mockReturnValue({ limit }); + limit.mockResolvedValue([ + { id: "user-1", email: "user@example.com", lastSentAt: null }, + ]); + getDigestContext.mockResolvedValue({ + weekStart: new Date("2026-08-17T00:00:00.000Z"), + previousTrends: new Map(), + }); + deliverWeeklyDigest.mockResolvedValue(true); + }); + + it("requires authentication", async () => { + getSession.mockResolvedValue(null); + + const response = await POST( + new Request("http://localhost/api/digest-trigger", { method: "POST" }), + ); + + expect(response.status).toBe(401); + expect(select).not.toHaveBeenCalled(); + }); + + it("sends the signed-in user's digest", async () => { + const response = await POST( + new Request("http://localhost/api/digest-trigger", { method: "POST" }), + ); + + await expect(response.json()).resolves.toEqual({ sent: true }); + expect(deliverWeeklyDigest).toHaveBeenCalledWith( + expect.anything(), + { id: "user-1", email: "user@example.com", lastSentAt: null }, + expect.objectContaining({ previousTrends: expect.any(Map) }), + "https://openissue-dev.vercel.app", + ); + }); + + it("enforces the delivery window and saved-search requirement", async () => { + limit.mockResolvedValueOnce([ + { id: "user-1", email: "user@example.com", lastSentAt: new Date() }, + ]); + const limited = await POST( + new Request("http://localhost/api/digest-trigger", { method: "POST" }), + ); + limit.mockResolvedValueOnce([ + { id: "user-1", email: "user@example.com", lastSentAt: null }, + ]); + deliverWeeklyDigest.mockResolvedValueOnce(false); + const noSearches = await POST( + new Request("http://localhost/api/digest-trigger", { method: "POST" }), + ); + + expect(limited.status).toBe(429); + expect(noSearches.status).toBe(400); + }); + + it("handles missing accounts and delivery failures", async () => { + limit.mockResolvedValueOnce([]); + const missing = await POST( + new Request("http://localhost/api/digest-trigger", { method: "POST" }), + ); + expect(missing.status).toBe(404); + + limit.mockResolvedValueOnce([ + { id: "user-1", email: "user@example.com", lastSentAt: null }, + ]); + deliverWeeklyDigest.mockRejectedValueOnce(new Error("SMTP unavailable")); + const failed = await POST( + new Request("http://localhost/api/digest-trigger", { method: "POST" }), + ); + expect(failed.status).toBe(502); + }); +}); diff --git a/tests/app/api/repositories/route.test.ts b/tests/app/api/repositories/route.test.ts new file mode 100644 index 0000000..8e4c4e7 --- /dev/null +++ b/tests/app/api/repositories/route.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSession, searchGitHubRepositories } = vi.hoisted(() => ({ + getSession: vi.fn(), + searchGitHubRepositories: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/features/issues/server/github-search", () => ({ + searchGitHubRepositories, +})); + +import { GET } from "@/app/api/repositories/route"; + +describe("repository autocomplete API", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("requires authentication", async () => { + getSession.mockResolvedValue(null); + expect((await GET(new Request("http://localhost?query=react"))).status).toBe(401); + }); + + it("validates the query", async () => { + expect((await GET(new Request("http://localhost"))).status).toBe(400); + expect((await GET(new Request("http://localhost?query=r"))).status).toBe(400); + expect( + ( + await GET( + new Request(`http://localhost?query=${"r".repeat(101)}`), + ) + ).status, + ).toBe(400); + expect(searchGitHubRepositories).not.toHaveBeenCalled(); + }); + + it("returns matching repositories", async () => { + const repositories = [{ fullName: "facebook/react" }]; + searchGitHubRepositories.mockResolvedValue(repositories); + const response = await GET(new Request("http://localhost?query=react")); + await expect(response.json()).resolves.toEqual({ repositories }); + }); + + it("converts GitHub failures to a gateway error", async () => { + searchGitHubRepositories.mockRejectedValue(new Error("GitHub unavailable")); + const response = await GET(new Request("http://localhost?query=react")); + expect(response.status).toBe(502); + }); +}); diff --git a/tests/app/api/repository-digest-template/route.test.ts b/tests/app/api/repository-digest-template/route.test.ts new file mode 100644 index 0000000..ec66f9b --- /dev/null +++ b/tests/app/api/repository-digest-template/route.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSession, database } = vi.hoisted(() => ({ + getSession: vi.fn(), + database: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/lib/db", () => ({ getDatabase: () => database })); + +import { GET, PUT } from "@/app/api/repository-digest-template/route"; + +function limitedResult(result: unknown[]) { + return { from: () => ({ where: () => ({ limit: async () => result }) }) }; +} + +function orderedResult(result: unknown[]) { + return { from: () => ({ where: () => ({ orderBy: async () => result }) }) }; +} + +function directResult(result: unknown[]) { + return { from: () => ({ where: async () => result }) }; +} + +describe("repository digest template API", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("requires authentication", async () => { + getSession.mockResolvedValue(null); + expect((await GET(new Request("http://localhost"))).status).toBe(401); + expect( + ( + await PUT( + new Request("http://localhost", { method: "PUT", body: "{}" }), + ) + ).status, + ).toBe(401); + }); + + it("returns a saved template and its ordered repositories", async () => { + database.select + .mockReturnValueOnce( + limitedResult([ + { + id: "template-1", + name: "My alerts", + enabled: true, + frequency: "daily", + }, + ]), + ) + .mockReturnValueOnce( + orderedResult([{ fullName: "acme/repo", url: "https://github.com/acme/repo" }]), + ); + + const response = await GET(new Request("http://localhost")); + await expect(response.json()).resolves.toEqual({ + template: { + name: "My alerts", + enabled: true, + frequency: "daily", + repositories: [ + { fullName: "acme/repo", url: "https://github.com/acme/repo" }, + ], + }, + }); + }); + + it("returns null when no template exists", async () => { + database.select.mockReturnValueOnce(limitedResult([])); + await expect((await GET(new Request("http://localhost"))).json()).resolves.toEqual({ + template: null, + }); + }); + + it("rejects malformed and duplicate repository selections", async () => { + const invalidJson = await PUT( + new Request("http://localhost", { method: "PUT", body: "invalid" }), + ); + const invalidTemplate = await PUT( + new Request("http://localhost", { + method: "PUT", + body: JSON.stringify({ + name: "Alerts", + enabled: true, + frequency: "hourly", + repositories: [], + }), + }), + ); + const duplicate = { + fullName: "acme/repo", + url: "https://github.com/acme/repo", + }; + const duplicateTemplate = await PUT( + new Request("http://localhost", { + method: "PUT", + body: JSON.stringify({ + name: "Alerts", + enabled: true, + frequency: "weekly", + repositories: [duplicate, duplicate], + }), + }), + ); + + expect(invalidJson.status).toBe(400); + expect(invalidTemplate.status).toBe(400); + expect(duplicateTemplate.status).toBe(400); + }); + + it.each([ + { name: "", enabled: true, frequency: "weekly", repositories: [] }, + { name: "Alerts", enabled: "yes", frequency: "weekly", repositories: [] }, + { + name: "Alerts", + enabled: true, + frequency: "weekly", + repositories: [{ fullName: "invalid", url: "https://github.com/invalid" }], + }, + { + name: "Alerts", + enabled: true, + frequency: "weekly", + repositories: [ + { fullName: "acme/repo", url: "https://example.com/acme/repo" }, + ], + }, + { name: 42, enabled: true, frequency: "weekly", repositories: [] }, + { name: "Alerts", enabled: true, frequency: "weekly", repositories: [42] }, + ])("rejects unsafe template fields", async (body) => { + const response = await PUT( + new Request("http://localhost", { + method: "PUT", + body: JSON.stringify(body), + }), + ); + expect(response.status).toBe(400); + }); + + it("creates an empty disabled template", async () => { + database.select + .mockReturnValueOnce(limitedResult([])) + .mockReturnValueOnce( + limitedResult([ + { + id: "new-template", + name: "Later", + enabled: false, + frequency: "fortnightly", + }, + ]), + ) + .mockReturnValueOnce(orderedResult([])); + database.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }), + }); + database.delete.mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }); + + const response = await PUT( + new Request("http://localhost", { + method: "PUT", + body: JSON.stringify({ + name: "Later", + enabled: false, + frequency: "fortnightly", + repositories: [], + }), + }), + ); + expect(response.status).toBe(200); + expect(database.insert).toHaveBeenCalledOnce(); + }); + + it("updates a template while preserving issue snapshots", async () => { + database.select + .mockReturnValueOnce(limitedResult([{ id: "template-1" }])) + .mockReturnValueOnce( + directResult([{ fullName: "acme/repo", lastIssueIds: '["issue-1"]' }]), + ) + .mockReturnValueOnce( + limitedResult([ + { id: "template-1", name: "Alerts", enabled: true, frequency: "weekly" }, + ]), + ) + .mockReturnValueOnce( + orderedResult([{ fullName: "acme/repo", url: "https://github.com/acme/repo" }]), + ); + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + database.insert + .mockReturnValueOnce({ values }) + .mockReturnValueOnce({ values: vi.fn().mockResolvedValue(undefined) }); + database.delete.mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }); + + const response = await PUT( + new Request("http://localhost", { + method: "PUT", + body: JSON.stringify({ + name: "Alerts", + enabled: true, + frequency: "weekly", + repositories: [ + { fullName: "acme/repo", url: "https://github.com/acme/repo" }, + { fullName: "acme/new", url: "https://github.com/acme/new" }, + ], + }), + }), + ); + + expect(response.status).toBe(200); + expect(database.insert).toHaveBeenCalledTimes(2); + expect(database.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/issues/components/issue-finder.test.tsx b/tests/features/issues/components/issue-finder.test.tsx index 6457eec..aa101ab 100644 --- a/tests/features/issues/components/issue-finder.test.tsx +++ b/tests/features/issues/components/issue-finder.test.tsx @@ -11,6 +11,11 @@ const { replaceSavedSearches, syncSavedSearches, deleteCloudSavedSearch, + getDigestPreference, + getAlertEmail, + triggerWeeklyDigest, + updateDigestPreference, + updateAlertEmail, useSession, } = vi.hoisted(() => ({ addSavedSearch: vi.fn(), @@ -19,6 +24,11 @@ const { replaceSavedSearches: vi.fn(), syncSavedSearches: vi.fn(), deleteCloudSavedSearch: vi.fn(), + getDigestPreference: vi.fn(), + getAlertEmail: vi.fn(), + triggerWeeklyDigest: vi.fn(), + updateDigestPreference: vi.fn(), + updateAlertEmail: vi.fn(), useSession: vi.fn(), })); @@ -34,6 +44,14 @@ vi.mock("@/features/issues/lib/saved-search-cloud", () => ({ deleteCloudSavedSearch, })); +vi.mock("@/features/issues/lib/digest-preference-cloud", () => ({ + getDigestPreference, + getAlertEmail, + triggerWeeklyDigest, + updateDigestPreference, + updateAlertEmail, +})); + vi.mock("@/lib/auth-client", () => ({ authClient: { useSession, @@ -103,6 +121,11 @@ beforeEach(() => { replaceSavedSearches.mockReset(); syncSavedSearches.mockReset().mockResolvedValue([]); deleteCloudSavedSearch.mockReset().mockResolvedValue(undefined); + getDigestPreference.mockReset().mockResolvedValue(false); + getAlertEmail.mockReset().mockResolvedValue(""); + triggerWeeklyDigest.mockReset().mockResolvedValue(undefined); + updateDigestPreference.mockReset().mockResolvedValue(true); + updateAlertEmail.mockReset().mockResolvedValue(""); useSession.mockReset().mockReturnValue({ data: null, isPending: false }); vi.stubGlobal("fetch", vi.fn()); }); @@ -137,6 +160,82 @@ describe("IssueFinder", () => { expect(replaceSavedSearches).toHaveBeenCalledWith([saved]); }); + it("loads and updates the weekly digest preference", async () => { + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + getDigestPreference.mockResolvedValue(false); + + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Enable weekly digest" }), + ); + + await waitFor(() => expect(updateDigestPreference).toHaveBeenCalledWith(true)); + expect(screen.getByRole("button", { name: "Disable weekly digest" })).toBeTruthy(); + }); + + it("loads, saves, and clears the alternate alert email", async () => { + useSession.mockReturnValue({ + data: { + user: { + id: "user-1", + name: "Octo Cat", + email: "github@example.com", + }, + }, + isPending: false, + }); + getAlertEmail.mockResolvedValue("alerts@example.com"); + updateAlertEmail + .mockResolvedValueOnce("next@example.com") + .mockResolvedValueOnce(""); + + render(); + const input = await screen.findByLabelText("Alternate alert email"); + expect((input as HTMLInputElement).value).toBe("alerts@example.com"); + + fireEvent.change(input, { target: { value: "next@example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Save alert email" })); + expect( + await screen.findByText("Alerts will be sent to next@example.com."), + ).toBeTruthy(); + + fireEvent.change(input, { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "Save alert email" })); + expect( + await screen.findByText("Alerts will use your GitHub-linked email."), + ).toBeTruthy(); + }); + + it("manually sends a digest for an authenticated saved search", async () => { + const saved = { + id: "saved-1", + name: "React docs", + tech: "React", + label: "documentation", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-19T00:00:00.000Z", + }; + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + getSavedSearches.mockReturnValue([saved]); + syncSavedSearches.mockResolvedValue([saved]); + + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Send digest now" }), + ); + + await waitFor(() => expect(triggerWeeklyDigest).toHaveBeenCalledOnce()); + expect(await screen.findByText("Weekly digest sent. Check your inbox.")).toBeTruthy(); + }); + it("validates and manages saved searches", async () => { const saved = { id: "saved-1", diff --git a/tests/features/issues/components/repository-digest-card.test.tsx b/tests/features/issues/components/repository-digest-card.test.tsx new file mode 100644 index 0000000..3e84133 --- /dev/null +++ b/tests/features/issues/components/repository-digest-card.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getRepositoryDigestTemplate, + saveRepositoryDigestTemplate, + searchRepositories, +} = vi.hoisted(() => ({ + getRepositoryDigestTemplate: vi.fn(), + saveRepositoryDigestTemplate: vi.fn(), + searchRepositories: vi.fn(), +})); +const selectControl = vi.hoisted(() => ({ + onValueChange: null as null | ((value: "daily") => void), +})); + +vi.mock("@/features/issues/lib/repository-digest-cloud", () => ({ + getRepositoryDigestTemplate, + saveRepositoryDigestTemplate, + searchRepositories, +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ + children, + onValueChange, + }: { + children: React.ReactNode; + onValueChange: (value: "daily") => void; + }) => { + selectControl.onValueChange = onValueChange; + return
    {children}
    ; + }, + SelectTrigger: ({ children, ...props }: React.ComponentProps<"button">) => ( + + ), + SelectValue: () => Weekly, + SelectContent: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + SelectItem: ({ children }: { children: React.ReactNode }) => {children}, +})); + +import { RepositoryDigestCard } from "@/features/issues/components/repository-digest-card"; + +describe("RepositoryDigestCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRepositoryDigestTemplate.mockResolvedValue(null); + searchRepositories.mockResolvedValue([ + { + fullName: "acme/repo", + url: "https://github.com/acme/repo", + description: "Useful repository", + stars: 100, + }, + ]); + saveRepositoryDigestTemplate.mockImplementation(async (template) => template); + }); + + afterEach(() => cleanup()); + + it("loads, autocompletes, edits, and saves a template", async () => { + render(); + await waitFor(() => expect(getRepositoryDigestTemplate).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText("Search GitHub repositories"), { + target: { value: "acme" }, + }); + fireEvent.change(screen.getByLabelText("Repository alert template name"), { + target: { value: "Custom alerts" }, + }); + act(() => selectControl.onValueChange?.("daily")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + fireEvent.click(await screen.findByText("acme/repo")); + expect(screen.getByRole("link", { name: "acme/repo" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Disable repository alerts" })); + fireEvent.click(screen.getByRole("button", { name: "Save template" })); + await waitFor(() => + expect(saveRepositoryDigestTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + name: "Custom alerts", + frequency: "daily", + repositories: [ + { fullName: "acme/repo", url: "https://github.com/acme/repo" }, + ], + }), + ), + ); + expect(screen.getByText("Repository alert template saved.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Remove acme/repo" })); + expect(screen.queryByRole("link", { name: "acme/repo" })).toBeNull(); + }); + + it("restores a saved template and reports load failures", async () => { + getRepositoryDigestTemplate.mockResolvedValueOnce({ + name: "Daily alerts", + enabled: true, + frequency: "daily", + repositories: [], + }); + const { unmount } = render(); + expect(await screen.findByDisplayValue("Daily alerts")).toBeTruthy(); + unmount(); + + getRepositoryDigestTemplate.mockRejectedValueOnce(new Error("offline")); + render(); + expect(await screen.findByText("Unable to load repository alerts.")).toBeTruthy(); + }); + + it("reports save failures", async () => { + saveRepositoryDigestTemplate.mockRejectedValue(new Error("Save failed.")); + render(); + fireEvent.click(screen.getByRole("button", { name: "Save template" })); + expect(await screen.findByText("Save failed.")).toBeTruthy(); + }); + + it("handles repository search failures", async () => { + searchRepositories.mockRejectedValueOnce(new Error("offline")); + render(); + fireEvent.change(screen.getByLabelText("Search GitHub repositories"), { + target: { value: "missing" }, + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + expect(searchRepositories).toHaveBeenCalledWith("missing"); + expect(screen.queryByText("acme/repo")).toBeNull(); + }); + + it("enforces the five-repository limit", async () => { + const repositories = Array.from({ length: 5 }, (_, index) => ({ + fullName: `acme/repo-${index}`, + url: `https://github.com/acme/repo-${index}`, + })); + getRepositoryDigestTemplate.mockResolvedValueOnce({ + name: "Full", + enabled: true, + frequency: "weekly", + repositories, + }); + render(); + const search = await screen.findByLabelText("Search GitHub repositories"); + await waitFor(() => expect(search).toHaveProperty("disabled", true)); + }); +}); diff --git a/tests/features/issues/lib/digest-preference-cloud.test.ts b/tests/features/issues/lib/digest-preference-cloud.test.ts new file mode 100644 index 0000000..e616938 --- /dev/null +++ b/tests/features/issues/lib/digest-preference-cloud.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getDigestPreference, + getAlertEmail, + triggerWeeklyDigest, + updateDigestPreference, + updateAlertEmail, +} from "@/features/issues/lib/digest-preference-cloud"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("digest preference cloud client", () => { + it("loads and updates the preference", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ enabled: true })) + .mockResolvedValueOnce(Response.json({ enabled: false })); + + await expect(getDigestPreference()).resolves.toBe(true); + await expect(updateDigestPreference(false)).resolves.toBe(false); + expect(fetchMock).toHaveBeenLastCalledWith("/api/digest-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + }); + + it("loads and updates the alternate alert email", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ alertEmail: "alerts@example.com" })) + .mockResolvedValueOnce(Response.json({ alertEmail: "next@example.com" })); + + await expect(getAlertEmail()).resolves.toBe("alerts@example.com"); + await expect(updateAlertEmail("next@example.com")).resolves.toBe( + "next@example.com", + ); + expect(fetchMock).toHaveBeenLastCalledWith("/api/digest-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alertEmail: "next@example.com" }), + }); + }); + + it("reports request failures", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 500 }), + ); + + await expect(getDigestPreference()).rejects.toThrow("Unable to load"); + await expect(updateDigestPreference(true)).rejects.toThrow("Unable to update"); + }); + + it("reports alternate-email API failures", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ error: "Email rejected." }, { status: 400 }), + ); + await expect(updateAlertEmail("bad@example.com")).rejects.toThrow( + "Email rejected.", + ); + }); + + it("uses the alternate-email fallback error", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({}, { status: 500 }), + ); + await expect(updateAlertEmail("alerts@example.com")).rejects.toThrow( + "Unable to update the alert email.", + ); + }); + + it("triggers a manual digest", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ sent: true })); + + await triggerWeeklyDigest(); + + expect(fetchMock).toHaveBeenCalledWith("/api/digest-trigger", { + method: "POST", + }); + }); + + it.each([ + [{ error: "Digest unavailable." }, "Digest unavailable."], + [{}, "Unable to send the weekly digest."], + ])("reports manual digest errors", async (payload, message) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json(payload, { status: 502 }), + ); + await expect(triggerWeeklyDigest()).rejects.toThrow(message); + }); +}); diff --git a/tests/features/issues/lib/repository-digest-cloud.test.ts b/tests/features/issues/lib/repository-digest-cloud.test.ts new file mode 100644 index 0000000..bcf46ab --- /dev/null +++ b/tests/features/issues/lib/repository-digest-cloud.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getRepositoryDigestTemplate, + saveRepositoryDigestTemplate, + searchRepositories, +} from "@/features/issues/lib/repository-digest-cloud"; + +afterEach(() => vi.restoreAllMocks()); + +describe("repository digest cloud client", () => { + const template = { + name: "Alerts", + enabled: true, + frequency: "weekly" as const, + repositories: [ + { fullName: "acme/repo", url: "https://github.com/acme/repo" }, + ], + }; + + it("loads and saves a template", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ template })) + .mockResolvedValueOnce(Response.json({ template })); + + await expect(getRepositoryDigestTemplate()).resolves.toEqual(template); + await expect(saveRepositoryDigestTemplate(template)).resolves.toEqual(template); + expect(fetchMock).toHaveBeenLastCalledWith( + "/api/repository-digest-template", + expect.objectContaining({ method: "PUT", body: JSON.stringify(template) }), + ); + }); + + it("searches repositories with encoded query parameters", async () => { + const repositories = [{ fullName: "acme/repo" }]; + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ repositories })); + + await expect(searchRepositories("react tools")).resolves.toEqual(repositories); + expect(fetchMock).toHaveBeenCalledWith( + "/api/repositories?query=react+tools", + ); + }); + + it("surfaces API error messages", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ error: "Invalid template." }, { status: 400 }), + ); + await expect(saveRepositoryDigestTemplate(template)).rejects.toThrow( + "Invalid template.", + ); + }); + + it("uses a fallback error when the API omits a message", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({}, { status: 500 }), + ); + await expect(getRepositoryDigestTemplate()).rejects.toThrow("Request failed."); + }); +}); diff --git a/tests/features/issues/server/digest-delivery.test.ts b/tests/features/issues/server/digest-delivery.test.ts new file mode 100644 index 0000000..5f353a9 --- /dev/null +++ b/tests/features/issues/server/digest-delivery.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { buildWeeklyDigest, sendWeeklyDigest, buildRepositoryDigest } = vi.hoisted( + () => ({ + buildWeeklyDigest: vi.fn(), + sendWeeklyDigest: vi.fn(), + buildRepositoryDigest: vi.fn(), + }), +); + +vi.mock("server-only", () => ({})); +vi.mock("@/features/issues/server/weekly-digest", () => ({ + buildWeeklyDigest, + sendWeeklyDigest, + getWeekStart: () => new Date("2026-08-24T00:00:00.000Z"), +})); +vi.mock("@/features/issues/server/repository-digest", () => ({ + buildRepositoryDigest, +})); + +import { + deliverWeeklyDigest, + getDigestContext, + getRepositoryAlertSchedule, +} from "@/features/issues/server/digest-delivery"; +import { + digestTrendSnapshot, + repositoryDigestRepository, + repositoryDigestTemplate, + savedSearch, +} from "@/lib/auth-schema"; + +function resultChain(rows: unknown[]) { + const promise = Promise.resolve(rows); + return { + limit: async () => rows, + orderBy: async () => rows, + then: promise.then.bind(promise), + }; +} + +function databaseWith(rowsByTable: Map) { + const where = vi.fn(); + const select = vi.fn().mockReturnValue({ + from: (table: unknown) => ({ + where: () => { + const chain = resultChain(rowsByTable.get(table) ?? []); + where(table); + return chain; + }, + }), + }); + const onConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const insertValues = vi.fn().mockReturnValue({ onConflictDoNothing }); + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + return { + select, + insert: vi.fn().mockReturnValue({ values: insertValues }), + update: vi.fn().mockReturnValue({ set: updateSet }), + where, + insertValues, + updateSet, + }; +} + +describe("digest delivery", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendWeeklyDigest.mockResolvedValue(undefined); + buildWeeklyDigest.mockResolvedValue({ + subject: "Weekly digest", + html: "

    Saved searches

    ", + issueCount: 2, + trends: [ + { + searchKey: "search-key", + weekStart: new Date("2026-08-17T00:00:00.000Z"), + issueCount: 2, + topRepository: "acme/repo", + topRepositoryIssueCount: 2, + }, + ], + }); + buildRepositoryDigest.mockResolvedValue({ + changed: true, + issueCount: 1, + html: "

    Repositories

    ", + snapshots: [{ id: "repo-row", issueIds: '["issue-1"]' }], + }); + }); + + it("loads trend and repository scheduling context", async () => { + const trend = { + searchKey: "key", + weekStart: new Date("2026-08-10T00:00:00.000Z"), + }; + const schedule = { enabled: true, frequency: "daily", lastSentAt: null }; + const database = databaseWith( + new Map([ + [digestTrendSnapshot, [trend]], + [repositoryDigestTemplate, [schedule]], + ]), + ); + + const context = await getDigestContext(database as never); + expect(context.previousTrends.get("key")).toBe(trend); + await expect( + getRepositoryAlertSchedule(database as never, "user-1"), + ).resolves.toEqual(schedule); + + const emptyDatabase = databaseWith(new Map()); + await expect( + getRepositoryAlertSchedule(emptyDatabase as never, "user-2"), + ).resolves.toBeNull(); + }); + + it("does nothing when the selected delivery has no content", async () => { + const database = databaseWith(new Map()); + await expect( + deliverWeeklyDigest( + database as never, + { id: "user-1", email: "user@example.com" }, + { weekStart: new Date(), previousTrends: new Map() }, + "https://example.com", + ), + ).resolves.toBe(false); + expect(sendWeeklyDigest).not.toHaveBeenCalled(); + }); + + it("skips an unchanged repository-only digest", async () => { + buildRepositoryDigest.mockResolvedValueOnce({ + changed: false, + issueCount: 1, + html: "

    Repositories

    ", + snapshots: [], + }); + const database = databaseWith( + new Map([ + [repositoryDigestTemplate, [{ id: "template-1" }]], + [repositoryDigestRepository, [{ id: "repo-row" }]], + ]), + ); + await expect( + deliverWeeklyDigest( + database as never, + { id: "user-1", email: "user@example.com" }, + { weekStart: new Date(), previousTrends: new Map() }, + "https://example.com", + { includeSavedSearches: false }, + ), + ).resolves.toBe(false); + expect(sendWeeklyDigest).not.toHaveBeenCalled(); + }); + + it("combines content, uses the alternate email, and stores successful state", async () => { + const database = databaseWith( + new Map([ + [ + savedSearch, + [ + { + name: "React", + tech: "React", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: new Date("2026-08-01T00:00:00Z"), + }, + ], + ], + [repositoryDigestTemplate, [{ id: "template-1" }]], + [repositoryDigestRepository, [{ id: "repo-row" }]], + ]), + ); + + await expect( + deliverWeeklyDigest( + database as never, + { + id: "user-1", + email: "github@example.com", + alertEmail: "alerts@example.com", + }, + { weekStart: new Date(), previousTrends: new Map() }, + "https://example.com", + ), + ).resolves.toBe(true); + + expect(sendWeeklyDigest).toHaveBeenCalledWith( + expect.objectContaining({ + to: "alerts@example.com", + html: "

    Saved searches

    Repositories

    ", + }), + ); + expect(database.updateSet).toHaveBeenCalledWith({ + lastIssueIds: '["issue-1"]', + }); + expect(database.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ weeklyDigestLastSentAt: expect.any(Date) }), + ); + expect(database.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ lastSentAt: expect.any(Date) }), + ); + }); + + it("sends saved-search content alone to the linked email", async () => { + const database = databaseWith( + new Map([ + [ + savedSearch, + [ + { + name: "React", + tech: "React", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: new Date("2026-08-01T00:00:00Z"), + }, + ], + ], + ]), + ); + + await expect( + deliverWeeklyDigest( + database as never, + { id: "user-1", email: "github@example.com" }, + { weekStart: new Date(), previousTrends: new Map() }, + "https://example.com", + { includeRepositoryAlerts: false }, + ), + ).resolves.toBe(true); + expect(sendWeeklyDigest).toHaveBeenCalledWith({ + to: "github@example.com", + subject: "Weekly digest", + html: "

    Saved searches

    ", + }); + }); + + it("sends changed repository content without updating saved-search delivery", async () => { + const database = databaseWith( + new Map([ + [repositoryDigestTemplate, [{ id: "template-1" }]], + [repositoryDigestRepository, [{ id: "repo-row" }]], + ]), + ); + + await expect( + deliverWeeklyDigest( + database as never, + { id: "user-1", email: "user@example.com" }, + { weekStart: new Date(), previousTrends: new Map() }, + "https://example.com", + { includeSavedSearches: false }, + ), + ).resolves.toBe(true); + expect(sendWeeklyDigest).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "1 open-source issues for you this week", + html: "

    Repositories

    ", + }), + ); + expect(database.updateSet).not.toHaveBeenCalledWith( + expect.objectContaining({ weeklyDigestLastSentAt: expect.any(Date) }), + ); + }); +}); diff --git a/tests/features/issues/server/digest-schedule.test.ts b/tests/features/issues/server/digest-schedule.test.ts new file mode 100644 index 0000000..459e18d --- /dev/null +++ b/tests/features/issues/server/digest-schedule.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { isRepositoryAlertDue } from "@/features/issues/server/digest-delivery"; + +describe("repository alert schedule", () => { + const now = new Date("2026-08-24T09:00:00.000Z"); + + it("sends templates that have never been delivered", () => { + expect(isRepositoryAlertDue("daily", null, now)).toBe(true); + }); + + it.each([ + ["daily", "2026-08-23T13:00:00.000Z", true], + ["daily", "2026-08-23T14:00:01.000Z", false], + ["weekly", "2026-08-17T13:00:00.000Z", true], + ["weekly", "2026-08-17T13:00:01.000Z", false], + ["fortnightly", "2026-08-10T13:00:00.000Z", true], + ["fortnightly", "2026-08-10T13:00:01.000Z", false], + ] as const)("evaluates %s delivery windows", (frequency, lastSentAt, due) => { + expect(isRepositoryAlertDue(frequency, new Date(lastSentAt), now)).toBe(due); + }); +}); diff --git a/tests/features/issues/server/github-search.test.ts b/tests/features/issues/server/github-search.test.ts index eebb7ac..c7e9ca5 100644 --- a/tests/features/issues/server/github-search.test.ts +++ b/tests/features/issues/server/github-search.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { searchGitHubIssues } from "@/features/issues/server/github-search"; +import { + getRecentRepositoryIssues, + searchGitHubIssues, + searchGitHubRepositories, +} from "@/features/issues/server/github-search"; const originalToken = process.env.GITHUB_TOKEN; @@ -539,3 +543,86 @@ describe("searchGitHubIssues", () => { ); }); }); + +describe("repository digest GitHub queries", () => { + afterEach(() => vi.restoreAllMocks()); + + it("maps repository autocomplete results", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + total_count: 1, + items: [ + { + full_name: "acme/widgets", + html_url: "https://github.com/acme/widgets", + description: "Widget tools", + stargazers_count: 250, + archived: false, + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(searchGitHubRepositories(" widgets ")).resolves.toEqual([ + { + fullName: "acme/widgets", + url: "https://github.com/acme/widgets", + description: "Widget tools", + stars: 250, + }, + ]); + expect(String(fetchMock.mock.calls[0][0])).toContain( + "widgets+in%3Aname%2Cdescription+archived%3Afalse", + ); + }); + + it("maps the five newest open repository issues with concise summaries", async () => { + const issues = Array.from({ length: 6 }, (_, index) => + githubIssue({ + number: index + 1, + html_url: `https://github.com/acme/widgets/issues/${index + 1}`, + title: `Issue ${index + 1}`, + body: index === 0 ? "A concise\nsummary" : null, + comments: index, + assignee: index === 0 ? { login: "owner" } : null, + }), + ); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ total_count: 6, items: issues })); + vi.stubGlobal("fetch", fetchMock); + + const result = await getRecentRepositoryIssues("acme/widgets"); + expect(result).toHaveLength(5); + expect(result[0]).toMatchObject({ + summary: "A concise summary", + assigned: true, + }); + expect(result[1].summary).toBe("No description provided."); + expect(String(fetchMock.mock.calls[0][0])).toContain("is%3Aopen"); + }); +}); + +describe("updated issue ranges", () => { + afterEach(() => vi.restoreAllMocks()); + + it("supports an open-ended updated-after qualifier", async () => { + const fetchMock = vi.fn(); + searchPageResponses([]).forEach((response) => + fetchMock.mockResolvedValueOnce(response), + ); + vi.stubGlobal("fetch", fetchMock); + + await searchGitHubIssues({ + tech: "Java", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + updatedAfter: "2026-08-01", + }); + expect(String(fetchMock.mock.calls[0][0])).toContain( + "updated%3A%3E%3D2026-08-01", + ); + }); +}); diff --git a/tests/features/issues/server/repository-digest.test.ts b/tests/features/issues/server/repository-digest.test.ts new file mode 100644 index 0000000..817c39f --- /dev/null +++ b/tests/features/issues/server/repository-digest.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/features/issues/server/github-search", () => ({ + getRecentRepositoryIssues: vi.fn(), +})); + +import { getRecentRepositoryIssues } from "@/features/issues/server/github-search"; +import { buildRepositoryDigest } from "@/features/issues/server/repository-digest"; + +const mockedGetIssues = vi.mocked(getRecentRepositoryIssues); + +describe("buildRepositoryDigest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("renders issue details and records a changed snapshot", async () => { + mockedGetIssues.mockResolvedValue([ + { + id: "https://github.com/acme/widgets/issues/3", + title: "Escape ", + url: "https://github.com/acme/widgets/issues/3", + summary: "Useful & concise details", + labels: ["help wanted"], + createdAt: "2026-08-24T10:00:00Z", + comments: 2, + assigned: false, + }, + ]); + + const digest = await buildRepositoryDigest([ + { + id: "selection-1", + fullName: "acme/widgets", + url: "https://github.com/acme/widgets", + lastIssueIds: "[]", + }, + ]); + + expect(digest.changed).toBe(true); + expect(digest.issueCount).toBe(1); + expect(digest.snapshots[0].issueIds).toContain("issues/3"); + expect(digest.html).toContain("Escape <this>"); + expect(digest.html).toContain("Useful & concise details"); + expect(digest.html).toContain("unassigned"); + }); + + it("recognizes an unchanged five-issue set", async () => { + mockedGetIssues.mockResolvedValue([ + { + id: "issue-1", + title: "Issue", + url: "https://github.com/acme/widgets/issues/1", + summary: "Details", + labels: [], + createdAt: "2026-08-24T10:00:00Z", + comments: 0, + assigned: true, + }, + ]); + + const digest = await buildRepositoryDigest([ + { + id: "selection-1", + fullName: "acme/widgets", + url: "https://github.com/acme/widgets", + lastIssueIds: JSON.stringify(["issue-1"]), + }, + ]); + + expect(digest.changed).toBe(false); + }); +}); diff --git a/tests/features/issues/server/weekly-digest.test.ts b/tests/features/issues/server/weekly-digest.test.ts new file mode 100644 index 0000000..9232300 --- /dev/null +++ b/tests/features/issues/server/weekly-digest.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SearchResponse } from "@/features/issues/types/search"; + +const { searchGitHubIssues, createTransport, sendMail } = vi.hoisted(() => ({ + searchGitHubIssues: vi.fn(), + createTransport: vi.fn(), + sendMail: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/features/issues/server/github-search", () => ({ searchGitHubIssues })); +vi.mock("nodemailer", () => ({ + default: { createTransport }, +})); + +import { + buildWeeklyDigest, + getDigestSearchKey, + getWeekStart, + sendWeeklyDigest, +} from "@/features/issues/server/weekly-digest"; + +const savedSearch = { + id: "saved-1", + name: "React & docs", + tech: "React", + label: "documentation", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-19T00:00:00.000Z", +}; + +function response(): SearchResponse { + return { + query: "React", + totalCount: 1, + candidateCount: 1, + rateLimitRemaining: "100", + tokenConfigured: true, + page: 1, + issues: [ + { + id: "issue-1", + title: "Improve ", + url: "https://github.com/acme/repo/issues/1", + repo: "acme/repo", + repoUrl: "https://github.com/acme/repo", + stars: 10, + comments: 0, + labels: ["documentation"], + updatedAt: "2026-08-20T00:00:00.000Z", + createdAt: "2026-08-19T00:00:00.000Z", + assigned: false, + linkedPrCount: 0, + hacktoberfest: false, + hacktoberfestSource: null, + qualityScore: 80, + }, + ], + }; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("weekly digest", () => { + it("builds escaped recommendations and saved-search links", async () => { + searchGitHubIssues.mockResolvedValue(response()); + + const digest = await buildWeeklyDigest( + [savedSearch], + "https://openissue.dev/", + new Map(), + new Date("2026-08-17T00:00:00.000Z"), + ); + + expect(digest.issueCount).toBe(1); + expect(digest.html).toContain("Improve <docs>"); + expect(digest.html).toContain("tech=React"); + expect(digest.html).toContain("React & docs"); + expect(digest.html).toContain("baseline recorded"); + expect(digest.html).toContain( + "Leading recommendation source: acme/repo (1)", + ); + expect(searchGitHubIssues).toHaveBeenCalledWith( + expect.objectContaining({ + updatedAfter: "2026-08-17", + updatedBefore: "2026-08-23", + }), + ); + }); + + it("compares GitHub activity with the previous weekly snapshot", async () => { + searchGitHubIssues.mockResolvedValue(response()); + const weekStart = new Date("2026-08-24T00:00:00.000Z"); + const searchKey = JSON.stringify({ + tech: "react", + label: "documentation", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + }); + + const digest = await buildWeeklyDigest( + [savedSearch], + "https://openissue.dev/", + new Map([ + [ + searchKey, + { + searchKey, + weekStart: new Date("2026-08-17T00:00:00.000Z"), + issueCount: 0, + topRepository: null, + topRepositoryIssueCount: 0, + }, + ], + ]), + weekStart, + ); + + expect(digest.html).toContain("up 1 from last week"); + }); + + it("renders empty results using the default completed week", async () => { + searchGitHubIssues.mockResolvedValue({ + ...response(), + totalCount: 0, + candidateCount: 0, + issues: [], + }); + + const digest = await buildWeeklyDigest([savedSearch], "https://openissue.dev/"); + expect(digest.issueCount).toBe(0); + expect(digest.html).toContain("No new matching issues this week."); + expect(digest.html).toContain("No repository trend yet."); + expect(digest.html).not.toContain("Leading recommendation source:"); + expect(getWeekStart(new Date("2026-08-23T18:00:00Z"))).toEqual( + new Date("2026-08-17T00:00:00.000Z"), + ); + expect(getDigestSearchKey({ ...savedSearch, tech: " React " })).toContain( + '"tech":"react"', + ); + }); + + it("reports down and steady trends and keeps the higher-ranked duplicate", async () => { + const lowerRankedDuplicate = { + ...response().issues[0], + qualityScore: 20, + }; + const secondSearch = { + ...savedSearch, + id: "saved-2", + name: "Second search", + tech: "TypeScript", + }; + searchGitHubIssues + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce({ + ...response(), + totalCount: 1, + issues: [lowerRankedDuplicate], + }); + const firstKey = getDigestSearchKey(savedSearch); + const secondKey = getDigestSearchKey(secondSearch); + + const digest = await buildWeeklyDigest( + [savedSearch, secondSearch], + "https://openissue.dev/", + new Map([ + [ + firstKey, + { + searchKey: firstKey, + weekStart: new Date("2026-08-10T00:00:00Z"), + issueCount: 3, + topRepository: "acme/repo", + topRepositoryIssueCount: 1, + }, + ], + [ + secondKey, + { + searchKey: secondKey, + weekStart: new Date("2026-08-10T00:00:00Z"), + issueCount: 1, + topRepository: "acme/repo", + topRepositoryIssueCount: 1, + }, + ], + ]), + new Date("2026-08-17T00:00:00Z"), + ); + + expect(digest.issueCount).toBe(1); + expect(digest.html).toContain("down 2 from last week"); + expect(digest.html).toContain("steady from last week"); + }); + + it("sends through configured Gmail SMTP", async () => { + vi.stubEnv("SMTP_USER", "openissue.project@gmail.com"); + vi.stubEnv("SMTP_APP_PASSWORD", "app-password"); + vi.stubEnv( + "DIGEST_FROM_EMAIL", + "OpenIssue.dev ", + ); + createTransport.mockReturnValue({ sendMail }); + sendMail.mockResolvedValue(undefined); + + await sendWeeklyDigest({ + to: "user@example.com", + subject: "Digest", + html: "

    Hi

    ", + }); + + expect(createTransport).toHaveBeenCalledWith({ + host: "smtp.gmail.com", + port: 465, + secure: true, + auth: { + user: "openissue.project@gmail.com", + pass: "app-password", + }, + }); + expect(sendMail).toHaveBeenCalledWith({ + from: "OpenIssue.dev ", + to: "user@example.com", + subject: "Digest", + html: "

    Hi

    ", + }); + }); + + it.each([ + [undefined, "password", "sender@example.com"], + ["user@example.com", undefined, "sender@example.com"], + ["user@example.com", "password", undefined], + ])("rejects incomplete SMTP configuration", async (user, password, from) => { + if (user) vi.stubEnv("SMTP_USER", user); + if (password) vi.stubEnv("SMTP_APP_PASSWORD", password); + if (from) vi.stubEnv("DIGEST_FROM_EMAIL", from); + + await expect( + sendWeeklyDigest({ + to: "recipient@example.com", + subject: "Digest", + html: "

    Digest

    ", + }), + ).rejects.toThrow("Weekly digest email is not configured."); + }); +}); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f3c923a --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/cron/weekly-digest", + "schedule": "0 9 * * *" + } + ] +}