import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { first, run } from "~/server/db"; import { accountDb, repoDb } from "~/server/scoped-db"; import { assertBelowCap, CAPS, limitAccountWrite } from "~/server/limits"; import { requireRepoAccess } from "~/server/repos"; import { issueParticipants, loadIssue } from "~/server/issues"; import { notFoundError } from "~/server/errors"; import { loadNotificationSettings, WATCH_LEVELS } from "~/server/notification-prefs"; import { repoRef } from "~/lib/validation"; import { DIGEST_MODES, NOTIFICATION_CATEGORIES } from "~/lib/notification-categories"; import { loadCategoryPrefs, saveCategoryPref } from "~/server/notify/prefs"; const threadRef = repoRef.extend({ number: z.number().int().positive() }); export const getNotificationSettings = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .handler(async ({ context }) => { const viewer = requireViewer(context.viewer); return loadNotificationSettings(viewer.id); }); export const updateNotificationSettings = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ emailParticipating: z.boolean(), emailWatching: z.boolean(), webParticipating: z.boolean(), webWatching: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await accountDb(viewer.id).run( `INSERT INTO notification_settings (user_id, email_participating, email_watching, web_participating, web_watching, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET email_participating = excluded.email_participating, email_watching = excluded.email_watching, web_participating = excluded.web_participating, web_watching = excluded.web_watching, updated_at = excluded.updated_at`, viewer.id, data.emailParticipating ? 1 : 0, data.emailWatching ? 1 : 0, data.webParticipating ? 1 : 0, data.webWatching ? 1 : 0, Date.now(), ); return { ok: true }; }); export const getCategoryPrefs = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .handler(async ({ context }) => { const viewer = requireViewer(context.viewer); return loadCategoryPrefs(viewer.id); }); const categoryPref = z.object({ web: z.boolean(), email: z.boolean(), digest: z.enum(DIGEST_MODES) }); export const updateCategoryPrefs = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_CATEGORIES), categoryPref) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); for (const category of NOTIFICATION_CATEGORIES) { const pref = data.prefs[category]; if (pref) await saveCategoryPref(viewer.id, category, pref); } return { ok: true }; }); export const getWatchLevel = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); const row = await first<{ level: string }>( "SELECT level FROM watches WHERE user_id = ? AND repo_id = ?", viewer.id, repo.id, ); return { level: row?.level ?? "none" }; }); /** "none" stops watching (participating notifications still arrive); "ignore" mutes the repo entirely. */ export const setWatchLevel = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ level: z.enum([...WATCH_LEVELS, "none"]) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); if (data.level === "none") { await run("DELETE FROM watches WHERE user_id = ? AND repo_id = ?", viewer.id, repo.id); } else { await run( `INSERT INTO watches (user_id, repo_id, created_at, level) VALUES (?, ?, ?, ?) ON CONFLICT (user_id, repo_id) DO UPDATE SET level = excluded.level`, viewer.id, repo.id, Date.now(), data.level, ); } return { ok: true }; }); export const getThreadSubscription = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(threadRef) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); const explicit = await repoDb(repo.id).first<{ subscribed: number }>( "SELECT subscribed FROM thread_subscriptions WHERE user_id = ? AND issue_id = ?", viewer.id, issue.id, ); if (explicit) return { subscribed: explicit.subscribed === 1, reason: "manual" }; if ((await issueParticipants(issue.id)).includes(viewer.id)) return { subscribed: true, reason: "participating" }; const watch = await first<{ level: string }>( "SELECT level FROM watches WHERE user_id = ? AND repo_id = ?", viewer.id, repo.id, ); return { subscribed: watch?.level === "all", reason: watch?.level === "all" ? "watching" : "none" }; }); export const setThreadSubscription = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(threadRef.extend({ subscribed: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); const db = repoDb(repo.id); const existing = await db.first( "SELECT 1 AS ok FROM thread_subscriptions WHERE user_id = ? AND issue_id = ?", viewer.id, issue.id, ); if (!existing) { // Subscriptions live per repository now, so the cap applies within this repository. const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM thread_subscriptions WHERE user_id = ?", viewer.id, ); assertBelowCap(count?.count ?? 0, CAPS.threadSubscriptionsPerUser, "thread subscriptions"); } await db.run( `INSERT INTO thread_subscriptions (user_id, issue_id, subscribed, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (user_id, issue_id) DO UPDATE SET subscribed = excluded.subscribed`, viewer.id, issue.id, data.subscribed ? 1 : 0, Date.now(), ); return { ok: true }; });