evOo-local/packages/adapters/src/push-notifications.ts
Claude 2d95db70f2
refactor(notifications): keep the push sender contract next to the fan-out
The payload, target and outcome types are shared by the Expo and web push senders, so they now
live with PushSender in push-notifications.ts instead of in the web push adapter. The settings
E2E also stubs the Notification permission (headless Chromium reports it as denied even when
granted), and the quiet-hours inputs are wide enough for 12-hour times.

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

103 lines
3.5 KiB
TypeScript

import type {
AdapterContext,
NotificationMessage,
NotificationProvider,
} from "@rakazo/adapter-kit";
import type { PrismaClient, PushSubscriptionKind } from "@rakazo/db";
import {
forgetPushSubscription,
listPushSubscriptions,
loadNotificationPolicy,
markPushSubscriptionUsed,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
/** Lock screens show a line or two; the rest lives in the conversation. */
const PUSH_BODY_MAX_LENGTH = 240;
/** What a device shows: the notification plus where a tap leads (a path in the web app). */
export interface PushPayload extends NotificationMessage {
url: string;
}
/** "gone": the push service no longer knows this device, so its registration can be dropped. */
export type PushOutcome = "sent" | "gone";
export interface PushTarget {
endpoint: string;
p256dh: string | null;
auth: string | null;
}
export interface PushSender {
send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise<PushOutcome>;
}
function clip(text: string): string {
const trimmed = text.trim();
return trimmed.length > PUSH_BODY_MAX_LENGTH
? `${trimmed.slice(0, PUSH_BODY_MAX_LENGTH - 1).trimEnd()}…`
: trimmed;
}
/**
* Delivers one notification to every device the user registered (browsers and the mobile app),
* except during their quiet hours. Devices the push service reports gone are dropped. Throws only
* when every device failed.
*/
export class PushNotificationProvider implements NotificationProvider {
constructor(
private readonly prisma: PrismaClient,
private readonly senders: Partial<Record<PushSubscriptionKind, PushSender>>,
private readonly now: () => Date = () => new Date(),
) {}
describe() {
return {
id: "push",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { push: true, email: false },
};
}
async send(message: NotificationMessage, context: AdapterContext): Promise<void> {
const policy = await loadNotificationPolicy(this.prisma, context.userId, this.now());
if (policy.quiet) return;
const devices = (await listPushSubscriptions(this.prisma, context.userId)).filter(
(device) => this.senders[device.kind],
);
if (devices.length === 0) return;
const payload: PushPayload = {
...message,
title: message.title.trim().slice(0, 120),
body: clip(message.body),
url: await this.conversationPath(message),
};
const results = await Promise.allSettled(
devices.map(async (device) => {
const outcome = await this.senders[device.kind]!.send(device, payload, context.signal);
if (outcome === "gone") await forgetPushSubscription(this.prisma, device.id);
else await markPushSubscriptionUsed(this.prisma, device.id, this.now());
}),
);
const failures = results.filter((result) => result.status === "rejected");
for (const failure of failures) {
// Errors carry a status or a reason, never the endpoint or token.
getLogger().error("push delivery failed", failure.reason);
}
if (failures.length === devices.length) throw new Error("Push delivery failed");
}
/** The web app path of the conversation the notification is about. */
private async conversationPath(message: NotificationMessage): Promise<string> {
const thread = await this.prisma.thread.findUnique({
where: { id: message.threadId },
select: { groupId: true },
});
return thread?.groupId
? `/app/g/${encodeURIComponent(thread.groupId)}`
: `/app/${encodeURIComponent(message.botId)}`;
}
}