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
103 lines
3.5 KiB
TypeScript
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)}`;
|
|
}
|
|
}
|