@@ -0,0 +1,350 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { notificationActionGroups, notificationActionTokens } from "../db/schema.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { getNotificationActionLabels, type PushNotificationAction } from "./notifications/action-renderer.js";
|
||||
|
||||
export type NotificationActionKind = "taken" | "skip" | "respond" | "view";
|
||||
|
||||
type TokenKind = Exclude<NotificationActionKind, "view">;
|
||||
type ActiveTokenKind = "taken" | "skip" | "respond";
|
||||
|
||||
export type NotificationActionContext = {
|
||||
groupId?: number;
|
||||
sequenceId?: string;
|
||||
respondUrl?: string;
|
||||
viewUrl: string;
|
||||
actions: PushNotificationAction[];
|
||||
};
|
||||
|
||||
type NotificationActionMode = "full" | "view-only";
|
||||
|
||||
export type NotificationActionTokenRecord = {
|
||||
token: typeof notificationActionTokens.$inferSelect;
|
||||
group: typeof notificationActionGroups.$inferSelect;
|
||||
doseIds: string[];
|
||||
viewUrl: string | null;
|
||||
};
|
||||
|
||||
const NOTIFICATION_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function normalizePublicAppUrl(publicAppUrl: string): string {
|
||||
return publicAppUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function parseConfiguredUrl(value: string | null | undefined): URL | null {
|
||||
const trimmedValue = value?.trim();
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmedValue);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalizedHostname = hostname.toLowerCase();
|
||||
return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1";
|
||||
}
|
||||
|
||||
function resolveNotificationPublicAppUrl(publicAppUrl: string | null | undefined): string | null {
|
||||
const configuredUrl = parseConfiguredUrl(publicAppUrl ?? env.PUBLIC_APP_URL);
|
||||
if (configuredUrl && !isLoopbackHostname(configuredUrl.hostname)) {
|
||||
return normalizePublicAppUrl(configuredUrl.toString());
|
||||
}
|
||||
|
||||
const corsOrigins = env.CORS_ORIGINS.split(",")
|
||||
.map((origin) => parseConfiguredUrl(origin))
|
||||
.filter((origin): origin is URL => origin !== null);
|
||||
const reachableCorsOrigin =
|
||||
corsOrigins.find((origin) => !isLoopbackHostname(origin.hostname)) ?? corsOrigins[0] ?? null;
|
||||
if (reachableCorsOrigin) {
|
||||
return normalizePublicAppUrl(reachableCorsOrigin.toString());
|
||||
}
|
||||
|
||||
return configuredUrl ? normalizePublicAppUrl(configuredUrl.toString()) : null;
|
||||
}
|
||||
|
||||
function getScheduledKey(scheduledFor: Date): string {
|
||||
return String(Math.floor(scheduledFor.getTime() / 60000));
|
||||
}
|
||||
|
||||
function formatDateParam(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function buildViewUrl(baseUrl: string, scheduledFor: Date | null, doseIds: string[]): string {
|
||||
const params = new URLSearchParams();
|
||||
const primaryDoseId = doseIds[0];
|
||||
|
||||
if (scheduledFor) {
|
||||
params.set("day", formatDateParam(scheduledFor));
|
||||
}
|
||||
|
||||
if (primaryDoseId) {
|
||||
params.set("dose", primaryDoseId);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString.length > 0 ? `${baseUrl}/dashboard?${queryString}` : `${baseUrl}/dashboard`;
|
||||
}
|
||||
|
||||
function parseDoseIdsJson(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createSequenceId(groupKey: string): string {
|
||||
return `medassist-${createHash("sha256").update(groupKey, "utf8").digest("hex").slice(0, 32)}`;
|
||||
}
|
||||
|
||||
export function createActionToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export function hashActionToken(token: string): string {
|
||||
return createHash("sha256").update(token, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function createTokenRow(groupId: number, kind: TokenKind): Promise<{ kind: TokenKind; token: string }> {
|
||||
const token = createActionToken();
|
||||
await db.insert(notificationActionTokens).values({
|
||||
groupId,
|
||||
tokenHash: hashActionToken(token),
|
||||
kind,
|
||||
});
|
||||
|
||||
return { kind, token };
|
||||
}
|
||||
|
||||
async function createActionTokens(groupId: number): Promise<Record<ActiveTokenKind, string>> {
|
||||
const createdTokens = await Promise.all([
|
||||
createTokenRow(groupId, "taken"),
|
||||
createTokenRow(groupId, "skip"),
|
||||
createTokenRow(groupId, "respond"),
|
||||
]);
|
||||
|
||||
return createdTokens.reduce(
|
||||
(accumulator, entry) => {
|
||||
accumulator[entry.kind] = entry.token;
|
||||
return accumulator;
|
||||
},
|
||||
{ taken: "", skip: "", respond: "" } as Record<ActiveTokenKind, string>
|
||||
);
|
||||
}
|
||||
|
||||
export async function createNotificationActionContext(input: {
|
||||
userId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
doseIds: string[];
|
||||
scheduledFor: Date;
|
||||
publicAppUrl?: string | null;
|
||||
language: Language;
|
||||
actionMode?: NotificationActionMode;
|
||||
}): Promise<NotificationActionContext | null> {
|
||||
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
|
||||
if (!publicAppUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueDoseIds = [...new Set(input.doseIds.filter((doseId) => doseId.trim().length > 0))].sort();
|
||||
if (uniqueDoseIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = publicAppUrl;
|
||||
const actionMode = input.actionMode ?? "full";
|
||||
const labels = getNotificationActionLabels(input.language);
|
||||
const viewUrl = buildViewUrl(baseUrl, input.scheduledFor, uniqueDoseIds);
|
||||
|
||||
if (actionMode === "view-only") {
|
||||
return {
|
||||
viewUrl,
|
||||
actions: [{ kind: "view", label: labels.view, url: viewUrl, method: "GET" }],
|
||||
};
|
||||
}
|
||||
|
||||
const groupKey = `intake:${input.userId}:${uniqueDoseIds.join(",")}:${getScheduledKey(input.scheduledFor)}`;
|
||||
const sequenceId = createSequenceId(groupKey);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
|
||||
|
||||
let [group] = await db
|
||||
.select()
|
||||
.from(notificationActionGroups)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationActionGroups.groupKey, groupKey),
|
||||
isNull(notificationActionGroups.resolvedAction),
|
||||
gt(notificationActionGroups.expiresAt, now)
|
||||
)
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
[group] = await db
|
||||
.insert(notificationActionGroups)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
groupKey,
|
||||
sequenceId,
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
const tokens = await createActionTokens(group.id);
|
||||
const groupLanguage = (group.language as Language | null) ?? input.language;
|
||||
const groupLabels = getNotificationActionLabels(groupLanguage);
|
||||
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
|
||||
const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds);
|
||||
|
||||
return {
|
||||
groupId: group.id,
|
||||
sequenceId: group.sequenceId,
|
||||
respondUrl,
|
||||
viewUrl: resolvedViewUrl,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: groupLabels.taken,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
kind: "skip",
|
||||
label: groupLabels.skip,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
|
||||
method: "POST",
|
||||
},
|
||||
{ kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTestNotificationActionContext(input: {
|
||||
userId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
publicAppUrl?: string | null;
|
||||
language: Language;
|
||||
}): Promise<NotificationActionContext | null> {
|
||||
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
|
||||
if (!publicAppUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = publicAppUrl;
|
||||
const now = new Date();
|
||||
const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`;
|
||||
const sequenceId = createSequenceId(groupKey);
|
||||
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
|
||||
const viewUrl = buildViewUrl(baseUrl, null, []);
|
||||
|
||||
const [group] = await db
|
||||
.insert(notificationActionGroups)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
groupKey,
|
||||
sequenceId,
|
||||
doseIdsJson: "[]",
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: now,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const tokens = await createActionTokens(group.id);
|
||||
const groupLanguage = (group.language as Language | null) ?? input.language;
|
||||
const groupLabels = getNotificationActionLabels(groupLanguage);
|
||||
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
|
||||
|
||||
return {
|
||||
groupId: group.id,
|
||||
sequenceId: group.sequenceId,
|
||||
respondUrl,
|
||||
viewUrl,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: groupLabels.taken,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
kind: "skip",
|
||||
label: groupLabels.skip,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
|
||||
method: "POST",
|
||||
},
|
||||
{ kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNotificationActionTokenRecord(
|
||||
rawToken: string
|
||||
): Promise<NotificationActionTokenRecord | null> {
|
||||
const tokenHash = hashActionToken(rawToken);
|
||||
const rows = await db
|
||||
.select({ token: notificationActionTokens, group: notificationActionGroups })
|
||||
.from(notificationActionTokens)
|
||||
.innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id))
|
||||
.where(eq(notificationActionTokens.tokenHash, tokenHash));
|
||||
|
||||
const record = rows[0];
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL);
|
||||
return {
|
||||
token: record.token,
|
||||
group: record.group,
|
||||
doseIds: parseDoseIdsJson(record.group.doseIdsJson),
|
||||
viewUrl: baseUrl
|
||||
? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean {
|
||||
return record.group.expiresAt.getTime() <= Date.now();
|
||||
}
|
||||
|
||||
export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise<void> {
|
||||
const normalizedMessageId = ntfyMessageId.trim();
|
||||
if (normalizedMessageId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, groupId));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { Language } from "../../i18n/translations.js";
|
||||
|
||||
export type PushNotificationAction =
|
||||
| {
|
||||
kind: "taken";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
}
|
||||
| {
|
||||
kind: "view";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "GET";
|
||||
};
|
||||
|
||||
export type PushNotificationOptions = {
|
||||
actions?: PushNotificationAction[];
|
||||
respondUrl?: string;
|
||||
viewUrl?: string;
|
||||
clickUrl?: string;
|
||||
tags?: string[];
|
||||
priority?: number;
|
||||
sequenceId?: string;
|
||||
};
|
||||
|
||||
type NtfyActionPayload = {
|
||||
action: "http" | "view";
|
||||
label: string;
|
||||
url: string;
|
||||
method?: "POST";
|
||||
clear: boolean;
|
||||
};
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
|
||||
}
|
||||
|
||||
export function isNtfyNotificationUrl(urlStr: string): boolean {
|
||||
if (urlStr.startsWith("ntfy://")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(urlStr);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationProvider(urlStr: string): string {
|
||||
if (isNtfyNotificationUrl(urlStr)) {
|
||||
return "ntfy";
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationActionLabels(language: Language): {
|
||||
taken: string;
|
||||
skip: string;
|
||||
respond: string;
|
||||
view: string;
|
||||
} {
|
||||
if (language === "de") {
|
||||
return {
|
||||
taken: "Einnehmen",
|
||||
skip: "Überspringen",
|
||||
respond: "Antworten",
|
||||
view: "Öffnen",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
taken: "Take",
|
||||
skip: "Skip",
|
||||
respond: "Respond",
|
||||
view: "View",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
|
||||
const actions = options.actions ?? [];
|
||||
|
||||
return actions.map((action) => {
|
||||
if (action.kind === "view") {
|
||||
return {
|
||||
action: "view",
|
||||
label: action.label,
|
||||
url: action.url,
|
||||
clear: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "http",
|
||||
label: action.label,
|
||||
url: action.url,
|
||||
method: "POST",
|
||||
// Clear the original actionable ntfy notification locally after a successful mutation.
|
||||
clear: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
|
||||
if (!options.respondUrl && !options.viewUrl) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const lines = [message.trimEnd()];
|
||||
|
||||
if (options.respondUrl) {
|
||||
lines.push("", "Respond:", options.respondUrl);
|
||||
}
|
||||
|
||||
if (options.viewUrl) {
|
||||
lines.push("", "View:", options.viewUrl);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function renderNotificationActionPayload(
|
||||
urlStr: string,
|
||||
message: string,
|
||||
options: PushNotificationOptions
|
||||
): { message: string; headers: Record<string, string> } {
|
||||
if (!isNtfyNotificationUrl(urlStr)) {
|
||||
return {
|
||||
message: appendFallbackActionLinks(message, options),
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const ntfyActions = buildNtfyActions(options);
|
||||
if (ntfyActions.length > 0) {
|
||||
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
|
||||
}
|
||||
if (options.clickUrl && ntfyActions.length === 0) {
|
||||
headers.Click = options.clickUrl;
|
||||
}
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
headers.Tags = options.tags.join(",");
|
||||
}
|
||||
if (typeof options.priority === "number") {
|
||||
headers.Priority = String(options.priority);
|
||||
}
|
||||
if (options.sequenceId) {
|
||||
headers["X-Sequence-ID"] = options.sequenceId;
|
||||
}
|
||||
|
||||
return { message, headers };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { sendShoutrrrNotification } from "../../routes/settings.js";
|
||||
import type { PushNotificationOptions } from "./action-renderer.js";
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
@@ -122,14 +123,15 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
|
||||
export async function sendPushNotification(
|
||||
url: string,
|
||||
title: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
message: string,
|
||||
options: PushNotificationOptions = {}
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, title, message);
|
||||
const result = await sendShoutrrrNotification(url, title, message, options);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true };
|
||||
return { success: true, providerMessageId: result.providerMessageId };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
|
||||
Reference in New Issue
Block a user