203 lines · 8.6 KB
import { env } from "cloudflare:workers";
import { accountDb } from "../scoped-db";
import { forEachScope } from "../account-scope";
import { hmacSha256Hex, randomId, timingSafeEqualStrings } from "../crypto";
import { fromBase64Url, toBase64Url } from "~/lib/base64url";
import { publicUrl } from "../events/emit";
import { takeInboxEmailBudget } from "../user-inbox";
import { CATEGORY_INFO, NOTIFICATION_CATEGORIES } from "~/lib/notification-categories";
import type { DigestMode, NotificationCategory } from "~/lib/notification-categories";
import type { EmailFooter } from "../email";

export type CategoryPref = { web: boolean; email: boolean; digest: DigestMode };
/** Unsubscribe targets: a category, or the legacy participating/watching email switches. */
export type UnsubscribeTarget = NotificationCategory | "participating" | "watching";

const HOUR_MS = 60 * 60 * 1000;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/** Per-recipient email budget per hour; overflow is folded into the next digest instead of being dropped. */
export const EMAILS_PER_HOUR = 20;

function defaults(category: NotificationCategory): CategoryPref {
  const info = CATEGORY_INFO[category];
  return { web: info.web, email: info.email, digest: "off" };
}

type PrefRow = { user_id: string; category: string; web: number; email: number; digest: string };

function fromRow(category: NotificationCategory, row: PrefRow | undefined): CategoryPref {
  if (!row) return defaults(category);
  const digest = row.digest === "daily" && CATEGORY_INFO[category].digestable ? "daily" : "off";
  return { web: row.web === 1, email: row.email === 1, digest };
}

export async function loadCategoryPrefs(userId: string): Promise<Record<NotificationCategory, CategoryPref>> {
  const rows = await accountDb(userId).all<PrefRow>(
    "SELECT * FROM notification_category_prefs WHERE user_id = ?",
    userId,
  );
  const byCategory = new Map(rows.map((row) => [row.category, row]));