Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <openissue.project@gmail.com>
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]
```
Expand Down
15 changes: 15 additions & 0 deletions db/migrations/0003_weekly_digest.sql
Original file line number Diff line number Diff line change
@@ -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");
27 changes: 27 additions & 0 deletions db/migrations/0004_repository_digest.sql
Original file line number Diff line number Diff line change
@@ -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");
4 changes: 4 additions & 0 deletions db/migrations/0005_repository_digest_frequency.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions db/migrations/0006_alert_email.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "alert_email" text;
46 changes: 46 additions & 0 deletions doc/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ flowchart LR
AuthAPI["/api/auth/*"]
BetterAuth[Better Auth]
SavedAPI["/api/saved-searches"]
DigestAPI["/api/digest-preference"]
DigestCron["Weekly digest cron"]
Drizzle[Drizzle ORM]
end

subgraph External[External services]
GitHubAPI[GitHub Search and REST APIs]
GitHubOAuth[GitHub OAuth]
Turso[(Turso / libSQL)]
EmailAPI[Gmail SMTP]
end

UI -->|Search filters| SearchAPI
Expand All @@ -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
```

Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions doc/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions doc/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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.
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions src/app/api/cron/weekly-digest/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading