Compare commits

...

2 Commits

Author SHA1 Message Date
Daniel Volz cf18318410 Merge branch 'main' into feat/ntfy-action-foundation 2026-05-10 21:30:51 +02:00
Daniel Volz e2ed25059a feat: add ntfy notification action foundation 2026-05-10 19:00:57 +02:00
7 changed files with 151 additions and 35 deletions
+7 -1
View File
@@ -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
@@ -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;
+29
View File
@@ -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)`,
]; ];
+42 -4
View File
@@ -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
View File
@@ -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
}); });
+25 -14
View File
@@ -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");