evOo-local/packages/db/src/push-subscriptions.ts
Claude a163447461
feat(notifications): add push subscriptions, notification settings and web push interfaces
Push could only reach one mobile device per user (a token file), quiet hours did not exist,
and the web app had no service worker, so a closed tab never heard about finished work.

This lands the shared interfaces first so onboarding can build on them:
- push_subscriptions (web push endpoints and Expo tokens, one row per device) and
  user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a
  per-day nudge counter), with CHECK constraints.
- NotificationSettingsSchema and web push contracts, notifications.settings.get/update and
  notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only).
- getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers.
- VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted,
  and an SSRF-guarded web push sender built on the web-push package.
- The web app links its manifest, registers a push-only service worker where supported (secure
  context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/
  disableWebPush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:13:22 +00:00

89 lines
2.6 KiB
TypeScript

import type { PrismaClient } from "./client.js";
export type PushSubscriptionKind = "webpush" | "expo";
/** Devices one user keeps registered; the least recently registered ones are dropped first. */
export const PUSH_SUBSCRIPTION_LIMIT = 20;
export interface StoredPushSubscription {
id: string;
kind: PushSubscriptionKind;
endpoint: string;
p256dh: string | null;
auth: string | null;
}
export interface NewPushSubscription {
kind: PushSubscriptionKind;
/** Web push endpoint URL or Expo push token. */
endpoint: string;
p256dh?: string | null;
auth?: string | null;
userAgent?: string;
}
/**
* Registers a device for the user. An endpoint registered by another account moves to this one:
* a device delivers to whoever signed in on it last.
*/
export async function savePushSubscription(
prisma: PrismaClient,
userId: string,
input: NewPushSubscription,
): Promise<void> {
const data = {
userId,
kind: input.kind,
p256dh: input.p256dh ?? null,
auth: input.auth ?? null,
userAgent: (input.userAgent ?? "").slice(0, 256),
};
await prisma.pushSubscription.upsert({
where: { endpoint: input.endpoint },
create: { endpoint: input.endpoint, ...data },
// A renewed registration counts as the newest device for the per-user limit.
update: { ...data, createdAt: new Date() },
});
const stale = await prisma.pushSubscription.findMany({
where: { userId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: PUSH_SUBSCRIPTION_LIMIT,
select: { id: true },
});
if (stale.length > 0) {
await prisma.pushSubscription.deleteMany({
where: { id: { in: stale.map((row) => row.id) } },
});
}
}
/** Removes one of the user's devices; another user's endpoint is left alone. */
export async function removePushSubscription(
prisma: PrismaClient,
userId: string,
endpoint: string,
): Promise<void> {
await prisma.pushSubscription.deleteMany({ where: { userId, endpoint } });
}
/** Removes every device of one kind, e.g. all mobile app tokens on sign-out. */
export async function removePushSubscriptionsOfKind(
prisma: PrismaClient,
userId: string,
kind: PushSubscriptionKind,
): Promise<void> {
await prisma.pushSubscription.deleteMany({ where: { userId, kind } });
}
export async function listPushSubscriptions(
prisma: PrismaClient,
userId: string,
): Promise<StoredPushSubscription[]> {
const rows = await prisma.pushSubscription.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: { id: true, kind: true, endpoint: true, p256dh: true, auth: true },
});
return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind }));
}