Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e85b29549 | |||
| e55e695c88 |
+1
-7
@@ -13,12 +13,6 @@ PORT=3000
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
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
|
||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||
# and frontend browser console (via build-time injection)
|
||||
@@ -155,6 +149,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
||||
# UI defaults
|
||||
# DEFAULT_LANGUAGE=en # en or de
|
||||
# DEFAULT_STOCK_CALCULATION_MODE=automatic # 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
|
||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||
@@ -1,30 +0,0 @@
|
||||
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`);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL;
|
||||
@@ -59,7 +59,6 @@ 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_channel 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_medication_overview integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||
@@ -76,7 +75,6 @@ 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_med_names text`,
|
||||
`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) {
|
||||
@@ -98,31 +96,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -151,8 +124,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
const createIndexMigrations = [
|
||||
`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 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)`,
|
||||
];
|
||||
|
||||
|
||||
@@ -108,9 +108,8 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
||||
// 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 show stock status (Critical/Low/Normal) to intake users
|
||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||
// Whether shared schedule links also embed the medication overview section
|
||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||
// UI timeline visibility preferences
|
||||
@@ -184,43 +183,6 @@ export const shareTokens = sqliteTable("share_tokens", {
|
||||
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
|
||||
// =============================================================================
|
||||
@@ -232,8 +194,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
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'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
+16
-17
@@ -10,11 +10,10 @@ const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.string()
|
||||
.default("3000")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(3000),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
OPENAPI_DOCS_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
@@ -26,18 +25,18 @@ const EnvSchema = z.object({
|
||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
// Disable username/password form login (useful for OIDC-only setups)
|
||||
FORM_LOGIN_ENABLED: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
@@ -47,20 +46,20 @@ const EnvSchema = z.object({
|
||||
// Token TTL settings
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(15),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(7),
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
// ==========================================================================
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -68,8 +67,8 @@ const EnvSchema = z.object({
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||
});
|
||||
|
||||
@@ -2,10 +2,9 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
|
||||
import { medications, refillHistory } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -196,22 +195,13 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const refillBaselineAt = new Date();
|
||||
const [settings] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.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;
|
||||
const baselineStockBeforeRefill = isAmountBased
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||
|
||||
// Update medication stock. Refill establishes a new stock baseline at the current visible
|
||||
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
|
||||
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
||||
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||
let newPackCount = med.packCount + effectivePacksAdded;
|
||||
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||
|
||||
@@ -65,7 +65,6 @@ const reportDataResponseSchema = {
|
||||
properties: {
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "integer" },
|
||||
usedPrescription: { type: "boolean" },
|
||||
refillDate: { type: "string", format: "date-time" },
|
||||
},
|
||||
@@ -116,16 +115,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
: null;
|
||||
|
||||
// Verify all medications belong to this user
|
||||
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 userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||
|
||||
for (const id of medicationIds) {
|
||||
@@ -169,13 +159,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: {
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription: boolean;
|
||||
refillDate: string;
|
||||
}[];
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
@@ -186,9 +170,6 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||
|
||||
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.
|
||||
const refills = await db
|
||||
@@ -205,7 +186,6 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
refills: refills.map((r) => ({
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -345,7 +345,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 2,
|
||||
loosePillsAdded: 5,
|
||||
quantityAdded: 7,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
@@ -377,7 +376,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 1,
|
||||
usedPrescription: false,
|
||||
});
|
||||
});
|
||||
@@ -403,7 +401,8 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
describe("Real /doses/taken routes", () => {
|
||||
it("should mark a dose using real route", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
const medicationId = await createMedication(testClient, userId, "Dose Route Med", []);
|
||||
const doseId = `${medicationId}-0-1735344000000`;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1121,7 +1120,8 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
describe("Real /doses/taken routes - edge cases", () => {
|
||||
it("should return already marked message for duplicate dose", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
const medicationId = await createMedication(testClient, userId, "Duplicate Dose Med", []);
|
||||
const doseId = `${medicationId}-0-1735344000000`;
|
||||
|
||||
// Mark first time
|
||||
await app.inject({
|
||||
@@ -1142,7 +1142,8 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should handle doses with person name in doseId", async () => {
|
||||
const doseId = "1-0-1735344000000-Daniel";
|
||||
const medicationId = await createMedication(testClient, userId, "Taken By Med", ["Daniel"]);
|
||||
const doseId = `${medicationId}-0-1735344000000-Daniel`;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1354,7 +1355,8 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should handle dose marking and get taken doses", async () => {
|
||||
const doseId = "99-0-1735344000099";
|
||||
const medicationId = await createMedication(testClient, userId, "Coverage Dose Med", []);
|
||||
const doseId = `${medicationId}-0-1735344000099`;
|
||||
|
||||
// Mark the dose
|
||||
const markResponse = await app.inject({
|
||||
@@ -2445,81 +2447,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3125,47 +3052,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3359,196 +3245,6 @@ 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 () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3580,11 +3276,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
@@ -3607,15 +3298,9 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: 180,
|
||||
expectedQuantityAdded: 180,
|
||||
expectedPacksAdded: 1,
|
||||
expectedAmountPerPackage: 180,
|
||||
});
|
||||
expect(refillData.refill.packsAdded).toBe(1);
|
||||
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(180);
|
||||
expect(refillData.newStock.totalPills).toBe(360);
|
||||
|
||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||
@@ -3626,54 +3311,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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([
|
||||
{
|
||||
name: "liquid_container",
|
||||
@@ -3690,12 +3327,10 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 180,
|
||||
expectedPacksAdded: 1,
|
||||
expectedLooseAdded: 180,
|
||||
expectedRemainingRefills: 1,
|
||||
expectedTotalPills: 360,
|
||||
expectedAmountPerPackage: 180,
|
||||
},
|
||||
{
|
||||
name: "tube",
|
||||
@@ -3707,28 +3342,19 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 80,
|
||||
expectedPacksAdded: 2,
|
||||
expectedLooseAdded: 80,
|
||||
expectedRemainingRefills: 1,
|
||||
expectedTotalPills: 160,
|
||||
expectedAmountPerPackage: 40,
|
||||
},
|
||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||
payload,
|
||||
refillPayload,
|
||||
expectedVisibleStockBeforeRefill,
|
||||
expectedPacksAdded,
|
||||
expectedLooseAdded,
|
||||
expectedRemainingRefills,
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
@@ -3745,17 +3371,8 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||
expectedQuantityAdded: expectedLooseAdded,
|
||||
expectedPacksAdded,
|
||||
expectedAmountPerPackage,
|
||||
});
|
||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.prescription.used).toBe(true);
|
||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||
@@ -3769,7 +3386,6 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(historyResponse.json()[0]).toMatchObject({
|
||||
packsAdded: expectedPacksAdded,
|
||||
loosePillsAdded: expectedLooseAdded,
|
||||
quantityAdded: expectedLooseAdded,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
@@ -3791,15 +3407,9 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: 80,
|
||||
expectedQuantityAdded: 40,
|
||||
expectedPacksAdded: 1,
|
||||
expectedAmountPerPackage: 40,
|
||||
});
|
||||
expect(refillData.refill.packsAdded).toBe(1);
|
||||
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(40);
|
||||
expect(refillData.newStock.totalPills).toBe(120);
|
||||
|
||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||
|
||||
@@ -10,34 +10,33 @@ const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.string()
|
||||
.default("3000")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(3000),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(15),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(7),
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -45,8 +44,8 @@ const EnvSchema = z.object({
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
});
|
||||
@@ -82,7 +81,6 @@ describe("EnvSchema", () => {
|
||||
expect(result.PORT).toBe(3000);
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||
expect(result.LOG_LEVEL).toBe("info");
|
||||
expect(result.PUBLIC_APP_URL).toBeUndefined();
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||
@@ -190,15 +188,6 @@ describe("EnvSchema", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale, withFormattingTimezone } from "../utils";
|
||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
@@ -1092,16 +1092,22 @@ export function MedDetailModal({
|
||||
{refillHistory.map((entry) => (
|
||||
<div key={entry.id} className="refill-history-item">
|
||||
<span className="refill-date">
|
||||
{new Date(entry.refillDate).toLocaleDateString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
{new Date(entry.refillDate).toLocaleDateString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
)}
|
||||
,{" "}
|
||||
{new Date(entry.refillDate).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{new Date(entry.refillDate).toLocaleTimeString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
{(() => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Medication } from "../types";
|
||||
import {
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getStockDisplayCapacity,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
@@ -31,13 +30,7 @@ type ReportData = Record<
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: {
|
||||
packsAdded: number;
|
||||
loosePillsAdded?: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription: boolean;
|
||||
refillDate: string;
|
||||
}[];
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -384,7 +377,7 @@ function generateTextReport(
|
||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||
} else {
|
||||
lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med))));
|
||||
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
||||
}
|
||||
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -442,7 +435,7 @@ function generateTextReport(
|
||||
if (data.refills.length > 0) {
|
||||
lines.push(h3(t("report.docRefillHistory")));
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||
}
|
||||
@@ -582,7 +575,7 @@ function buildPrintHtml(
|
||||
if (med.looseTablets > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||
} else {
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||
}
|
||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -648,7 +641,7 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,6 @@ describe("ReportModal", () => {
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 20,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user