feat: add ntfy notification action foundation

This commit is contained in:
Daniel Volz
2026-05-10 19:00:57 +02:00
parent 7554a79898
commit e2ed25059a
7 changed files with 151 additions and 35 deletions
+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_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)`,
];
+42 -4
View File
@@ -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
});
// =============================================================================