Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f48a20ad55 | |||
| 09ca3927bc | |||
| ae5aba29ad | |||
| de31ac7eb7 | |||
| e2ed25059a |
+7
-1
@@ -13,6 +13,12 @@ PORT=3000
|
|||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=warn
|
LOG_LEVEL=warn
|
||||||
|
|
||||||
|
# Public base URL used for notification action links.
|
||||||
|
# Required for intake reminder action buttons/links.
|
||||||
|
# PUBLIC_APP_URL=https://medassist.example.com
|
||||||
|
# For mobile testing on the same LAN, use your laptop IP instead of localhost,
|
||||||
|
# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS.
|
||||||
|
|
||||||
# Levels: debug, info, warn, error, silent
|
# Levels: debug, info, warn, error, silent
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||||
# and frontend browser console (via build-time injection)
|
# and frontend browser console (via build-time injection)
|
||||||
@@ -149,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
|||||||
# UI defaults
|
# UI defaults
|
||||||
# DEFAULT_LANGUAGE=en # en or de
|
# DEFAULT_LANGUAGE=en # en or de
|
||||||
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
||||||
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
|
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
|
||||||
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
# MedAssist-ng - Copilot Entry Point
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## VERY IMPORTANT - Prioritized Constraints
|
## VERY IMPORTANT
|
||||||
|
|
||||||
**First: Update Memory and Reports**
|
|
||||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
- If `doku/memory_notes.md` is missing, create it immediately.
|
|
||||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
- If `doku/report.md` is missing, create it immediately.
|
|
||||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
**Second: Follow Governance Rules**
|
|
||||||
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
|
|
||||||
|
|
||||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
## Required Startup Steps
|
## Required Startup Steps
|
||||||
|
|||||||
+1
-15
@@ -107,18 +107,4 @@ docs/SPEC_KIT.md
|
|||||||
.github/skills/nodejs-backend-patterns/
|
.github/skills/nodejs-backend-patterns/
|
||||||
.github/skills/nodejs-best-practices/
|
.github/skills/nodejs-best-practices/
|
||||||
.github/skills/seo/
|
.github/skills/seo/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
|
||||||
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
|
||||||
.github/agents/copilot-instructions.md
|
|
||||||
.github/agents/gsd-*.agent.md
|
|
||||||
.github/agents/medassist-feature-orchestrator.agent.md
|
|
||||||
.github/agents/speckit.*.agent.md
|
|
||||||
.github/get-shit-done/
|
|
||||||
.github/gsd-file-manifest.json
|
|
||||||
.github/prompts/speckit.*.prompt.md
|
|
||||||
.github/skills/gsd-*/
|
|
||||||
.planning/
|
|
||||||
doku/memory_notes.md
|
|
||||||
doku/report.md
|
|
||||||
ops/medtest/
|
|
||||||
@@ -378,14 +378,6 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
|
|||||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||||
|
|
||||||
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
|
|
||||||
|
|
||||||
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
|
|
||||||
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
|
|
||||||
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
|
|
||||||
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
|
|
||||||
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
|
|
||||||
|
|
||||||
Useful local commands:
|
Useful local commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE `notification_action_groups` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`group_key` text(255) NOT NULL,
|
||||||
|
`sequence_id` text(255) NOT NULL,
|
||||||
|
`dose_ids_json` text NOT NULL,
|
||||||
|
`title` text(255) NOT NULL,
|
||||||
|
`message` text NOT NULL,
|
||||||
|
`language` text(10) DEFAULT 'en' NOT NULL,
|
||||||
|
`scheduled_for` integer,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`resolved_action` text(20),
|
||||||
|
`resolved_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `notification_action_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`group_id` integer NOT NULL,
|
||||||
|
`token_hash` text(128) NOT NULL,
|
||||||
|
`kind` text(20) NOT NULL,
|
||||||
|
`used_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL;
|
||||||
@@ -59,6 +59,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
|
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||||
@@ -75,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
@@ -96,6 +98,31 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_key TEXT NOT NULL UNIQUE,
|
||||||
|
sequence_id TEXT NOT NULL,
|
||||||
|
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
|
||||||
|
dose_ids_json TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
scheduled_for INTEGER,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
resolved_action TEXT,
|
||||||
|
resolved_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -124,6 +151,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
const createIndexMigrations = [
|
const createIndexMigrations = [
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,9 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
||||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
// Current MedAssist versions no longer read or expose this setting in product flows.
|
||||||
|
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
// Whether shared schedule links also embed the medication overview section
|
// Whether shared schedule links also embed the medication overview section
|
||||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||||
// UI timeline visibility preferences
|
// UI timeline visibility preferences
|
||||||
@@ -183,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
|
|||||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Action Groups - Shared action state for reminder notifications
|
||||||
|
// =============================================================================
|
||||||
|
export const notificationActionGroups = sqliteTable("notification_action_groups", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
groupKey: text("group_key", { length: 255 }).notNull().unique(),
|
||||||
|
sequenceId: text("sequence_id", { length: 255 }).notNull(),
|
||||||
|
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
|
||||||
|
doseIdsJson: text("dose_ids_json").notNull(),
|
||||||
|
title: text("title", { length: 255 }).notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
|
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
resolvedAction: text("resolved_action", { length: 20 }),
|
||||||
|
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Action Tokens - Hashed tokens for public reminder responses
|
||||||
|
// =============================================================================
|
||||||
|
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
groupId: integer("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
|
||||||
|
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
|
||||||
|
kind: text("kind", { length: 20 }).notNull(),
|
||||||
|
usedAt: integer("used_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dose Tracking - Tracks when doses are marked as taken
|
// Dose Tracking - Tracks when doses are marked as taken
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -194,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+17
-16
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("3000")
|
||||||
.default(3000),
|
.transform((v) => parseInt(v, 10)),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
OPENAPI_DOCS_ENABLED: z
|
OPENAPI_DOCS_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
|
|||||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default(false),
|
.transform((v) => v === "true"),
|
||||||
// Allow new user registrations (auto-enabled if no users exist)
|
// Allow new user registrations (auto-enabled if no users exist)
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default(false),
|
.transform((v) => v === "true"),
|
||||||
// Disable username/password form login (useful for OIDC-only setups)
|
// Disable username/password form login (useful for OIDC-only setups)
|
||||||
FORM_LOGIN_ENABLED: z
|
FORM_LOGIN_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default(true),
|
.transform((v) => v === "true"),
|
||||||
|
|
||||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
|
|||||||
// Token TTL settings
|
// Token TTL settings
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("15")
|
||||||
.default(15),
|
.transform((v) => parseInt(v, 10)),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("7")
|
||||||
.default(7),
|
.transform((v) => parseInt(v, 10)),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default(false),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default(true),
|
.transform((v) => v === "true"),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { and, desc, eq } from "drizzle-orm";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications, refillHistory } from "../db/schema.js";
|
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
@@ -195,13 +196,22 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refillBaselineAt = new Date();
|
const refillBaselineAt = new Date();
|
||||||
const baselineStockBeforeRefill = isAmountBased
|
const [settings] = await db
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||||
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
.from(userSettings)
|
||||||
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
.where(eq(userSettings.userId, userId));
|
||||||
|
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||||
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
|
const currentStockAtRefill = computeMedicationCurrentStock({
|
||||||
|
medication: med,
|
||||||
|
doses,
|
||||||
|
stockCalculationMode,
|
||||||
|
nowMs: refillBaselineAt.getTime(),
|
||||||
|
});
|
||||||
|
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
|
||||||
|
|
||||||
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
// Update medication stock. Refill establishes a new stock baseline at the current visible
|
||||||
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
|
||||||
let newPackCount = med.packCount + effectivePacksAdded;
|
let newPackCount = med.packCount + effectivePacksAdded;
|
||||||
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const reportDataResponseSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "integer" },
|
||||||
usedPrescription: { type: "boolean" },
|
usedPrescription: { type: "boolean" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
},
|
},
|
||||||
@@ -115,7 +116,16 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Verify all medications belong to this user
|
// Verify all medications belong to this user
|
||||||
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
const userMeds = await db
|
||||||
|
.select({
|
||||||
|
id: medications.id,
|
||||||
|
packageType: medications.packageType,
|
||||||
|
blistersPerPack: medications.blistersPerPack,
|
||||||
|
pillsPerBlister: medications.pillsPerBlister,
|
||||||
|
})
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, userId));
|
||||||
|
const medMap = new Map(userMeds.map((med) => [med.id, med]));
|
||||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||||
|
|
||||||
for (const id of medicationIds) {
|
for (const id of medicationIds) {
|
||||||
@@ -159,7 +169,13 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
dosesSkipped: number;
|
dosesSkipped: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
refills: {
|
||||||
|
packsAdded: number;
|
||||||
|
loosePillsAdded: number;
|
||||||
|
quantityAdded: number;
|
||||||
|
usedPrescription: boolean;
|
||||||
|
refillDate: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -170,6 +186,9 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
const skippedDoses = doses.filter((d) => d.dismissed);
|
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
|
const medication = medMap.get(medId);
|
||||||
|
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||||
|
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||||
|
|
||||||
// Get refills for this medication scoped to the authenticated user.
|
// Get refills for this medication scoped to the authenticated user.
|
||||||
const refills = await db
|
const refills = await db
|
||||||
@@ -186,6 +205,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
refills: refills.map((r) => ({
|
refills: refills.map((r) => ({
|
||||||
packsAdded: r.packsAdded,
|
packsAdded: r.packsAdded,
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ import { eq } from "drizzle-orm";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { userSettings } from "../db/schema.js";
|
import { userSettings } from "../db/schema.js";
|
||||||
|
import { getDateLocale, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import {
|
||||||
|
createTestNotificationActionContext,
|
||||||
|
storeNotificationActionGroupNtfyMessageId,
|
||||||
|
} from "../services/notification-actions-service.js";
|
||||||
|
import {
|
||||||
|
type PushNotificationOptions,
|
||||||
|
renderNotificationActionPayload,
|
||||||
|
} from "../services/notifications/action-renderer.js";
|
||||||
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
|
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
|
||||||
import {
|
import {
|
||||||
classifyTestEmailFailure,
|
classifyTestEmailFailure,
|
||||||
@@ -70,36 +79,6 @@ const settingsErrorSchema = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type MailDeliveryInfo = {
|
|
||||||
accepted?: unknown;
|
|
||||||
rejected?: unknown;
|
|
||||||
response?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeRecipients(value: unknown): string[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value
|
|
||||||
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
|
||||||
const accepted = normalizeRecipients(info.accepted);
|
|
||||||
const rejected = normalizeRecipients(info.rejected);
|
|
||||||
|
|
||||||
if (accepted.length > 0) return null;
|
|
||||||
if (rejected.length > 0) {
|
|
||||||
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof info.response === "string" && info.response.trim()) {
|
|
||||||
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "SMTP did not confirm accepted recipients.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function envInt(key: string, defaultVal: number): number {
|
function envInt(key: string, defaultVal: number): number {
|
||||||
const val = process.env[key];
|
const val = process.env[key];
|
||||||
if (val === undefined) return defaultVal;
|
if (val === undefined) return defaultVal;
|
||||||
@@ -107,6 +86,24 @@ function envInt(key: string, defaultVal: number): number {
|
|||||||
return Number.isNaN(parsed) ? defaultVal : parsed;
|
return Number.isNaN(parsed) ? defaultVal : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLanguage(language: string | null | undefined): Language {
|
||||||
|
return language === "de" ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInteractiveTestPushNotification(language: Language): { title: string; message: string } {
|
||||||
|
const tr = getTranslations(language);
|
||||||
|
const reminderAt = new Date(Date.now() + 60 * 1000);
|
||||||
|
const reminderTime = new Intl.DateTimeFormat(getDateLocale(language), {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(reminderAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t(tr.push.intakeTitle, { minutes: 1 }),
|
||||||
|
message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getOrCreateUserSettings(userId: number) {
|
async function getOrCreateUserSettings(userId: number) {
|
||||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
@@ -552,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
|
const language = getLanguage(settings.language);
|
||||||
|
const { title, message } = buildInteractiveTestPushNotification(language);
|
||||||
|
const actionContext = await createTestNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
publicAppUrl: env.PUBLIC_APP_URL,
|
||||||
|
language,
|
||||||
|
});
|
||||||
const provider = getNotificationProvider(url);
|
const provider = getNotificationProvider(url);
|
||||||
const result = await sendShoutrrrNotification(
|
const result = await sendShoutrrrNotification(url, title, message, {
|
||||||
url,
|
actions: actionContext?.actions,
|
||||||
"MedAssist-ng Test",
|
respondUrl: actionContext?.respondUrl,
|
||||||
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
|
viewUrl: actionContext?.viewUrl,
|
||||||
);
|
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||||
|
sequenceId: actionContext?.sequenceId,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (actionContext?.groupId && result.providerMessageId) {
|
||||||
|
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
request.log.info({ provider }, "[Settings] Test push notification sent");
|
request.log.info({ provider }, "[Settings] Test push notification sent");
|
||||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||||
} else {
|
} else {
|
||||||
@@ -582,8 +598,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
export async function sendShoutrrrNotification(
|
export async function sendShoutrrrNotification(
|
||||||
urlStr: string,
|
urlStr: string,
|
||||||
title: string,
|
title: string,
|
||||||
message: string
|
message: string,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
options: PushNotificationOptions = {}
|
||||||
|
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||||
try {
|
try {
|
||||||
if (urlStr.startsWith("pushover://")) {
|
if (urlStr.startsWith("pushover://")) {
|
||||||
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||||
@@ -736,12 +753,13 @@ export async function sendShoutrrrNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||||
|
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
const method = "POST";
|
const method = "POST";
|
||||||
let headers: Record<string, string> = {};
|
let headers: Record<string, string> = {};
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
|
const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
|
||||||
|
|
||||||
// Remove emojis from title for header compatibility
|
// Remove emojis from title for header compatibility
|
||||||
const cleanTitle = title
|
const cleanTitle = title
|
||||||
@@ -786,19 +804,27 @@ export async function sendShoutrrrNotification(
|
|||||||
// characters (umlauts, accents, etc.) through HTTP headers
|
// characters (umlauts, accents, etc.) through HTTP headers
|
||||||
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
||||||
headers = { Title: encodedTitle, Tags: "pill" };
|
headers = { Title: encodedTitle, Tags: "pill" };
|
||||||
body = message;
|
body = renderedPayload.message;
|
||||||
|
|
||||||
// Add auth if present (extracted during sanitization)
|
// Add auth if present (extracted during sanitization)
|
||||||
if (auth) {
|
if (auth) {
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNtfy) {
|
||||||
|
headers = { ...headers, ...renderedPayload.headers };
|
||||||
|
}
|
||||||
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
||||||
targetUrl = sanitizedUrl;
|
targetUrl = sanitizedUrl;
|
||||||
headers = { "Content-Type": "application/json" };
|
headers = { "Content-Type": "application/json" };
|
||||||
if (isDiscordWebhook) {
|
if (isDiscordWebhook) {
|
||||||
body = JSON.stringify({ content: `${title}\n\n${message}` });
|
body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
|
||||||
} else {
|
} else {
|
||||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
body = JSON.stringify({
|
||||||
|
title,
|
||||||
|
message: renderedPayload.message,
|
||||||
|
text: `${title}\n\n${renderedPayload.message}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
@@ -823,7 +849,17 @@ export async function sendShoutrrrNotification(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return { success: true };
|
let providerMessageId: string | undefined;
|
||||||
|
if (isNtfy) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { id?: unknown };
|
||||||
|
providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
|
||||||
|
} catch {
|
||||||
|
providerMessageId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, providerMessageId };
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type Language,
|
type Language,
|
||||||
t,
|
t,
|
||||||
} from "../i18n/translations.js";
|
} from "../i18n/translations.js";
|
||||||
|
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
@@ -29,6 +31,10 @@ import {
|
|||||||
type UpcomingIntake,
|
type UpcomingIntake,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||||
|
import {
|
||||||
|
createNotificationActionContext,
|
||||||
|
storeNotificationActionGroupNtfyMessageId,
|
||||||
|
} from "./notification-actions-service.js";
|
||||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||||
|
|
||||||
@@ -93,6 +99,31 @@ function getMedicationDisplayName(med: { id: number; name: string | null; generi
|
|||||||
return `Medication #${med.id}`;
|
return `Medication #${med.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPushProviderLabel(url: string): string {
|
||||||
|
const normalizedUrl = url.trim().toLowerCase();
|
||||||
|
if (normalizedUrl.startsWith("ntfy://")) return "ntfy";
|
||||||
|
if (normalizedUrl.startsWith("discord://")) return "discord";
|
||||||
|
if (normalizedUrl.startsWith("pushover://")) return "pushover";
|
||||||
|
if (normalizedUrl.startsWith("gotify://")) return "gotify";
|
||||||
|
if (normalizedUrl.startsWith("telegram://")) return "telegram";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActionContextLog(options: {
|
||||||
|
actionMode: "full" | "view-only";
|
||||||
|
doseCount: number;
|
||||||
|
actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null;
|
||||||
|
}): string {
|
||||||
|
const { actionMode, doseCount, actionContext } = options;
|
||||||
|
return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function autoMarkDueIntakesAsTaken(
|
async function autoMarkDueIntakesAsTaken(
|
||||||
settings: UserSettings & { userId: number },
|
settings: UserSettings & { userId: number },
|
||||||
rows: (typeof medications.$inferSelect)[],
|
rows: (typeof medications.$inferSelect)[],
|
||||||
@@ -483,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const state = loadIntakeReminderState(logger);
|
const state = loadIntakeReminderState(logger);
|
||||||
|
const trackedDoses = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
|
const reminderEntriesWithStock = reminderEntries.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
currentStock: computeMedicationCurrentStock({
|
||||||
|
medication: entry.med,
|
||||||
|
doses: trackedDoses,
|
||||||
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
|
nowMs: now.getTime(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0);
|
||||||
|
if (suppressedEmptyStockEntries.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries
|
||||||
|
.map((entry) =>
|
||||||
|
getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName })
|
||||||
|
)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0);
|
||||||
|
if (reminderEntriesEligible.length === 0) {
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
let scheduledIntakesTodayCount = 0;
|
let scheduledIntakesTodayCount = 0;
|
||||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
const now = new Date();
|
|
||||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
todayStart.setHours(0, 0, 0, 0);
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -495,7 +557,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
todayEnd.setHours(23, 59, 59, 999);
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||||
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntriesEligible) {
|
||||||
// Medication-level takenBy (for fallback/display purposes)
|
// Medication-level takenBy (for fallback/display purposes)
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||||
@@ -801,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
.join("\n") +
|
.join("\n") +
|
||||||
repeatNote +
|
repeatNote +
|
||||||
`\n\n---\n${getFooterPlain(language)}`;
|
`\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
const actionMode = remindersToSend.length === 1 ? "full" : "view-only";
|
||||||
|
const actionDoseIds = remindersToSend.map((intake) =>
|
||||||
|
buildDoseIdForIntake({
|
||||||
|
...intake,
|
||||||
|
medicationId: intake.medicationId,
|
||||||
|
blisterIndex: intake.blisterIndex,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null = null;
|
||||||
|
let actionContextFailed = false;
|
||||||
|
try {
|
||||||
|
actionContext = await createNotificationActionContext({
|
||||||
|
userId: settings.userId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
doseIds: actionDoseIds,
|
||||||
|
scheduledFor: remindersToSend[0]?.intakeTime ?? new Date(),
|
||||||
|
publicAppUrl: env.PUBLIC_APP_URL,
|
||||||
|
language,
|
||||||
|
actionMode,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
actionContextFailed = true;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(
|
||||||
|
`[IntakeReminder] Notification action context failed for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||||
|
settings.shoutrrrUrl!
|
||||||
|
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext: null })}): ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!actionContext) {
|
||||||
|
if (actionContextFailed) {
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] Sending intake reminders without actions after action context failure for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||||
|
settings.shoutrrrUrl!
|
||||||
|
)})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] No reachable public app URL configured; sending intake reminders without actions for user=${username} (userId=${settings.userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Notification action context ready for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||||
|
settings.shoutrrrUrl!
|
||||||
|
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message);
|
const pushProvider = getPushProviderLabel(settings.shoutrrrUrl!);
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Sending push reminder for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, priority=${hasNaggingReminder ? 4 : 3}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message, {
|
||||||
|
actions: actionContext?.actions,
|
||||||
|
respondUrl: actionContext?.respondUrl,
|
||||||
|
viewUrl: actionContext?.viewUrl,
|
||||||
|
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||||
|
sequenceId: actionContext?.sequenceId,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: hasNaggingReminder ? 4 : 3,
|
||||||
|
});
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })}): ${result.error}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if (actionContext?.groupId && result.providerMessageId) {
|
||||||
|
try {
|
||||||
|
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (actionContext?.groupId && pushProvider === "ntfy") {
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
|
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, providerMessageId=${result.providerMessageId ?? "n/a"}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 nodemailer from "nodemailer";
|
||||||
import { sendShoutrrrNotification } from "../../routes/settings.js";
|
import { sendShoutrrrNotification } from "../../routes/settings.js";
|
||||||
|
import type { PushNotificationOptions } from "./action-renderer.js";
|
||||||
|
|
||||||
type MailDeliveryInfo = {
|
type MailDeliveryInfo = {
|
||||||
accepted?: unknown;
|
accepted?: unknown;
|
||||||
@@ -122,14 +123,15 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
|
|||||||
export async function sendPushNotification(
|
export async function sendPushNotification(
|
||||||
url: string,
|
url: string,
|
||||||
title: string,
|
title: string,
|
||||||
message: string
|
message: string,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
options: PushNotificationOptions = {}
|
||||||
|
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||||
try {
|
try {
|
||||||
const result = await sendShoutrrrNotification(url, title, message);
|
const result = await sendShoutrrrNotification(url, title, message, options);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error };
|
return { success: false, error: result.error };
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true, providerMessageId: result.providerMessageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { userSettings } from "../db/schema.js";
|
import { userSettings } from "../db/schema.js";
|
||||||
import type { Language } from "../i18n/translations.js";
|
import type { Language } from "../i18n/translations.js";
|
||||||
|
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
|
||||||
|
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -81,7 +82,7 @@ export function getNotificationProvider(url: string): string {
|
|||||||
if (url.startsWith("telegram://")) return "telegram";
|
if (url.startsWith("telegram://")) return "telegram";
|
||||||
if (url.startsWith("gotify://")) return "gotify";
|
if (url.startsWith("gotify://")) return "gotify";
|
||||||
if (url.startsWith("pushover://")) return "pushover";
|
if (url.startsWith("pushover://")) return "pushover";
|
||||||
if (url.startsWith("ntfy://")) return "ntfy";
|
if (isNtfyNotificationUrl(url)) return "ntfy";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -231,7 +232,7 @@ export function sanitizeNotificationUrl(
|
|||||||
return { url: discordWebhookUrl, isNtfy: false };
|
return { url: discordWebhookUrl, isNtfy: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNtfy = urlStr.startsWith("ntfy://");
|
const isNtfy = isNtfyNotificationUrl(urlStr);
|
||||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||||
const parsed = new URL(normalizedUrl);
|
const parsed = new URL(normalizedUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 2,
|
packsAdded: 2,
|
||||||
loosePillsAdded: 5,
|
loosePillsAdded: 5,
|
||||||
|
quantityAdded: 7,
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -376,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 0,
|
loosePillsAdded: 0,
|
||||||
|
quantityAdded: 1,
|
||||||
usedPrescription: false,
|
usedPrescription: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2443,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Liquid Stale Client Stock Correction",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 7,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 1050,
|
||||||
|
looseTablets: 1050,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const correctionResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 1,
|
||||||
|
totalPills: 150,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(correctionResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(afterCorrectionResponse.statusCode).toBe(200);
|
||||||
|
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(correctedMed).toBeTruthy();
|
||||||
|
expect(correctedMed.packCount).toBe(1);
|
||||||
|
expect(correctedMed.totalPills).toBe(150);
|
||||||
|
expect(correctedMed.looseTablets).toBe(150);
|
||||||
|
expect(correctedMed.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(150);
|
||||||
|
expect(refillData.newStock.packCount).toBe(2);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(300);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(300);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0].quantityAdded).toBe(150);
|
||||||
|
|
||||||
|
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(afterRefillResponse.statusCode).toBe(200);
|
||||||
|
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(refilledMed).toBeTruthy();
|
||||||
|
expect(refilledMed.packCount).toBe(2);
|
||||||
|
expect(refilledMed.totalPills).toBe(300);
|
||||||
|
expect(refilledMed.looseTablets).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
it("should persist stockAdjustment in GET /medications", async () => {
|
it("should persist stockAdjustment in GET /medications", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3048,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
}: {
|
||||||
|
medId: number;
|
||||||
|
refillData: {
|
||||||
|
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
|
||||||
|
newStock: { packCount: number; totalPills: number; looseTablets: number };
|
||||||
|
};
|
||||||
|
visibleStockBeforeRefill: number;
|
||||||
|
expectedQuantityAdded: number;
|
||||||
|
expectedPacksAdded: number;
|
||||||
|
expectedAmountPerPackage?: number;
|
||||||
|
}) {
|
||||||
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
|
||||||
|
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
|
packsAdded: expectedPacksAdded,
|
||||||
|
quantityAdded: expectedQuantityAdded,
|
||||||
|
totalPillsAdded: expectedQuantityAdded,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expectedAmountPerPackage) {
|
||||||
|
expect(refillData.newStock.packCount).toBe(
|
||||||
|
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it("should create and return bottle type medication", async () => {
|
it("should create and return bottle type medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3241,6 +3359,196 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "bottle",
|
||||||
|
payload: {
|
||||||
|
...bottleMedication,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 10,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
|
||||||
|
expectedVisibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 100,
|
||||||
|
expectedResponsePacksAdded: 0,
|
||||||
|
expectedPackCount: 0,
|
||||||
|
expectedLooseTablets: 104,
|
||||||
|
expectedTotalPills: 104,
|
||||||
|
expectedPersistedTotalPills: 100,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blister",
|
||||||
|
payload: {
|
||||||
|
...blisterMedication,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
expectedVisibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 10,
|
||||||
|
expectedResponsePacksAdded: 1,
|
||||||
|
expectedPackCount: 2,
|
||||||
|
expectedLooseTablets: 0,
|
||||||
|
expectedTotalPills: 14,
|
||||||
|
expectedPersistedTotalPills: null,
|
||||||
|
expectedStockAdjustment: -6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "liquid_container",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 100,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
expectedVisibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 100,
|
||||||
|
expectedResponsePacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 100,
|
||||||
|
expectedPackCount: 2,
|
||||||
|
expectedLooseTablets: 104,
|
||||||
|
expectedTotalPills: 104,
|
||||||
|
expectedPersistedTotalPills: 104,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
])("should refill from current visible stock after prior consumption for $name", async ({
|
||||||
|
payload,
|
||||||
|
refillPayload,
|
||||||
|
expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedResponsePacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
expectedPackCount,
|
||||||
|
expectedLooseTablets,
|
||||||
|
expectedTotalPills,
|
||||||
|
expectedPersistedTotalPills,
|
||||||
|
expectedStockAdjustment,
|
||||||
|
}) => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
for (let day = 1; day <= 6; day += 1) {
|
||||||
|
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
|
||||||
|
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: refillPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedPacksAdded: expectedResponsePacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(expectedPackCount);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(expectedPackCount);
|
||||||
|
expect(med.looseTablets).toBe(expectedLooseTablets);
|
||||||
|
expect(med.totalPills).toBe(expectedPersistedTotalPills);
|
||||||
|
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should refill tube stock from the corrected visible baseline", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...tubeMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 80,
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const correctionResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: -6,
|
||||||
|
looseTablets: 10,
|
||||||
|
totalPills: 10,
|
||||||
|
packageAmountValue: 80,
|
||||||
|
packCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(correctionResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 80,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 80,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(2);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(84);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(84);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(2);
|
||||||
|
expect(med.looseTablets).toBe(84);
|
||||||
|
expect(med.totalPills).toBe(84);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3272,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3294,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
expect(refillData.refill.packsAdded).toBe(1);
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 180,
|
||||||
|
expectedQuantityAdded: 180,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 180,
|
||||||
|
});
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(180);
|
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(180);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(360);
|
expect(refillData.newStock.totalPills).toBe(360);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
@@ -3307,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.looseTablets).toBe(360);
|
expect(med.looseTablets).toBe(360);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 0,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
totalPills: 300,
|
||||||
|
looseTablets: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 5, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 300,
|
||||||
|
expectedQuantityAdded: 750,
|
||||||
|
expectedPacksAdded: 5,
|
||||||
|
expectedAmountPerPackage: 150,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(7);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(1050);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(7);
|
||||||
|
expect(med.totalPills).toBe(1050);
|
||||||
|
expect(med.looseTablets).toBe(1050);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "liquid_container",
|
name: "liquid_container",
|
||||||
@@ -3323,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||||
|
expectedVisibleStockBeforeRefill: 180,
|
||||||
expectedPacksAdded: 1,
|
expectedPacksAdded: 1,
|
||||||
expectedLooseAdded: 180,
|
expectedLooseAdded: 180,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 360,
|
expectedTotalPills: 360,
|
||||||
|
expectedAmountPerPackage: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tube",
|
name: "tube",
|
||||||
@@ -3338,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||||
|
expectedVisibleStockBeforeRefill: 80,
|
||||||
expectedPacksAdded: 2,
|
expectedPacksAdded: 2,
|
||||||
expectedLooseAdded: 80,
|
expectedLooseAdded: 80,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 160,
|
expectedTotalPills: 160,
|
||||||
|
expectedAmountPerPackage: 40,
|
||||||
},
|
},
|
||||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||||
payload,
|
payload,
|
||||||
refillPayload,
|
refillPayload,
|
||||||
|
expectedVisibleStockBeforeRefill,
|
||||||
expectedPacksAdded,
|
expectedPacksAdded,
|
||||||
expectedLooseAdded,
|
expectedLooseAdded,
|
||||||
expectedRemainingRefills,
|
expectedRemainingRefills,
|
||||||
expectedTotalPills,
|
expectedTotalPills,
|
||||||
|
expectedAmountPerPackage,
|
||||||
}) => {
|
}) => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3367,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded: expectedLooseAdded,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
});
|
||||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.prescription.used).toBe(true);
|
expect(refillData.prescription.used).toBe(true);
|
||||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||||
@@ -3382,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(historyResponse.json()[0]).toMatchObject({
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
packsAdded: expectedPacksAdded,
|
packsAdded: expectedPacksAdded,
|
||||||
loosePillsAdded: expectedLooseAdded,
|
loosePillsAdded: expectedLooseAdded,
|
||||||
|
quantityAdded: expectedLooseAdded,
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3403,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
expect(refillData.refill.packsAdded).toBe(1);
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 80,
|
||||||
|
expectedQuantityAdded: 40,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 40,
|
||||||
|
});
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(40);
|
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(40);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(120);
|
expect(refillData.newStock.totalPills).toBe(120);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
|||||||
@@ -10,33 +10,34 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("3000")
|
||||||
.default(3000),
|
.transform((v) => parseInt(v, 10)),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default(false),
|
.transform((v) => v === "true"),
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default(false),
|
.transform((v) => v === "true"),
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
REFRESH_SECRET: z.string().min(10).optional(),
|
REFRESH_SECRET: z.string().min(10).optional(),
|
||||||
COOKIE_SECRET: z.string().min(10).optional(),
|
COOKIE_SECRET: z.string().min(10).optional(),
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("15")
|
||||||
.default(15),
|
.transform((v) => parseInt(v, 10)),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("7")
|
||||||
.default(7),
|
.transform((v) => parseInt(v, 10)),
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default(false),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -44,8 +45,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default(true),
|
.transform((v) => v === "true"),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||||
});
|
});
|
||||||
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
|
|||||||
expect(result.PORT).toBe(3000);
|
expect(result.PORT).toBe(3000);
|
||||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||||
expect(result.LOG_LEVEL).toBe("info");
|
expect(result.LOG_LEVEL).toBe("info");
|
||||||
|
expect(result.PUBLIC_APP_URL).toBeUndefined();
|
||||||
expect(result.AUTH_ENABLED).toBe(false);
|
expect(result.AUTH_ENABLED).toBe(false);
|
||||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||||
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OIDC URL validation", () => {
|
describe("OIDC URL validation", () => {
|
||||||
|
it("should accept valid PUBLIC_APP_URL", () => {
|
||||||
|
const result = EnvSchema.parse({ PUBLIC_APP_URL: "https://medassist.example.com" });
|
||||||
|
expect(result.PUBLIC_APP_URL).toBe("https://medassist.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid PUBLIC_APP_URL", () => {
|
||||||
|
expect(() => EnvSchema.parse({ PUBLIC_APP_URL: "not-a-url" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockedEnv,
|
||||||
|
createNotificationActionContextMock,
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock,
|
||||||
|
sendPushNotificationMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockedEnv: {
|
||||||
|
PUBLIC_APP_URL: undefined as string | undefined,
|
||||||
|
CORS_ORIGINS: "http://localhost:5173" as string,
|
||||||
|
},
|
||||||
|
createNotificationActionContextMock: vi.fn(),
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock: vi.fn(),
|
||||||
|
sendPushNotificationMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
existsSync: () => false,
|
||||||
|
readFileSync: vi.fn(),
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../db/path-utils.js", () => ({
|
||||||
|
getDataDir: () => "/tmp",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
},
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("../services/notification-actions-service.js", () => ({
|
||||||
|
createNotificationActionContext: createNotificationActionContextMock,
|
||||||
|
storeNotificationActionGroupNtfyMessageId: storeNotificationActionGroupNtfyMessageIdMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/notifications/delivery.js", () => ({
|
||||||
|
getSmtpConfig: vi.fn(() => null),
|
||||||
|
sendEmailNotification: vi.fn(),
|
||||||
|
sendPushNotification: sendPushNotificationMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/notifications/state.js", () => ({
|
||||||
|
updateReminderSentTime: vi.fn(),
|
||||||
|
updateUserReminderSentTime: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../utils/scheduler-utils.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../utils/scheduler-utils.js")>("../utils/scheduler-utils.js");
|
||||||
|
const candidate = {
|
||||||
|
medName: "Calcium",
|
||||||
|
intakeTime: new Date("2026-01-05T11:15:00.000Z"),
|
||||||
|
intakeTimeStr: "11:15",
|
||||||
|
usage: 1,
|
||||||
|
takenBy: null,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getEffectiveTimezone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
getDateLocale: () => "en-US",
|
||||||
|
parseTakenByJson: () => [],
|
||||||
|
parseIntakesJson: () => [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2026-01-05T10:45:00.000Z",
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getTodaysIntakes: () => [candidate],
|
||||||
|
getUpcomingIntakes: () => [candidate],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
return {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSelectWhere<T>(result: T) {
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: async () => result,
|
||||||
|
}),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("intake reminder scheduler action wiring", () => {
|
||||||
|
const mockedDb = vi.mocked(db);
|
||||||
|
let originalTz: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0));
|
||||||
|
originalTz = process.env.TZ;
|
||||||
|
process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
mockedEnv.PUBLIC_APP_URL = undefined;
|
||||||
|
mockedEnv.CORS_ORIGINS = "http://localhost:5173";
|
||||||
|
createNotificationActionContextMock.mockReset();
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock.mockReset();
|
||||||
|
sendPushNotificationMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalTz === undefined) {
|
||||||
|
delete process.env.TZ;
|
||||||
|
} else {
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches action context to push notifications when PUBLIC_APP_URL is configured", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 11,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
groupId: 41,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-1" });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 11,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 11,
|
||||||
|
publicAppUrl: "https://app.example.com",
|
||||||
|
language: "en",
|
||||||
|
actionMode: "full",
|
||||||
|
doseIds: [expect.stringMatching(/^7-0-/)],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(41, "ntfy-msg-1");
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses view-only actions for grouped intake reminders", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "grouped-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 13,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
userId: 13,
|
||||||
|
name: "Vitamin D",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 13,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 13,
|
||||||
|
publicAppUrl: "https://app.example.com",
|
||||||
|
language: "en",
|
||||||
|
actionMode: "view-only",
|
||||||
|
doseIds: [expect.stringMatching(/^7-0-/), expect.stringMatching(/^8-0-/)],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: undefined,
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
clickUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
sequenceId: undefined,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends push notifications without actions when PUBLIC_APP_URL is missing", async () => {
|
||||||
|
createNotificationActionContextMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "pushless-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 12,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 12,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 12,
|
||||||
|
publicAppUrl: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: undefined,
|
||||||
|
respondUrl: undefined,
|
||||||
|
viewUrl: undefined,
|
||||||
|
clickUrl: undefined,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("No reachable public app URL configured; sending intake reminders without actions")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to push delivery without actions when action context generation fails", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "context-failure-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 15,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockRejectedValue(new Error("action context write failed"));
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 15,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: undefined,
|
||||||
|
respondUrl: undefined,
|
||||||
|
viewUrl: undefined,
|
||||||
|
clickUrl: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Notification action context failed"));
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Sending intake reminders without actions after action context failure")
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs enriched push delivery failures with action context metadata", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-failure-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 16,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
groupId: 52,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: false, error: "HTTP 500: upstream down" });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 16,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Push delivery failed"));
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("provider=ntfy"));
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("actionMode=full"));
|
||||||
|
expect(storeNotificationActionGroupNtfyMessageIdMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns but keeps reminder flow alive when ntfy message id persistence fails", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "persist-warning-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 17,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
groupId: 77,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-77" });
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock.mockRejectedValue(new Error("db write failed"));
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 17,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(77, "ntfy-msg-77");
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store ntfy message id"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send intake reminders for reminder-enabled medications with empty stock", async () => {
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "empty-stock-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 14,
|
||||||
|
name: "Calcium",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 14,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).not.toHaveBeenCalled();
|
||||||
|
expect(sendPushNotificationMock).not.toHaveBeenCalled();
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Skipping reminder-enabled medications with empty stock")
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("No reminder-eligible medications with stock remaining")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getNotificationActionLabels,
|
||||||
|
isNtfyNotificationUrl,
|
||||||
|
type PushNotificationAction,
|
||||||
|
renderNotificationActionPayload,
|
||||||
|
} from "../services/notifications/action-renderer.js";
|
||||||
|
|
||||||
|
function decodeRfc2047Base64(value: string): string {
|
||||||
|
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(match[1], "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: PushNotificationAction[] = [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Take",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "skip",
|
||||||
|
label: "Skip",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("notification action renderer", () => {
|
||||||
|
it("builds ntfy native actions without duplicate click headers", () => {
|
||||||
|
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
||||||
|
actions,
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 4,
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.message).toBe("Body");
|
||||||
|
expect(result.headers).toMatchObject({
|
||||||
|
Tags: "pill",
|
||||||
|
Priority: "4",
|
||||||
|
"X-Sequence-ID": "medassist-sequence",
|
||||||
|
});
|
||||||
|
expect(result.headers.Click).toBeUndefined();
|
||||||
|
|
||||||
|
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
|
||||||
|
expect(parsedActions).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Take",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Skip",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/?date=2026-01-05",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ntfy click header when there are no native actions", () => {
|
||||||
|
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||||
|
actions,
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
|
||||||
|
expect(result.message).toBe("Body");
|
||||||
|
expect(result.headers.Actions).toBeTruthy();
|
||||||
|
expect(result.message).not.toContain("Respond:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Take",
|
||||||
|
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Take",
|
||||||
|
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "skip",
|
||||||
|
label: "Überspringen",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "Öffnen",
|
||||||
|
url: "https://app.example.com/?date=2026-01-05",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
|
||||||
|
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Überspringen",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "Öffnen",
|
||||||
|
url: "https://app.example.com/?date=2026-01-05",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses consistent action-form labels for English and German", () => {
|
||||||
|
expect(getNotificationActionLabels("en")).toEqual({
|
||||||
|
taken: "Take",
|
||||||
|
skip: "Skip",
|
||||||
|
respond: "Respond",
|
||||||
|
view: "View",
|
||||||
|
});
|
||||||
|
expect(getNotificationActionLabels("de")).toEqual({
|
||||||
|
taken: "Einnehmen",
|
||||||
|
skip: "Überspringen",
|
||||||
|
respond: "Antworten",
|
||||||
|
view: "Öffnen",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends respond and view fallback links for non-ntfy providers", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.headers).toEqual({});
|
||||||
|
expect(result.message).toBe(
|
||||||
|
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ Scope and behavior:
|
|||||||
|
|
||||||
- These values are applied only when a user's settings are created for the first time.
|
- These values are applied only when a user's settings are created for the first time.
|
||||||
- After that, values stored in the database are used and take precedence.
|
- After that, values stored in the database are used and take precedence.
|
||||||
|
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
|
||||||
|
|
||||||
## Email Defaults
|
## Email Defaults
|
||||||
|
|
||||||
@@ -46,6 +47,6 @@ Scope and behavior:
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||||
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
||||||
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
||||||
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ function AppContent() {
|
|||||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/medications" element={<MedicationsPage />} />
|
<Route path="/medications" element={<MedicationsPage />} />
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markDoseTaken(doseId: string) {
|
async function markDoseTaken(doseId: string) {
|
||||||
|
if (dismissedDoses.has(doseId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const wasTaken = takenDoses.has(doseId);
|
const wasTaken = takenDoses.has(doseId);
|
||||||
const wasSkipped = dismissedDoses.has(doseId);
|
const wasSkipped = dismissedDoses.has(doseId);
|
||||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||||
@@ -462,7 +466,7 @@ export function SharedSchedule() {
|
|||||||
<button
|
<button
|
||||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||||
onClick={() => markDoseTaken(options.doseId)}
|
onClick={() => markDoseTaken(options.doseId)}
|
||||||
disabled={options.isEmpty}
|
disabled={options.isEmpty || options.isSkipped}
|
||||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
@@ -472,7 +476,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
const skipButton = options.isSkipped ? (
|
const skipButton = options.isSkipped ? (
|
||||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||||
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
<span aria-hidden="true">↩</span>
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -257,10 +257,8 @@ export function MedicationsPage() {
|
|||||||
useUnsavedChangesWarning(formChanged);
|
useUnsavedChangesWarning(formChanged);
|
||||||
|
|
||||||
// View mode: grid (default) or form (edit/new)
|
// View mode: grid (default) or form (edit/new)
|
||||||
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
|
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
||||||
const [pendingEditTransition, setPendingEditTransition] = useState(
|
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
||||||
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
|
|
||||||
);
|
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
@@ -271,23 +269,9 @@ export function MedicationsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showEditModalRef.current = showEditModal;
|
showEditModalRef.current = showEditModal;
|
||||||
}, [showEditModal]);
|
}, [showEditModal]);
|
||||||
const processedMedicationLinkRef = useRef<string | null>(null);
|
const processedEditMedIdRef = useRef<string | null>(null);
|
||||||
const hasDesktopFormHistoryState = useRef(false);
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
|
|
||||||
const viewMedId = params.get("viewMedId");
|
|
||||||
if (viewMedId) {
|
|
||||||
return { mode: "view" as const, linkedMedId: viewMedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const editMedId = params.get("editMedId");
|
|
||||||
if (editMedId) {
|
|
||||||
return { mode: "edit" as const, linkedMedId: editMedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { mode: null, linkedMedId: null };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Sync formChanged state to the global context for navigation blocking
|
// Sync formChanged state to the global context for navigation blocking
|
||||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -835,13 +819,12 @@ export function MedicationsPage() {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearMedicationLinkParams = useCallback(() => {
|
const clearEditMedIdParam = useCallback(() => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prevParams) => {
|
(prevParams) => {
|
||||||
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
|
if (!prevParams.has("editMedId")) return prevParams;
|
||||||
const nextParams = new URLSearchParams(prevParams);
|
const nextParams = new URLSearchParams(prevParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
nextParams.delete("viewMedId");
|
|
||||||
return nextParams;
|
return nextParams;
|
||||||
},
|
},
|
||||||
{ replace: true }
|
{ replace: true }
|
||||||
@@ -865,7 +848,7 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearMedicationLinkParams();
|
clearEditMedIdParam();
|
||||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
window.history.back();
|
window.history.back();
|
||||||
@@ -1176,7 +1159,7 @@ export function MedicationsPage() {
|
|||||||
if (shouldCloseMobileModal) {
|
if (shouldCloseMobileModal) {
|
||||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
clearMedicationLinkParams();
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
@@ -1205,8 +1188,7 @@ export function MedicationsPage() {
|
|||||||
// Handle browser back button for modals and unsaved changes
|
// Handle browser back button for modals and unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
const currentParams = new URLSearchParams(window.location.search);
|
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
||||||
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
|
|
||||||
|
|
||||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||||
if (showObsoleteConfirm) {
|
if (showObsoleteConfirm) {
|
||||||
@@ -1225,10 +1207,10 @@ export function MedicationsPage() {
|
|||||||
// If close was already confirmed programmatically, allow navigation
|
// If close was already confirmed programmatically, allow navigation
|
||||||
if (closeConfirmedRef.current) {
|
if (closeConfirmedRef.current) {
|
||||||
closeConfirmedRef.current = false;
|
closeConfirmedRef.current = false;
|
||||||
if (currentMedicationLinkId && currentLinkMode) {
|
if (currentEditMedId) {
|
||||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||||
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
processedEditMedIdRef.current = currentEditMedId;
|
||||||
clearMedicationLinkParams();
|
clearEditMedIdParam();
|
||||||
}
|
}
|
||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -1249,11 +1231,11 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentMedicationLinkId && currentLinkMode) {
|
if (currentEditMedId) {
|
||||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||||
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
processedEditMedIdRef.current = currentEditMedId;
|
||||||
}
|
}
|
||||||
clearMedicationLinkParams();
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
resetMedicationEnrichment();
|
resetMedicationEnrichment();
|
||||||
@@ -1289,16 +1271,7 @@ export function MedicationsPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
}, [
|
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
|
||||||
showObsoleteConfirm,
|
|
||||||
showDeleteConfirm,
|
|
||||||
showEditModal,
|
|
||||||
viewMode,
|
|
||||||
formChanged,
|
|
||||||
resetForm,
|
|
||||||
clearMedicationLinkParams,
|
|
||||||
getMedicationLinkState,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1416,23 +1389,22 @@ export function MedicationsPage() {
|
|||||||
}, [activeMeds, editingId]);
|
}, [activeMeds, editingId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
|
const editMedId = searchParams.get("editMedId");
|
||||||
if (!linkedMedId || !linkMode) {
|
if (!editMedId) {
|
||||||
processedMedicationLinkRef.current = null;
|
processedEditMedIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const linkKey = `${linkMode}:${linkedMedId}`;
|
if (processedEditMedIdRef.current === editMedId) return;
|
||||||
if (processedMedicationLinkRef.current === linkKey) return;
|
const parsedMedId = Number.parseInt(editMedId, 10);
|
||||||
const parsedMedId = Number.parseInt(linkedMedId, 10);
|
|
||||||
if (Number.isNaN(parsedMedId)) return;
|
if (Number.isNaN(parsedMedId)) return;
|
||||||
const medicationToEdit =
|
const medicationToEdit =
|
||||||
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
||||||
if (!medicationToEdit) return;
|
if (!medicationToEdit) return;
|
||||||
|
|
||||||
processedMedicationLinkRef.current = linkKey;
|
processedEditMedIdRef.current = editMedId;
|
||||||
|
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(linkMode === "view");
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||||
startEdit(medicationToEdit, openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
@@ -1443,9 +1415,8 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
nextParams.delete("viewMedId");
|
|
||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||||
|
|
||||||
const selectedMedication = useMemo(() => {
|
const selectedMedication = useMemo(() => {
|
||||||
if (!editingId) return null;
|
if (!editingId) return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import App from "../App";
|
import App from "../App";
|
||||||
|
|
||||||
@@ -59,15 +59,7 @@ vi.mock("../context", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../pages", () => ({
|
vi.mock("../pages", () => ({
|
||||||
DashboardPage: () => {
|
DashboardPage: () => <div>dashboard-page</div>,
|
||||||
const location = useLocation();
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span>dashboard-page</span>
|
|
||||||
<span data-testid="dashboard-location-search">{location.search}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MedicationsPage: () => <div>medications-page</div>,
|
MedicationsPage: () => <div>medications-page</div>,
|
||||||
PlannerPage: () => <div>planner-page</div>,
|
PlannerPage: () => <div>planner-page</div>,
|
||||||
SchedulePage: () => <div>schedule-page</div>,
|
SchedulePage: () => <div>schedule-page</div>,
|
||||||
@@ -273,19 +265,6 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves notification query params when redirecting root to dashboard", () => {
|
|
||||||
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={[`/${search}`]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders initializing state when auth state is missing", () => {
|
it("renders initializing state when auth state is missing", () => {
|
||||||
authMock = {
|
authMock = {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -175,10 +175,6 @@ describe("LoginForm", () => {
|
|||||||
oidcProviderName: "",
|
oidcProviderName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.history.replaceState({}, "", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
|||||||
@@ -475,21 +475,6 @@ describe("MedicationsPage with items", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens read-only view from viewMedId query parameter", async () => {
|
|
||||||
const startEdit = vi.fn();
|
|
||||||
mockFormHookValue = createMockFormHook({ startEdit });
|
|
||||||
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
|
|
||||||
|
|
||||||
renderPage("/medications?viewMedId=1");
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(startEdit).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("common.close")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
||||||
const startEdit = vi.fn();
|
const startEdit = vi.fn();
|
||||||
const resetForm = vi.fn();
|
const resetForm = vi.fn();
|
||||||
|
|||||||
@@ -2,24 +2,6 @@ import { existsSync, readFileSync } from "fs";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
function parseCsvEnv(value: string | undefined, fallback: string[]) {
|
|
||||||
const entries = value
|
|
||||||
?.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0);
|
|
||||||
|
|
||||||
return entries && entries.length > 0 ? entries : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionalPort(value: string | undefined) {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read version from package.json at build time
|
// Read version from package.json at build time
|
||||||
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
@@ -27,19 +9,6 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
|||||||
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
||||||
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
||||||
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
||||||
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
|
|
||||||
const hmrHost = process.env.VITE_HMR_HOST?.trim();
|
|
||||||
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
|
|
||||||
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
|
|
||||||
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
|
|
||||||
const hmr = hmrHost
|
|
||||||
? {
|
|
||||||
host: hmrHost,
|
|
||||||
protocol: hmrProtocol ?? "wss",
|
|
||||||
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
|
|
||||||
port: hmrPort ?? 5173,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -50,8 +19,6 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
allowedHosts,
|
|
||||||
hmr,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: backendTarget,
|
target: backendTarget,
|
||||||
|
|||||||
Reference in New Issue
Block a user