feat: add ntfy notification action context service
This commit is contained in:
@@ -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,225 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
mockedEnv: {
|
||||||
|
PUBLIC_APP_URL: "https://app.example.com",
|
||||||
|
CORS_ORIGINS: "http://localhost:5173,http://localhost:4173",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
const { createNotificationActionContext, getNotificationActionTokenRecord, hashActionToken } = await import(
|
||||||
|
"../services/notification-actions-service.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
function extractToken(url: string): string {
|
||||||
|
return url.split("/").at(-1) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||||
|
await testClient.execute("DELETE FROM notification_action_groups");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(username: string) {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||||
|
args: [username],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("notification-actions-service", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://localhost:4173";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a notification action group with hashed tokens and app/view links", async () => {
|
||||||
|
const userId = await createUser("notify-actions-user");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-1-1736064000000", "9-0-1736064000000", "9-1-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
sequenceId: expect.stringMatching(/^medassist-/),
|
||||||
|
});
|
||||||
|
expect(context?.actions.map((action) => action.kind)).toEqual(["taken", "skip", "view"]);
|
||||||
|
|
||||||
|
const groups = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM notification_action_groups WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(Number(groups.rows[0].count)).toBe(1);
|
||||||
|
|
||||||
|
const tokenRows = await testClient.execute({
|
||||||
|
sql: "SELECT kind, token_hash FROM notification_action_tokens ORDER BY kind ASC",
|
||||||
|
});
|
||||||
|
expect(tokenRows.rows).toHaveLength(3);
|
||||||
|
|
||||||
|
const respondToken = extractToken(context!.respondUrl!);
|
||||||
|
const respondRow = tokenRows.rows.find((row: { kind?: unknown }) => row.kind === "respond");
|
||||||
|
expect(respondRow).toEqual(expect.objectContaining({ token_hash: hashActionToken(respondToken), kind: "respond" }));
|
||||||
|
expect(respondRow?.token_hash).not.toBe(respondToken);
|
||||||
|
|
||||||
|
const record = await getNotificationActionTokenRecord(respondToken);
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a view-only context without mutation tokens", async () => {
|
||||||
|
const userId = await createUser("notify-actions-view-only");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Grouped reminder",
|
||||||
|
message: "Open the dashboard for details",
|
||||||
|
doseIds: ["9-0-1736064000000", "10-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
actionMode: "view-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context).toEqual({
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
|
||||||
|
expect(Number(groups.rows[0].count)).toBe(0);
|
||||||
|
|
||||||
|
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
|
||||||
|
expect(Number(tokens.rows[0].count)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses an unresolved active group for the same dose set and schedule", async () => {
|
||||||
|
const userId = await createUser("notify-actions-reuse");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const first = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
const second = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second?.sequenceId).toBe(first?.sequenceId);
|
||||||
|
|
||||||
|
const groups = await testClient.execute("SELECT id, sequence_id FROM notification_action_groups");
|
||||||
|
expect(groups.rows).toHaveLength(1);
|
||||||
|
expect(groups.rows[0]).toEqual(expect.objectContaining({ sequence_id: first?.sequenceId }));
|
||||||
|
|
||||||
|
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
|
||||||
|
expect(Number(tokens.rows[0].count)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
|
||||||
|
const userId = await createUser("notify-actions-mobile");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "http://localhost:5173";
|
||||||
|
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://192.168.0.113:5173";
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
respondUrl: `http://192.168.0.113:5173/api/notification-actions/${extractToken(context!.respondUrl!)}`,
|
||||||
|
viewUrl: "http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await getNotificationActionTokenRecord(extractToken(context!.respondUrl!));
|
||||||
|
expect(record?.viewUrl).toBe("http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the date view when dose ids do not contain a medication id", async () => {
|
||||||
|
const userId = await createUser("notify-actions-fallback");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["invalid-dose-id"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context?.viewUrl).toBe("https://app.example.com/dashboard?day=2026-01-05&dose=invalid-dose-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user