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
89 lines
2.6 KiB
TypeScript
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 }));
|
|
}
|