mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(notifications): resolve a tap target per event and offer mood quick answers
This commit is contained in:
1 parent
9aadb29876
commit
f19dfc4572
21 files changed
+859
-21
No files matched your search
@@ -271,6 +271,13 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
// renders nothing about the profile before the preview answers.
|
||||
"app/auth/claim/page.tsx":
|
||||
"carries the handover token from the query string into the claim requests' body",
|
||||
// v1.42.2 — notification quick answers. A notification app (ntfy's
|
||||
// `http` action) calls it with no session, so the signed, user- and
|
||||
// action-bound, short-lived token in the path is the credential. Rate
|
||||
// limited before verification, single-use through the mood entry's unique
|
||||
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
|
||||
"app/api/notifications/action/[token]/route.ts":
|
||||
"signed single-use quick-answer token from a notification action",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -304,6 +311,7 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
expect(bySegment.length).toBeGreaterThan(0);
|
||||
expect(bySegment).toEqual([
|
||||
"app/api/c/[token]/unlock/route.ts",
|
||||
"app/api/notifications/action/[token]/route.ts",
|
||||
"app/api/whoop/webhook/[token]/route.ts",
|
||||
"app/api/withings/webhook/[token]/route.ts",
|
||||
"app/c/[token]/d/[id]/route.ts",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* `POST /api/notifications/action/<token>` — a quick answer tapped inside a
|
||||
* notification.
|
||||
*
|
||||
* The caller is a notification app (ntfy's `http` action), not a browser
|
||||
* with a session, so the signed path segment is the whole credential. What
|
||||
* the token proves and how it is kept single-use is in
|
||||
* `src/lib/notifications/action-token.ts`; this route only applies it:
|
||||
*
|
||||
* 1. Rate-limit per client address BEFORE looking at the token, so the
|
||||
* route is no oracle for guessing one (the anonymous-surface limiter
|
||||
* collapses a broken proxy trust chain into one tight bucket).
|
||||
* 2. Verify the signature (constant-time) and the expiry.
|
||||
* 3. Re-check what may have changed since the reminder went out: the
|
||||
* account still exists and its mood module is still on.
|
||||
* 4. Write the mood entry under the reminder's nonce. The unique
|
||||
* `(userId, source, externalId)` key makes the first answer the only
|
||||
* one: a repeat tap, or a different answer on the same reminder, is
|
||||
* refused with 409 and writes nothing.
|
||||
*
|
||||
* The token travels in the path, so it is scrubbed from every log line by
|
||||
* the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the
|
||||
* URL-credential inventory in `session-surface-guard.test.ts`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
|
||||
import {
|
||||
MOOD_QUICK_ACTIONS,
|
||||
quickActionExternalId,
|
||||
verifyActionToken,
|
||||
} from "@/lib/notifications/action-token";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Generous for one person tapping, tight for anyone enumerating. */
|
||||
const RATE_LIMIT = 20;
|
||||
const RATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "notification.action.invoke" } });
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"notification-action",
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { token } = await context.params;
|
||||
const verdict = verifyActionToken(token);
|
||||
if (!verdict.ok) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: verdict.reason },
|
||||
});
|
||||
return verdict.reason === "expired"
|
||||
? apiError("This answer has expired", 410, {
|
||||
errorCode: "notification_action.expired",
|
||||
})
|
||||
: apiError("Invalid action", 401, {
|
||||
errorCode: "notification_action.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: verdict.userId },
|
||||
select: { id: true, timezone: true },
|
||||
});
|
||||
if (!user) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "unknown_user" },
|
||||
});
|
||||
return apiError("Invalid action", 401, {
|
||||
errorCode: "notification_action.invalid",
|
||||
});
|
||||
}
|
||||
getEvent()?.setAuth({
|
||||
user_id: user.id,
|
||||
auth_method: "notification_action_token",
|
||||
});
|
||||
|
||||
if (!(await isModuleEnabled(user.id, "mood"))) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "module_disabled" },
|
||||
});
|
||||
return apiError("Mood tracking is turned off", 403, {
|
||||
errorCode: "notification_action.module_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const score = MOOD_QUICK_ACTIONS[verdict.action];
|
||||
let created: boolean;
|
||||
try {
|
||||
const result = await logTelegramMood({
|
||||
userId: user.id,
|
||||
score,
|
||||
tz: user.timezone,
|
||||
externalId: quickActionExternalId(verdict.nonce),
|
||||
source: "NOTIFICATION",
|
||||
});
|
||||
created = result.created;
|
||||
} catch (err) {
|
||||
// Two taps raced past the existence check; the unique key let one in.
|
||||
if (!isUniqueViolation(err)) throw err;
|
||||
created = false;
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "already_used" },
|
||||
});
|
||||
return apiError("This reminder was already answered", 409, {
|
||||
errorCode: "notification_action.already_used",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "notification.action.mood_logged" },
|
||||
meta: { action: verdict.action },
|
||||
});
|
||||
return apiSuccess({ logged: true, action: verdict.action });
|
||||
},
|
||||
);
|
||||
@@ -1395,6 +1395,8 @@ function formatMoodSource(
|
||||
return t("mood.sourceTelegram");
|
||||
case "DAYLIO":
|
||||
return t("mood.sourceDaylio");
|
||||
case "NOTIFICATION":
|
||||
return t("mood.sourceNotification");
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{
|
||||
// and its last segment is a route name. A full URL carrying the token is
|
||||
// still scrubbed, by the `hlp_` rule in `redactSecrets`.
|
||||
{ prefix: "/claim/", pathStart: true },
|
||||
// v1.42.2 — notification quick answers (ntfy `http` actions). The signed
|
||||
// single-use action token is the request's only credential and rides the
|
||||
// trailing path segment of `/api/notifications/action/<token>`.
|
||||
{ prefix: "/api/notifications/action/" },
|
||||
];
|
||||
|
||||
function redactPathSegments(input: string): string {
|
||||
|
||||
@@ -59,7 +59,8 @@ export interface WideEvent {
|
||||
| "api_key"
|
||||
| "cron_secret"
|
||||
| "telegram_webhook"
|
||||
| "webhook_secret";
|
||||
| "webhook_secret"
|
||||
| "notification_action_token";
|
||||
/**
|
||||
* v1.36.0 — the account whose record this request read or wrote, when it
|
||||
* is not the caller's own. `user_id` above always stays the ACTOR: the
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* write so the two entry points agree on the canonical shape: the same
|
||||
* `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz`
|
||||
* anchoring, the same cache invalidation + DAY-rollup recompute the route
|
||||
* fires. `source` is pinned to "TELEGRAM"
|
||||
* (already an accepted free-text source value, no enum migration needed).
|
||||
* fires. `source` is "TELEGRAM" by default; a notification quick answer
|
||||
* (`/api/notifications/action/<token>`) writes the same shape under
|
||||
* "NOTIFICATION". Both are free-text source values, no enum migration.
|
||||
*
|
||||
* The `userId` is NEVER taken from the Telegram payload — the caller
|
||||
* passes the id it resolved from the stored `telegramChatId` binding.
|
||||
@@ -25,6 +26,9 @@ import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** The quick-write sources that share this helper. */
|
||||
export type QuickMoodSource = "TELEGRAM" | "NOTIFICATION";
|
||||
|
||||
export interface TelegramMoodResult {
|
||||
/** True when a new row was written; false when the idempotency key already had one. */
|
||||
created: boolean;
|
||||
@@ -47,8 +51,11 @@ export async function logTelegramMood(input: {
|
||||
/** Stable per-tap id for Telegram redelivery dedup. */
|
||||
externalId: string;
|
||||
note?: string | null;
|
||||
/** Provenance of the quick write. Default "TELEGRAM". */
|
||||
source?: QuickMoodSource;
|
||||
client?: PrismaClient | Prisma.TransactionClient;
|
||||
}): Promise<TelegramMoodResult> {
|
||||
const source = input.source ?? "TELEGRAM";
|
||||
const prisma = (input.client ?? defaultPrisma) as PrismaClient;
|
||||
const mood = MOOD_ENUM_BY_SCORE[input.score];
|
||||
if (!mood) {
|
||||
@@ -63,7 +70,7 @@ export async function logTelegramMood(input: {
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: input.userId,
|
||||
source: "TELEGRAM",
|
||||
source,
|
||||
externalId: input.externalId,
|
||||
},
|
||||
},
|
||||
@@ -88,7 +95,7 @@ export async function logTelegramMood(input: {
|
||||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(input.note ?? null),
|
||||
source: "TELEGRAM",
|
||||
source,
|
||||
externalId: input.externalId,
|
||||
moodLoggedAt,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Signed, short-lived, single-use tokens behind notification quick actions.
|
||||
*
|
||||
* A quick action is a button inside a notification ("Good", "Okay", "Bad"
|
||||
* on the mood reminder) that the notification app calls directly, with no
|
||||
* HealthLog session: ntfy's `http` action POSTs to a URL and nothing else.
|
||||
* The URL therefore carries the whole credential, and this module is what
|
||||
* makes that safe:
|
||||
*
|
||||
* - Bound. The token names the user and the one action it performs. A
|
||||
* token minted for one user cannot act for another (the user id is inside
|
||||
* the signed payload), and a token for "okay" cannot log "bad".
|
||||
* - Signed. HMAC-SHA256 over the payload, under a subkey derived from
|
||||
* `API_TOKEN_HMAC_KEY` with a fixed label, so the tag can never collide
|
||||
* with an OAuth state or a Bearer-token hash made from the same root key.
|
||||
* Compared with `timingSafeEqual`.
|
||||
* - Short-lived. The expiry is inside the signed payload; a token is good
|
||||
* for `ACTION_TOKEN_TTL_MS` after the reminder was sent.
|
||||
* - Single-use. The three answers of one reminder share one nonce, and the
|
||||
* write it authorises is keyed on that nonce (`MoodEntry`'s unique
|
||||
* `(userId, source, externalId)`), so the first answer wins and every
|
||||
* later tap on the same reminder, whichever button, is refused. The
|
||||
* database constraint is the arbiter, so two concurrent taps cannot both
|
||||
* write.
|
||||
*
|
||||
* Layout: `na1.<userId>.<action>.<expiryMs>.<nonce>.<tag>`. Every field is
|
||||
* free of `.` by construction (a cuid, a fixed action id, digits, base64url),
|
||||
* so the split is unambiguous, and every field is URL-path safe.
|
||||
*/
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* The mood quick answers and the five-point mood scale value each one logs
|
||||
* (`MOOD_ENUM_BY_SCORE`: 4 = GUT, 3 = OKAY, 2 = SCHLECHT). Three answers,
|
||||
* because ntfy shows at most three actions; the scale's outer points
|
||||
* (1 and 5) stay in the app and in the Telegram keyboard, which shows all
|
||||
* five.
|
||||
*/
|
||||
export const MOOD_QUICK_ACTIONS = {
|
||||
"mood-good": 4,
|
||||
"mood-okay": 3,
|
||||
"mood-bad": 2,
|
||||
} as const;
|
||||
|
||||
export type QuickActionId = keyof typeof MOOD_QUICK_ACTIONS;
|
||||
|
||||
export const QUICK_ACTION_IDS = Object.keys(
|
||||
MOOD_QUICK_ACTIONS,
|
||||
) as QuickActionId[];
|
||||
|
||||
/**
|
||||
* Six hours: long enough to answer a reminder sent at the chosen evening
|
||||
* hour before going to bed, short enough that a notification left on a
|
||||
* phone overnight no longer writes anything the next day.
|
||||
*/
|
||||
export const ACTION_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
const VERSION = "na1";
|
||||
const SEP = ".";
|
||||
const MAX_TOKEN_LENGTH = 300;
|
||||
const KEY_LABEL = "healthlog:notification-action:v1";
|
||||
|
||||
function signingKey(): Buffer {
|
||||
const root = process.env.API_TOKEN_HMAC_KEY;
|
||||
if (!root || root.length < 32) {
|
||||
throw new Error(
|
||||
"API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign notification actions",
|
||||
);
|
||||
}
|
||||
return createHmac("sha256", root).update(KEY_LABEL).digest();
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
return createHmac("sha256", signingKey()).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
function isQuickActionId(value: string): value is QuickActionId {
|
||||
return Object.prototype.hasOwnProperty.call(MOOD_QUICK_ACTIONS, value);
|
||||
}
|
||||
|
||||
/** A fresh nonce shared by every action offered on one notification. */
|
||||
export function newActionNonce(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
export function mintActionToken(input: {
|
||||
userId: string;
|
||||
action: QuickActionId;
|
||||
nonce: string;
|
||||
now?: number;
|
||||
}): string {
|
||||
const expiry = (input.now ?? Date.now()) + ACTION_TOKEN_TTL_MS;
|
||||
const payload = [
|
||||
VERSION,
|
||||
input.userId,
|
||||
input.action,
|
||||
String(expiry),
|
||||
input.nonce,
|
||||
].join(SEP);
|
||||
return `${payload}${SEP}${sign(payload)}`;
|
||||
}
|
||||
|
||||
export type ActionTokenVerdict =
|
||||
| {
|
||||
ok: true;
|
||||
userId: string;
|
||||
action: QuickActionId;
|
||||
nonce: string;
|
||||
expiry: number;
|
||||
}
|
||||
| { ok: false; reason: "malformed" | "bad_signature" | "expired" };
|
||||
|
||||
/**
|
||||
* Verify a token. The signature is checked before the expiry, so an expired
|
||||
* verdict is only ever given for a token this server actually minted; a
|
||||
* forged or altered token reads as `bad_signature` whatever its expiry says.
|
||||
* Never throws on bad input (only on a missing server key, which is an
|
||||
* operator misconfiguration).
|
||||
*/
|
||||
export function verifyActionToken(
|
||||
token: string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): ActionTokenVerdict {
|
||||
if (!token || token.length > MAX_TOKEN_LENGTH) {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
const parts = token.split(SEP);
|
||||
if (parts.length !== 6) return { ok: false, reason: "malformed" };
|
||||
const [version, userId, action, expiryStr, nonce, tag] = parts as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
if (version !== VERSION || !userId || !nonce || !tag) {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
if (!isQuickActionId(action)) return { ok: false, reason: "malformed" };
|
||||
const expiry = Number.parseInt(expiryStr, 10);
|
||||
if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
|
||||
const expected = Buffer.from(
|
||||
sign([version, userId, action, expiryStr, nonce].join(SEP)),
|
||||
);
|
||||
const received = Buffer.from(tag);
|
||||
if (
|
||||
received.length !== expected.length ||
|
||||
!timingSafeEqual(received, expected)
|
||||
) {
|
||||
return { ok: false, reason: "bad_signature" };
|
||||
}
|
||||
if (now >= expiry) return { ok: false, reason: "expired" };
|
||||
|
||||
return { ok: true, userId, action, nonce, expiry };
|
||||
}
|
||||
|
||||
/** The `MoodEntry.externalId` that makes one reminder's answers single-use. */
|
||||
export function quickActionExternalId(nonce: string): string {
|
||||
return `notification-action:${nonce}`;
|
||||
}
|
||||
|
||||
/** The public route a quick action calls. */
|
||||
export const QUICK_ACTION_ROUTE_PREFIX = "/api/notifications/action/";
|
||||
|
||||
export interface NotificationQuickAction {
|
||||
id: QuickActionId;
|
||||
label: string;
|
||||
/** Absolute URL the notification app POSTs to. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three mood answers for one reminder, sharing one nonce. Needs the
|
||||
* public origin: an app on a phone can only call an absolute URL.
|
||||
*/
|
||||
export function buildMoodQuickActions(input: {
|
||||
userId: string;
|
||||
origin: string;
|
||||
labels: Record<QuickActionId, string>;
|
||||
now?: number;
|
||||
}): NotificationQuickAction[] {
|
||||
const nonce = newActionNonce();
|
||||
return QUICK_ACTION_IDS.map((id) => ({
|
||||
id,
|
||||
label: input.labels[id],
|
||||
url: `${input.origin}${QUICK_ACTION_ROUTE_PREFIX}${mintActionToken({
|
||||
userId: input.userId,
|
||||
action: id,
|
||||
nonce,
|
||||
now: input.now,
|
||||
})}`,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Attach the tap target and the quick actions to a delivery.
|
||||
*
|
||||
* Called by the dispatcher once per recipient, after the delivery identity
|
||||
* is known and before the channel cascade, so every channel of one dispatch
|
||||
* carries the same target and the same (single-use) action tokens. The
|
||||
* senders only translate these fields into their own wire format; none of
|
||||
* them decides where a notification leads.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
buildMoodQuickActions,
|
||||
type QuickActionId,
|
||||
} from "@/lib/notifications/action-token";
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
import {
|
||||
absoluteAppUrl,
|
||||
configuredAppOrigin,
|
||||
resolveTapPath,
|
||||
} from "@/lib/notifications/tap-target";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
|
||||
const QUICK_ACTION_LABEL_KEYS: Record<QuickActionId, string> = {
|
||||
"mood-good": "notifications.quickActions.moodGood",
|
||||
"mood-okay": "notifications.quickActions.moodOkay",
|
||||
"mood-bad": "notifications.quickActions.moodBad",
|
||||
};
|
||||
|
||||
async function recipientLocale(userId: string): Promise<Locale> {
|
||||
try {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { locale: true },
|
||||
});
|
||||
const value = row?.locale;
|
||||
if (value && (locales as readonly string[]).includes(value)) {
|
||||
return value as Locale;
|
||||
}
|
||||
} catch {
|
||||
// A failed read only costs the label its language.
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this delivery may offer the mood quick answers: a mood reminder
|
||||
* delivered to the account it is about. A Guardian never gets a button that
|
||||
* writes into a record they are only notified about.
|
||||
*/
|
||||
function offersMoodActions(payload: NotificationPayload): boolean {
|
||||
return (
|
||||
payload.eventType === "MOOD_REMINDER" &&
|
||||
!payload.suppressActions &&
|
||||
!payload.discreet &&
|
||||
!isManagedGuardianDelivery(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export async function withDeliveryLinks(
|
||||
payload: NotificationPayload,
|
||||
recipientUserId: string,
|
||||
now: number = Date.now(),
|
||||
): Promise<NotificationPayload> {
|
||||
const path = resolveTapPath(payload);
|
||||
const moodActions = offersMoodActions(payload);
|
||||
if (!path && !moodActions) return payload;
|
||||
|
||||
const origin = configuredAppOrigin();
|
||||
const { t } = getServerTranslator(await recipientLocale(recipientUserId));
|
||||
|
||||
const next: NotificationPayload = { ...payload };
|
||||
if (path) {
|
||||
next.tap = {
|
||||
path,
|
||||
url: absoluteAppUrl(path, origin),
|
||||
label: t("notifications.links.open"),
|
||||
};
|
||||
}
|
||||
|
||||
if (moodActions && origin) {
|
||||
try {
|
||||
next.quickActions = buildMoodQuickActions({
|
||||
userId: recipientUserId,
|
||||
origin,
|
||||
labels: {
|
||||
"mood-good": t(QUICK_ACTION_LABEL_KEYS["mood-good"]),
|
||||
"mood-okay": t(QUICK_ACTION_LABEL_KEYS["mood-okay"]),
|
||||
"mood-bad": t(QUICK_ACTION_LABEL_KEYS["mood-bad"]),
|
||||
},
|
||||
now,
|
||||
});
|
||||
} catch (err) {
|
||||
// No signing key: the reminder still goes out, only without answers.
|
||||
getEvent()?.addWarning(
|
||||
`notification quick actions unavailable: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
hasClientManagedApnsGate,
|
||||
} from "@/lib/notifications/client-managed-apns";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { withDeliveryLinks } from "@/lib/notifications/delivery-links";
|
||||
import {
|
||||
claimManagedGuardianEgressAuthorization,
|
||||
completeManagedGuardianEgressAuthorization,
|
||||
@@ -131,8 +132,10 @@ export async function dispatchNotification(
|
||||
recordUserId: delivery.recordUserId,
|
||||
recipientUserId: delivery.recipientUserId,
|
||||
};
|
||||
const recipientPayload = await renderForRecipient(
|
||||
deliveryPayload,
|
||||
// Tap target + quick actions, resolved once so every channel of this
|
||||
// delivery carries the same target and the same single-use tokens.
|
||||
const recipientPayload = await withDeliveryLinks(
|
||||
await renderForRecipient(deliveryPayload, delivery.recipientUserId),
|
||||
delivery.recipientUserId,
|
||||
);
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
|
||||
@@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
import { stripHtml } from "@/lib/notifications/strip-html";
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
import type { NotificationTap } from "@/lib/notifications/types";
|
||||
|
||||
export interface ApnsPayload {
|
||||
/** APNs `aps.alert.title` + `aps.alert.body`. */
|
||||
@@ -597,6 +598,7 @@ export async function sendViaApns(
|
||||
suppressActions?: boolean;
|
||||
recordUserId?: string;
|
||||
recipientUserId?: string;
|
||||
tap?: NotificationTap;
|
||||
},
|
||||
): Promise<SendOutcome> {
|
||||
const config = loadApnsConfig();
|
||||
@@ -751,6 +753,13 @@ export async function sendViaApns(
|
||||
? stripCycleMetadata(payload.metadata)
|
||||
: payload.metadata,
|
||||
),
|
||||
// The resolved tap target (`tap-target.ts`): a same-origin path
|
||||
// the app opens on tap. Overrides a producer's `metadata.url`,
|
||||
// which the resolver already took into account. Never on a
|
||||
// managed delivery or a discreet one (the resolver returns null).
|
||||
...(!managedDelivery && payload.tap?.path
|
||||
? { url: payload.tap.path }
|
||||
: {}),
|
||||
},
|
||||
threadId: routingEvent,
|
||||
// The iOS app registers a `UNNotificationCategory` per event-type
|
||||
|
||||
@@ -163,7 +163,11 @@ export async function sendViaEmail(
|
||||
|
||||
// Plain text only — strip HTML + decorative emoji on routine reminders.
|
||||
const subject = plainPushText(payload.title, payload.eventType);
|
||||
const text = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
const body = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
// The tap target as a plain link line (plain text body, no markup).
|
||||
const text = payload.tap?.url
|
||||
? `${body}\n\n${payload.tap.label}: ${payload.tap.url}`
|
||||
: body;
|
||||
|
||||
try {
|
||||
await transport.transporter.sendMail({
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function sendViaNtfy(
|
||||
? "reminder"
|
||||
: payload.eventType.toLowerCase().replace(/_/g, "-");
|
||||
const headers: Record<string, string> = {
|
||||
Title: plainPushText(payload.title, payload.eventType),
|
||||
Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)),
|
||||
Priority: priority,
|
||||
// Discreet mode (cycle privacy): the X-Tags header is visible on the
|
||||
// lock screen, so collapse it to a generic tag instead of leaking the
|
||||
@@ -71,6 +71,16 @@ export async function sendViaNtfy(
|
||||
headers["Authorization"] = `Bearer ${config.authToken}`;
|
||||
}
|
||||
|
||||
// Tapping the notification opens this URL (ntfy `Click`). Absent when
|
||||
// the operator has not configured a public origin.
|
||||
if (payload.tap?.url) {
|
||||
headers["Click"] = payload.tap.url;
|
||||
}
|
||||
const actions = buildNtfyActionsHeader(payload);
|
||||
if (actions) {
|
||||
headers["Actions"] = ntfyHeaderValue(actions);
|
||||
}
|
||||
|
||||
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
|
||||
const body = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
|
||||
@@ -187,3 +197,51 @@ export async function sendViaNtfy(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** ntfy shows at most three actions per notification. */
|
||||
const NTFY_MAX_ACTIONS = 3;
|
||||
|
||||
/**
|
||||
* A label safe inside the `Actions` header's short format: `,` and `;`
|
||||
* separate fields and actions there, and a quote would open a quoted value.
|
||||
*/
|
||||
function ntfyActionLabel(label: string): string {
|
||||
return label.replace(/[,;"'\r\n]/g, " ").trim() || "?";
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Actions` header for the payload's quick answers, in ntfy's short
|
||||
* format (https://docs.ntfy.sh/publish/#action-buttons):
|
||||
*
|
||||
* http, <label>, <url>, method=POST, clear=true; …
|
||||
*
|
||||
* `clear=true` dismisses the notification once the request succeeded, so a
|
||||
* reminder answered from the shade does not linger. The token in the URL is
|
||||
* the request's only credential (see `action-token.ts`).
|
||||
*/
|
||||
export function buildNtfyActionsHeader(
|
||||
payload: Pick<NotificationPayload, "quickActions">,
|
||||
): string | null {
|
||||
const actions = (payload.quickActions ?? []).slice(0, NTFY_MAX_ACTIONS);
|
||||
if (actions.length === 0) return null;
|
||||
return actions
|
||||
.map(
|
||||
(action) =>
|
||||
`http, ${ntfyActionLabel(action.label)}, ${action.url}, method=POST, clear=true`,
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* A header value fetch can send and ntfy reads as UTF-8.
|
||||
*
|
||||
* `fetch` refuses any header character above U+00FF outright (a Korean
|
||||
* title or action label would fail the whole send), and a Latin-1 byte such
|
||||
* as `ü` reaches ntfy as invalid UTF-8. ntfy decodes RFC 2047 encoded words
|
||||
* in every header, so a value with anything beyond printable ASCII travels as
|
||||
* one `=?UTF-8?B?…?=` word; plain ASCII stays readable on the wire.
|
||||
*/
|
||||
export function ntfyHeaderValue(value: string): string {
|
||||
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
||||
return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
|
||||
}
|
||||
@@ -15,6 +15,18 @@ import {
|
||||
buildMeasurementKeyboard,
|
||||
} from "@/lib/notifications/senders/telegram-keyboards";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { telegramLinkMode } from "@/lib/notifications/tap-target";
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return escapeHtmlText(value).replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.19.0 — resolve the bot locale for an interactive reminder. The
|
||||
@@ -197,13 +209,31 @@ export async function sendViaTelegram(
|
||||
}
|
||||
: undefined);
|
||||
|
||||
// Tap target. Telegram has no "tap opens a URL" for a message, so the
|
||||
// target rides an inline URL button under it (below any action row), or a
|
||||
// text link where Telegram would refuse the button (see `telegramLinkMode`).
|
||||
const linkMode = payload.tap?.url ? telegramLinkMode(payload.tap.url) : "none";
|
||||
const linkedKeyboard =
|
||||
linkMode === "button" && payload.tap?.url
|
||||
? {
|
||||
inline_keyboard: [
|
||||
...(keyboard?.inline_keyboard ?? []),
|
||||
[{ text: payload.tap.label, url: payload.tap.url }],
|
||||
],
|
||||
}
|
||||
: keyboard;
|
||||
const text =
|
||||
linkMode === "text" && payload.tap?.url
|
||||
? `${payload.message}\n\n<a href="${escapeHtmlAttribute(payload.tap.url)}">${escapeHtmlText(payload.tap.label)}</a>`
|
||||
: payload.message;
|
||||
|
||||
const result = await sendTelegramMessage(
|
||||
config.botToken,
|
||||
config.chatId,
|
||||
payload.message,
|
||||
text,
|
||||
{
|
||||
parseMode: "HTML",
|
||||
replyMarkup: keyboard,
|
||||
replyMarkup: linkedKeyboard,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
import { stripHtml } from "@/lib/notifications/strip-html";
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
import { isSafeAppPath } from "@/lib/notifications/tap-target";
|
||||
|
||||
/**
|
||||
* Send Web Push notification to all subscribed devices of a user.
|
||||
@@ -115,12 +116,12 @@ export async function sendViaWebPush(
|
||||
// `/coach`); everything else keeps the legacy "/" click
|
||||
// target. Only relative paths are honoured so a poisoned metadata
|
||||
// value can never point the SW at a foreign origin.
|
||||
url:
|
||||
!managedDelivery &&
|
||||
typeof payload.metadata?.url === "string" &&
|
||||
payload.metadata.url.startsWith("/")
|
||||
? payload.metadata.url
|
||||
: "/",
|
||||
// The dispatcher's resolved tap target (`tap-target.ts`) wins; a payload
|
||||
// that did not pass through the dispatcher keeps the metadata path.
|
||||
url: managedDelivery
|
||||
? "/"
|
||||
: (payload.tap?.path ??
|
||||
(isSafeAppPath(payload.metadata?.url) ? payload.metadata.url : "/")),
|
||||
// v1.18.4 — urgent events ask the service worker to keep the
|
||||
// notification on screen until the user acts on it (where the
|
||||
// browser honours `requireInteraction`).
|
||||
|
||||
@@ -69,6 +69,9 @@ export function buildWebhookBody(
|
||||
const message = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
const eventType = payload.discreet ? "reminder" : payload.eventType;
|
||||
const band = priorityBand(payload);
|
||||
// The tap target as an absolute URL, when the operator configured a public
|
||||
// origin (`tap-target.ts`).
|
||||
const url = payload.tap?.url ?? null;
|
||||
|
||||
if (config.format === "gotify") {
|
||||
return JSON.stringify({
|
||||
@@ -80,6 +83,9 @@ export function buildWebhookBody(
|
||||
// body is never markdown (hard rule).
|
||||
"client::display": { contentType: "text/plain" },
|
||||
"healthlog::event": { type: eventType },
|
||||
// Gotify's Android client opens this URL when the notification is
|
||||
// tapped.
|
||||
...(url ? { "client::notification": { click: { url } } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -102,6 +108,9 @@ export function buildWebhookBody(
|
||||
content: truncateCodePoints(text, DISCORD_CONTENT_LIMIT),
|
||||
text,
|
||||
allowed_mentions: { parse: [] },
|
||||
// Additive: where the notification leads in the app, for a relay rule
|
||||
// that wants to attach a link. Absent without a public origin.
|
||||
...(url ? { url } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Where a notification takes the user when they tap it.
|
||||
*
|
||||
* One map, one resolver. Every event type names the in-app path it opens;
|
||||
* the dispatcher resolves the path once per delivery and every channel
|
||||
* carries the same target in its own vocabulary:
|
||||
*
|
||||
* - ntfy → `Click` header (absolute URL)
|
||||
* - Telegram → an inline URL button (absolute `https` URL only, see
|
||||
* `telegramLinkMode`)
|
||||
* - Web Push → `url` in the service-worker payload (same-origin path)
|
||||
* - APNs → `url` in the custom payload (same-origin path)
|
||||
* - Webhook → `url` in the body (absolute URL)
|
||||
* - Email → a link line under the body (absolute URL)
|
||||
*
|
||||
* The map is typed `Record<EventType, …>`, so a new event type without a
|
||||
* target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a
|
||||
* target that stops resolving fails there too.
|
||||
*
|
||||
* A producer may name a more precise path in `metadata.url` (the low-stock
|
||||
* alert opens the medication's stock tab, the Coach nudge opens its own
|
||||
* conversation). That override is accepted only as a same-origin path, so a
|
||||
* value that names another host can never become a click target.
|
||||
*/
|
||||
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
|
||||
/**
|
||||
* The default in-app path per event type. `null` means the event is never
|
||||
* shown to the user (a silent background push), so it carries no target.
|
||||
*/
|
||||
export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
|
||||
MEDICATION_REMINDER: "/medications",
|
||||
MEASUREMENT_ANOMALY: "/measurements",
|
||||
COMPLIANCE_LOW: "/insights/medications",
|
||||
WITHINGS_SYNC_FAILED: "/settings/integrations",
|
||||
SYSTEM_ALERT: "/",
|
||||
PERSONAL_RECORD: "/achievements",
|
||||
// `?add=1` opens the mood entry sheet on the first paint.
|
||||
MOOD_REMINDER: "/mood?add=1",
|
||||
CYCLE_PERIOD_SOON: "/cycle",
|
||||
CYCLE_PERIOD_CONFIRM: "/cycle",
|
||||
CYCLE_FERTILE_SOON: "/cycle",
|
||||
COACH_NUDGE: "/coach",
|
||||
MEDICATION_LOW_STOCK: "/medications",
|
||||
MEASUREMENT_REMINDER: "/checkups",
|
||||
MEDICATION_INTAKE_SYNC: null,
|
||||
SECURITY_ALERT: "/settings/security",
|
||||
DAILY_BRIEFING: "/",
|
||||
SHARED_RECORD_INTAKE: "/medications",
|
||||
};
|
||||
|
||||
const MAX_PATH_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* A same-origin, absolute-path reference: one leading slash, no scheme, no
|
||||
* protocol-relative `//host`, no backslash (browsers normalise `/\host` to
|
||||
* `//host`), no whitespace or control characters.
|
||||
*/
|
||||
export function isSafeAppPath(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
|
||||
if (!value.startsWith("/")) return false;
|
||||
if (value.startsWith("//")) return false;
|
||||
if (value.includes("\\")) return false;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\s\u0000-\u001f\u007f]/.test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a delivery opens, or `null` when it carries none.
|
||||
*
|
||||
* - A Guardian receiving a managed-record notification gets no deep link:
|
||||
* their session opens on their own record, and a path into the managed
|
||||
* record's surfaces would land them on the wrong person. The app root is
|
||||
* where the record switcher lives.
|
||||
* - Discreet mode (cycle privacy) carries no path either, because the path
|
||||
* names the surface (`/cycle`) and discreet mode promises that nothing on
|
||||
* the wire names the event.
|
||||
*/
|
||||
export function resolveTapPath(
|
||||
payload: Pick<
|
||||
NotificationPayload,
|
||||
"eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId"
|
||||
>,
|
||||
): string | null {
|
||||
const fallback = NOTIFICATION_TAP_PATHS[payload.eventType];
|
||||
if (fallback === null || fallback === undefined) return null;
|
||||
if (isManagedGuardianDelivery(payload)) return null;
|
||||
if (payload.discreet) return null;
|
||||
const override = payload.metadata?.url;
|
||||
return isSafeAppPath(override) ? override : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator-configured public origin (`APP_URL`, then
|
||||
* `NEXT_PUBLIC_APP_URL`), the same precedence the invite and handover links
|
||||
* use. Env only: a notification is sent from a worker with no request, and
|
||||
* the `Host` header is never trusted for a link that leaves the server.
|
||||
*/
|
||||
export function configuredAppOrigin(): string | null {
|
||||
for (const value of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") continue;
|
||||
return url.origin;
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Absolute URL for a path, or `null` when no public origin is configured. */
|
||||
export function absoluteAppUrl(
|
||||
path: string | null,
|
||||
origin: string | null = configuredAppOrigin(),
|
||||
): string | null {
|
||||
if (!path || !origin) return null;
|
||||
return `${origin}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How Telegram may carry a link.
|
||||
*
|
||||
* Telegram refuses an inline URL button whose URL is not `https` or whose
|
||||
* host is `localhost` (the whole `sendMessage` fails with "BUTTON_URL_INVALID"),
|
||||
* so the button is offered only for a public `https` origin. A plain-HTTP
|
||||
* self-host still gets a text link under the message; a loopback origin gets
|
||||
* nothing, because no phone can open it.
|
||||
*/
|
||||
export function telegramLinkMode(url: string): "button" | "text" | "none" {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const loopback =
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
host === "[::1]" ||
|
||||
host === "::1" ||
|
||||
/^127\./.test(host) ||
|
||||
host === "0.0.0.0";
|
||||
if (loopback) return "none";
|
||||
if (parsed.protocol === "https:") return "button";
|
||||
if (parsed.protocol === "http:") return "text";
|
||||
return "none";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { NotificationQuickAction } from "@/lib/notifications/action-token";
|
||||
|
||||
/**
|
||||
* Notification system type definitions.
|
||||
@@ -355,6 +356,29 @@ export interface NotificationPayload {
|
||||
* removes is the OFFER to act on it.
|
||||
*/
|
||||
suppressActions?: boolean;
|
||||
/**
|
||||
* Where tapping the notification leads, resolved once per delivery by the
|
||||
* dispatcher from `NOTIFICATION_TAP_PATHS` (`tap-target.ts`). Producers
|
||||
* never set it; a producer that knows a more precise path says so in
|
||||
* `metadata.url`. `path` is a same-origin path (Web Push, APNs), `url` the
|
||||
* same target as an absolute URL on the operator's public origin (ntfy,
|
||||
* Telegram, webhook, email), `null` when no public origin is configured.
|
||||
* `label` is the localised text of a link button.
|
||||
*/
|
||||
tap?: NotificationTap;
|
||||
/**
|
||||
* One-tap answers the channel can offer inside the notification (ntfy
|
||||
* `http` actions). Minted by the dispatcher for a self-delivered mood
|
||||
* reminder; each URL carries a signed single-use token
|
||||
* (`action-token.ts`).
|
||||
*/
|
||||
quickActions?: NotificationQuickAction[];
|
||||
}
|
||||
|
||||
export interface NotificationTap {
|
||||
path: string | null;
|
||||
url: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,10 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { CHANNEL_TYPES, EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
QUICK_ACTION_IDS,
|
||||
type QuickActionId,
|
||||
} from "@/lib/notifications/action-token";
|
||||
import {
|
||||
notificationPreferenceSchema,
|
||||
reEnableChannelSchema,
|
||||
@@ -148,8 +152,67 @@ const reEnableChannelRequest = reEnableChannelSchema.meta({
|
||||
"Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404.",
|
||||
});
|
||||
|
||||
const notificationActionResult = z
|
||||
.object({
|
||||
logged: z.literal(true),
|
||||
action: z
|
||||
.enum(QUICK_ACTION_IDS as [QuickActionId, ...QuickActionId[]])
|
||||
.describe(
|
||||
"The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the five-point mood scale.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationActionResult",
|
||||
description: "A quick answer from a notification, logged as a mood entry.",
|
||||
});
|
||||
|
||||
export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
{
|
||||
"/api/notifications/action/{token}": {
|
||||
post: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Log a quick answer tapped inside a notification",
|
||||
description:
|
||||
"Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is the only credential. The token is bound to one user and one answer, expires six hours after the reminder was sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe("Signed action token from the notification's URL."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The answer was logged.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
notificationActionResult,
|
||||
"NotificationActionResult",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"401": {
|
||||
description: "Invalid token (forged, altered, or unknown user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description: "The mood module is turned off for this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description: "This reminder was already answered.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"410": {
|
||||
description: "The answer expired.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/notifications/preferences": {
|
||||
get: {
|
||||
tags: ["Notifications"],
|
||||
|
||||
+4
-4
@@ -11,10 +11,10 @@ interface TelegramResponse {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface TelegramInlineKeyboardButton {
|
||||
text: string;
|
||||
callback_data: string;
|
||||
}
|
||||
/** A callback button, or a button that opens a URL (`https` only). */
|
||||
type TelegramInlineKeyboardButton =
|
||||
| { text: string; callback_data: string }
|
||||
| { text: string; url: string };
|
||||
|
||||
interface TelegramReplyMarkup {
|
||||
inline_keyboard: TelegramInlineKeyboardButton[][];
|
||||
|
||||
@@ -46,6 +46,8 @@ export const moodSourceEnum = z.enum([
|
||||
"WEB",
|
||||
"TELEGRAM",
|
||||
"DAYLIO",
|
||||
// A quick answer tapped inside a mood-reminder notification (ntfy action).
|
||||
"NOTIFICATION",
|
||||
]);
|
||||
|
||||
const MOOD_SCORE_MAP: Record<string, number> = {
|
||||
|
||||
@@ -41,6 +41,11 @@ export const PUBLIC_PATHS = [
|
||||
"/api/health",
|
||||
"/api/version",
|
||||
"/api/notifications/vapid",
|
||||
// v1.42.2 — notification quick answers. A notification app (ntfy's `http`
|
||||
// action) calls this with no session; the signed, single-use, short-lived
|
||||
// token in the path is the whole credential, verified by the route after
|
||||
// its rate limit. Trailing slash: admits only `/action/<token>`.
|
||||
"/api/notifications/action/",
|
||||
"/api/monitoring/",
|
||||
"/api/withings/webhook",
|
||||
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /
|
||||
|
||||
Reference in new issue
Block a user