Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf18318410 | |||
| e2ed25059a |
+7
-1
@@ -13,6 +13,12 @@ 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)
|
||||
@@ -149,6 +155,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_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_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;
|
||||
@@ -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_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`,
|
||||
@@ -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_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) {
|
||||
@@ -96,6 +98,31 @@ 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,
|
||||
@@ -124,6 +151,8 @@ 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,8 +108,9 @@ 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"),
|
||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||
// 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 also embed the medication overview section
|
||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||
// UI timeline visibility preferences
|
||||
@@ -183,6 +184,43 @@ 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
|
||||
// =============================================================================
|
||||
@@ -194,8 +232,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 or automatic
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
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
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
+17
-16
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.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"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
OPENAPI_DOCS_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
|
||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Disable username/password form login (useful for OIDC-only setups)
|
||||
FORM_LOGIN_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
|
||||
// Token TTL settings
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(15),
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(7),
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
// ==========================================================================
|
||||
OIDC_ENABLED: z
|
||||
.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_CLIENT_ID: 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_AUTO_CREATE_USERS: z
|
||||
.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_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||
});
|
||||
|
||||
@@ -10,33 +10,34 @@ const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.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"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
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()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(15),
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default(7),
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: 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_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
});
|
||||
@@ -81,6 +82,7 @@ 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);
|
||||
@@ -188,6 +190,15 @@ 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");
|
||||
|
||||
Reference in New Issue
Block a user