diff --git a/backend/drizzle/0010_mean_spot.sql b/backend/drizzle/0010_add_dose_tracking_taken_source.sql similarity index 100% rename from backend/drizzle/0010_mean_spot.sql rename to backend/drizzle/0010_add_dose_tracking_taken_source.sql diff --git a/backend/drizzle/0011_stiff_randall_flagg.sql b/backend/drizzle/0011_add_medication_form_lifecycle_columns.sql similarity index 100% rename from backend/drizzle/0011_stiff_randall_flagg.sql rename to backend/drizzle/0011_add_medication_form_lifecycle_columns.sql diff --git a/backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql b/backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql new file mode 100644 index 0000000..f39cdf4 --- /dev/null +++ b/backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql @@ -0,0 +1,18 @@ +CREATE TABLE `api_keys` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `name` text(100) NOT NULL, + `key_hash` text(128) NOT NULL, + `token_prefix` text(24) DEFAULT '' NOT NULL, + `scope` text(10) DEFAULT 'write' NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `last_used_at` integer, + `expires_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 `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint +ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0012_snapshot.json b/backend/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..8c09ded --- /dev/null +++ b/backend/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1220 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "66cf7f2e-59bf-41dd-ad1b-4fcd81519019", + "prevId": "b2f3aeb3-a855-428e-85e3-8bc34a2a3d69", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "scope": { + "name": "scope", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'write'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dose_tracking": { + "name": "dose_tracking", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dose_id": { + "name": "dose_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taken_at": { + "name": "taken_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s','now'))" + }, + "marked_by": { + "name": "marked_by", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "taken_source": { + "name": "taken_source", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "dismissed": { + "name": "dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "dose_tracking_user_id_users_id_fk": { + "name": "dose_tracking_user_id_users_id_fk", + "tableFrom": "dose_tracking", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "medications": { + "name": "medications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generic_name": { + "name": "generic_name", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "taken_by_json": { + "name": "taken_by_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "package_type": { + "name": "package_type", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blister'" + }, + "medication_form": { + "name": "medication_form", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tablet'" + }, + "pill_form": { + "name": "pill_form", + "type": "text(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle_category": { + "name": "lifecycle_category", + "type": "text(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'refill_when_empty'" + }, + "package_amount_value": { + "name": "package_amount_value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "package_amount_unit": { + "name": "package_amount_unit", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ml'" + }, + "pack_count": { + "name": "pack_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "blisters_per_pack": { + "name": "blisters_per_pack", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "pills_per_blister": { + "name": "pills_per_blister", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "total_pills": { + "name": "total_pills", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loose_tablets": { + "name": "loose_tablets", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stock_adjustment": { + "name": "stock_adjustment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_stock_correction_at": { + "name": "last_stock_correction_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pill_weight_mg": { + "name": "pill_weight_mg", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dose_unit": { + "name": "dose_unit", + "type": "text(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'mg'" + }, + "usage_json": { + "name": "usage_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "every_json": { + "name": "every_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "start_json": { + "name": "start_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "intakes_json": { + "name": "intakes_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intake_reminders_enabled": { + "name": "intake_reminders_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "medication_start_date": { + "name": "medication_start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "medication_end_date": { + "name": "medication_end_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_mark_obsolete_after_end_date": { + "name": "auto_mark_obsolete_after_end_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_obsolete": { + "name": "is_obsolete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "obsolete_at": { + "name": "obsolete_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prescription_enabled": { + "name": "prescription_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prescription_authorized_refills": { + "name": "prescription_authorized_refills", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prescription_remaining_refills": { + "name": "prescription_remaining_refills", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prescription_low_refill_threshold": { + "name": "prescription_low_refill_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "prescription_expiry_date": { + "name": "prescription_expiry_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dismissed_until": { + "name": "dismissed_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "medications_user_id_users_id_fk": { + "name": "medications_user_id_users_id_fk", + "tableFrom": "medications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refill_history": { + "name": "refill_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "medication_id": { + "name": "medication_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "packs_added": { + "name": "packs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "loose_pills_added": { + "name": "loose_pills_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used_prescription": { + "name": "used_prescription", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "refill_date": { + "name": "refill_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s','now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "refill_history_medication_id_medications_id_fk": { + "name": "refill_history_medication_id_medications_id_fk", + "tableFrom": "refill_history", + "tableTo": "medications", + "columnsFrom": [ + "medication_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refill_history_user_id_users_id_fk": { + "name": "refill_history_user_id_users_id_fk", + "tableFrom": "refill_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refresh_tokens": { + "name": "refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "refresh_tokens_token_id_unique": { + "name": "refresh_tokens_token_id_unique", + "columns": [ + "token_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_tokens": { + "name": "share_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taken_by": { + "name": "taken_by", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_days": { + "name": "schedule_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "share_tokens_token_unique": { + "name": "share_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "share_tokens_user_id_users_id_fk": { + "name": "share_tokens_user_id_users_id_fk", + "tableFrom": "share_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_settings": { + "name": "user_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_enabled": { + "name": "email_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notification_email": { + "name": "notification_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_stock_reminders": { + "name": "email_stock_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "email_intake_reminders": { + "name": "email_intake_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "email_prescription_reminders": { + "name": "email_prescription_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "shoutrrr_enabled": { + "name": "shoutrrr_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shoutrrr_url": { + "name": "shoutrrr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shoutrrr_stock_reminders": { + "name": "shoutrrr_stock_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "shoutrrr_intake_reminders": { + "name": "shoutrrr_intake_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "shoutrrr_prescription_reminders": { + "name": "shoutrrr_prescription_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "reminder_days_before": { + "name": "reminder_days_before", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "repeat_daily_reminders": { + "name": "repeat_daily_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "skip_reminders_for_taken_doses": { + "name": "skip_reminders_for_taken_doses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "repeat_reminders_enabled": { + "name": "repeat_reminders_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reminder_repeat_interval_minutes": { + "name": "reminder_repeat_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "max_nagging_reminders": { + "name": "max_nagging_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "low_stock_days": { + "name": "low_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "normal_stock_days": { + "name": "normal_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "high_stock_days": { + "name": "high_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 180 + }, + "expiry_warning_days": { + "name": "expiry_warning_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "language": { + "name": "language", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "stock_calculation_mode": { + "name": "stock_calculation_mode", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'automatic'" + }, + "share_stock_status": { + "name": "share_stock_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "upcoming_today_only": { + "name": "upcoming_today_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_schedule_today_only": { + "name": "share_schedule_today_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "swap_dashboard_main_sections": { + "name": "swap_dashboard_main_sections", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_auto_email_sent": { + "name": "last_auto_email_sent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_notification_type": { + "name": "last_notification_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_notification_channel": { + "name": "last_notification_channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_med_name": { + "name": "last_reminder_med_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_taken_by": { + "name": "last_reminder_taken_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_stock_reminder_sent": { + "name": "last_stock_reminder_sent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_stock_reminder_channel": { + "name": "last_stock_reminder_channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_stock_reminder_med_names": { + "name": "last_stock_reminder_med_names", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_prescription_reminder_sent": { + "name": "last_prescription_reminder_sent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_prescription_reminder_channel": { + "name": "last_prescription_reminder_channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_prescription_reminder_med_names": { + "name": "last_prescription_reminder_med_names", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 1a67f83..8de4b25 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -76,14 +76,21 @@ "idx": 10, "version": "6", "when": 1771694832866, - "tag": "0010_mean_spot", + "tag": "0010_add_dose_tracking_taken_source", "breakpoints": true }, { "idx": 11, "version": "6", "when": 1772219947541, - "tag": "0011_stiff_randall_flagg", + "tag": "0011_add_medication_form_lifecycle_columns", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1772881208026, + "tag": "0012_add_api_keys_and_package_amount_columns", "breakpoints": true } ] diff --git a/backend/package-lock.json b/backend/package-lock.json index d079456..d98282b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,8 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", "@fastify/static": "^9.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", "@libsql/client": "^0.17.0", "argon2": "^0.44.0", "dotenv": "^17.3.1", @@ -1512,6 +1514,52 @@ "glob": "^13.0.0" } }, + "node_modules/@fastify/swagger": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz", + "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.5.tgz", + "integrity": "sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^9.0.0", + "fastify-plugin": "^5.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -3183,7 +3231,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4695,6 +4742,23 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -4936,7 +5000,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5083,6 +5146,12 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/openid-client": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", @@ -6207,6 +6276,21 @@ "node": ">=0.4" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index 2f8c651..c0338e7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,8 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", "@fastify/static": "^9.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", "@libsql/client": "^0.17.0", "argon2": "^0.44.0", "dotenv": "^17.3.1", diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index 6816bcb..65e5484 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -189,7 +189,21 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo packs_added INTEGER NOT NULL DEFAULT 0, loose_pills_added INTEGER NOT NULL DEFAULT 0, refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now')) - )`, + )`, + // Added in v1.20.x - API key authentication for programmatic access + `CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'write', + is_active INTEGER NOT NULL DEFAULT 1, + last_used_at INTEGER, + expires_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + )`, ]; for (const sql of createTableMigrations) { @@ -207,6 +221,9 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo const createIndexMigrations = [ // Added in v1.6.x - case-insensitive unique usernames `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, + // Added in v1.20.x - fast API key lookup and ownership filtering + `CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`, + `CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`, ]; for (const sql of createIndexMigrations) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index e994527..4b0337b 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -146,6 +146,25 @@ export const refreshTokens = sqliteTable("refresh_tokens", { createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); +// ============================================================================= +// API Keys - Personal access tokens for programmatic API access +// ============================================================================= +export const apiKeys = sqliteTable("api_keys", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name", { length: 100 }).notNull(), + keyHash: text("key_hash", { length: 128 }).notNull().unique(), + tokenPrefix: text("token_prefix", { length: 24 }).notNull().default(""), + scope: text("scope", { length: 10 }).notNull().default("write"), // 'read' | 'write' + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + lastUsedAt: integer("last_used_at", { mode: "timestamp" }), + expiresAt: integer("expires_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`), +}); + // ============================================================================= // Share Tokens - For public schedule sharing by takenBy person // ============================================================================= diff --git a/backend/src/index.ts b/backend/src/index.ts index b3f4dd9..6461c9d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,10 +10,13 @@ import fastifyMultipart from "@fastify/multipart"; import rateLimit from "@fastify/rate-limit"; import sensible from "@fastify/sensible"; import fastifyStatic from "@fastify/static"; +import fastifySwagger from "@fastify/swagger"; +import fastifySwaggerUi from "@fastify/swagger-ui"; import Fastify, { type FastifyInstance } from "fastify"; import { migrationsReady } from "./db/client.js"; import { getDataDir } from "./db/db-utils.js"; import { env } from "./plugins/env.js"; +import { apiKeyRoutes } from "./routes/api-keys.js"; import { authRoutes } from "./routes/auth.js"; import { doseRoutes } from "./routes/doses.js"; import { exportRoutes } from "./routes/export.js"; @@ -58,12 +61,13 @@ function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null { } function buildLoggerOptions(level: string) { + const runtimeEnv = process.env.NODE_ENV ?? "production"; const base = { level, timestamp: () => `,"time":"${new Date().toISOString()}"`, }; // Human-readable logs in development, structured JSON in production/test - if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") { + if (runtimeEnv === "development") { return { ...base, transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } }, @@ -72,6 +76,55 @@ function buildLoggerOptions(level: string) { return base; } +async function registerApiDocs(app: FastifyInstance, enabled: boolean) { + if (!enabled) return; + + await app.register(fastifySwagger, { + openapi: { + openapi: "3.0.3", + info: { + title: "MedAssist-ng API", + description: "MedAssist-ng backend API", + version: process.env.npm_package_version ?? "dev", + }, + servers: [{ url: "/", description: "Current server" }], + tags: [ + { name: "health", description: "Service health endpoints" }, + { name: "auth", description: "Authentication and profile endpoints" }, + { name: "api-keys", description: "Programmatic API key management" }, + { name: "settings", description: "User settings and notification test endpoints" }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "API key or JWT", + description: "Use Authorization: Bearer ma_... (API key) or a JWT token.", + }, + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "access_token", + description: "Session cookie set by login.", + }, + }, + }, + }, + hideUntagged: false, + }); + + await app.register(fastifySwaggerUi, { + routePrefix: "/docs", + staticCSP: true, + transformSpecificationClone: true, + uiConfig: { + docExpansion: "list", + deepLinking: false, + }, + }); +} + /** Create and configure Fastify app (without starting) */ export async function createApp(options?: { logLevel?: string; @@ -84,6 +137,7 @@ export async function createApp(options?: { refreshTtlDays?: number; isProduction?: boolean; imagesDir?: string; + openApiDocsEnabled?: boolean; }): Promise { const opts = { logLevel: options?.logLevel ?? "info", @@ -96,6 +150,7 @@ export async function createApp(options?: { refreshTtlDays: options?.refreshTtlDays ?? 7, isProduction: options?.isProduction ?? false, imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"), + openApiDocsEnabled: options?.openApiDocsEnabled ?? false, }; const app = Fastify({ @@ -132,6 +187,7 @@ export async function createApp(options?: { await app.register(jwt, jwtConfig); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); + await registerApiDocs(app, opts.openApiDocsEnabled); // Only register static if directory exists if (existsSync(opts.imagesDir)) { @@ -145,6 +201,7 @@ export async function createApp(options?: { // Register routes await app.register(healthRoutes); await app.register(authRoutes); + await app.register(apiKeyRoutes); await app.register(oidcRoutes); await app.register(medicationRoutes); await app.register(settingsRoutes); @@ -215,6 +272,7 @@ const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET); await app.register(jwt, jwtConfig); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit +await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED); await app.register(fastifyStatic, { root: imagesDir, prefix: "/images/", @@ -223,6 +281,7 @@ await app.register(fastifyStatic, { await app.register(healthRoutes); await app.register(authRoutes); +await app.register(apiKeyRoutes); await app.register(oidcRoutes); await app.register(medicationRoutes); await app.register(settingsRoutes); diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index b7a39cb..a493bc9 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -1,7 +1,8 @@ -import { count, eq, sql } from "drizzle-orm"; +import { pbkdf2Sync } from "node:crypto"; +import { and, count, eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { db } from "../db/client.js"; -import { users } from "../db/schema.js"; +import { apiKeys, users } from "../db/schema.js"; import { env } from "./env.js"; // ============================================================================= @@ -82,6 +83,84 @@ export interface RequestUser { username: string; } +const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function isMutationMethod(method: string): boolean { + return !READ_ONLY_METHODS.has(method.toUpperCase()); +} + +function getApiKeyPepper(): string { + return env.JWT_SECRET || env.REFRESH_SECRET || "medassist-api-key-pepper"; +} + +export function hashApiKeyToken(token: string): string { + return pbkdf2Sync(token, getApiKeyPepper(), 120_000, 64, "sha512").toString("hex"); +} + +function getBearerToken(request: FastifyRequest): string | null { + const authHeader = request.headers.authorization; + if (!authHeader) return null; + + const [scheme, value] = authHeader.split(" "); + if (!scheme || !value) return null; + if (scheme.toLowerCase() !== "bearer") return null; + + const token = value.trim(); + return token.length > 0 ? token : null; +} + +async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Promise { + const bearerToken = getBearerToken(request); + if (!bearerToken) return false; + + if (!bearerToken.startsWith("ma_")) { + reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" }); + throw new Error("INVALID_API_KEY"); + } + + const keyHash = hashApiKeyToken(bearerToken); + const [keyRow] = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))); + + if (!keyRow) { + reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" }); + throw new Error("INVALID_API_KEY"); + } + + if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) { + reply.status(401).send({ error: "API key expired", code: "API_KEY_EXPIRED" }); + throw new Error("API_KEY_EXPIRED"); + } + + const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId)); + if (!user || !user.isActive) { + reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" }); + throw new Error("USER_NOT_FOUND"); + } + + const scope = keyRow.scope === "read" ? "read" : "write"; + if (scope === "read" && isMutationMethod(request.method)) { + reply.status(403).send({ error: "API key scope does not allow this operation", code: "API_KEY_SCOPE_FORBIDDEN" }); + throw new Error("API_KEY_SCOPE_FORBIDDEN"); + } + + request.user = { id: user.id, username: user.username }; + request.authContext = { + method: "api_key", + scope, + apiKeyId: keyRow.id, + }; + + await db + .update(apiKeys) + .set({ lastUsedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(apiKeys.id, keyRow.id), eq(apiKeys.userId, user.id))); + + return true; +} + // ============================================================================= // Auth Middleware Functions // ============================================================================= @@ -94,6 +173,28 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply return; } + const bearerToken = getBearerToken(request); + if (bearerToken?.startsWith("ma_")) { + const keyHash = hashApiKeyToken(bearerToken); + const [keyRow] = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))); + if (!keyRow) return; + if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return; + + const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId)); + if (userByKey?.isActive) { + request.user = { id: userByKey.id, username: userByKey.username }; + request.authContext = { + method: "api_key", + scope: keyRow.scope === "read" ? "read" : "write", + apiKeyId: keyRow.id, + }; + } + return; + } + const token = request.cookies.access_token; if (!token) { return; @@ -107,6 +208,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply id: user.id, username: user.username, }; + request.authContext = { + method: "session", + scope: "write", + }; } } catch { // Invalid token, continue as anonymous @@ -121,6 +226,10 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply) return; } + if (await tryApiKeyAuth(request, reply)) { + return; + } + const token = request.cookies.access_token; if (!token) { reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" }); @@ -145,11 +254,20 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply) id: user.id, username: user.username, }; + request.authContext = { + method: "session", + scope: "write", + }; } catch (err: unknown) { // Re-throw our own errors if ( err instanceof Error && - (err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED") + (err.message === "AUTH_REQUIRED" || + err.message === "USER_NOT_FOUND" || + err.message === "ACCOUNT_DISABLED" || + err.message === "INVALID_API_KEY" || + err.message === "API_KEY_EXPIRED" || + err.message === "API_KEY_SCOPE_FORBIDDEN") ) { throw err; } diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index aded744..f8c4ffe 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -14,6 +14,10 @@ const EnvSchema = z.object({ .default("3000"), CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), LOG_LEVEL: z.string().default("info"), + OPENAPI_DOCS_ENABLED: z + .string() + .transform((v) => v === "true") + .optional(), // ========================================================================== // Auth Configuration @@ -69,10 +73,13 @@ const EnvSchema = z.object({ OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button }); -export type Env = z.infer; +type ParsedEnv = z.infer; +export type Env = ParsedEnv & { + OPENAPI_DOCS_ENABLED: boolean; +}; // Parse and validate -let parsed: z.infer; +let parsed: ParsedEnv; try { parsed = EnvSchema.parse(process.env); } catch (err) { @@ -154,4 +161,8 @@ if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) { ); } -export const env = parsed; +export const env: Env = { + ...parsed, + // Docs UI/spec are enabled in non-production by default. + OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production", +}; diff --git a/backend/src/routes/api-keys.ts b/backend/src/routes/api-keys.ts new file mode 100644 index 0000000..bb10a28 --- /dev/null +++ b/backend/src/routes/api-keys.ts @@ -0,0 +1,294 @@ +import { randomBytes } from "node:crypto"; +import { and, desc, eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { apiKeys } from "../db/schema.js"; +import { hashApiKeyToken, requireAuth } from "../plugins/auth.js"; +import { env } from "../plugins/env.js"; +import type { AuthUser } from "../types/fastify.js"; + +const createApiKeySchema = z.object({ + name: z.string().trim().min(3).max(100), + scope: z.enum(["read", "write"]).default("write"), + expiresInDays: z.number().int().min(1).max(3650).optional(), +}); + +const idParamSchema = z.object({ + id: z.string().regex(/^\d+$/), +}); + +const protectedEndpointSecurity = [{ bearerAuth: [] }]; +const genericErrorSchema = { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + }, +}; + +const apiKeyMetadataSchema = { + type: "object", + properties: { + id: { type: "number" }, + name: { type: "string" }, + tokenPrefix: { type: "string" }, + scope: { type: "string", enum: ["read", "write"] }, + isActive: { type: "boolean" }, + lastUsedAt: { type: ["string", "null"], format: "date-time" }, + expiresAt: { type: ["string", "null"], format: "date-time" }, + createdAt: { type: ["string", "null"], format: "date-time" }, + updatedAt: { type: ["string", "null"], format: "date-time" }, + }, +}; + +function normalizeDateTime(value: unknown): string | null { + if (value == null) { + return null; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + if (typeof value === "number") { + const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value; + const date = new Date(timestampMs); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + return null; +} + +function serializeApiKeyMetadata< + T extends { + id: number; + name: string; + tokenPrefix: string; + scope: string; + isActive: boolean; + lastUsedAt: unknown; + expiresAt: unknown; + createdAt: unknown; + updatedAt: unknown; + }, +>(key: T) { + return { + id: key.id, + name: key.name, + tokenPrefix: key.tokenPrefix, + scope: key.scope, + isActive: key.isActive, + lastUsedAt: normalizeDateTime(key.lastUsedAt), + expiresAt: normalizeDateTime(key.expiresAt), + createdAt: normalizeDateTime(key.createdAt), + updatedAt: normalizeDateTime(key.updatedAt), + }; +} + +export async function apiKeyRoutes(app: FastifyInstance) { + app.addHook("preHandler", requireAuth); + + app.get( + "/auth/api-keys", + { + schema: { + tags: ["api-keys"], + summary: "List API keys for the current user", + description: "Returns API key metadata. Raw API key tokens are never returned.", + security: protectedEndpointSecurity, + response: { + 200: { + type: "object", + properties: { + keys: { + type: "array", + items: apiKeyMetadataSchema, + }, + }, + }, + 400: genericErrorSchema, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + if (!env.AUTH_ENABLED) { + return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" }); + } + + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" }); + } + + const keys = await db + .select({ + id: apiKeys.id, + name: apiKeys.name, + tokenPrefix: apiKeys.tokenPrefix, + scope: apiKeys.scope, + isActive: apiKeys.isActive, + lastUsedAt: apiKeys.lastUsedAt, + expiresAt: apiKeys.expiresAt, + createdAt: apiKeys.createdAt, + updatedAt: apiKeys.updatedAt, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, authUser.id)) + .orderBy(desc(apiKeys.createdAt)); + + return { keys: keys.map(serializeApiKeyMetadata) }; + } + ); + + app.post<{ Body: z.infer }>( + "/auth/api-keys", + { + schema: { + tags: ["api-keys"], + summary: "Create and rotate API key", + description: + "Creates a new API key and deactivates previously active API keys for the current user. The new token is returned only once.", + security: protectedEndpointSecurity, + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 3, maxLength: 100 }, + scope: { type: "string", enum: ["read", "write"], default: "write" }, + expiresInDays: { type: "number", minimum: 1, maximum: 3650 }, + }, + }, + response: { + 201: { + type: "object", + properties: { + key: apiKeyMetadataSchema, + token: { type: "string" }, + note: { type: "string" }, + }, + }, + 400: { anyOf: [genericErrorSchema, { type: "object" }] }, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + if (!env.AUTH_ENABLED) { + return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" }); + } + + const parsed = createApiKeySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send(parsed.error.format()); + } + + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" }); + } + + const { name, scope, expiresInDays } = parsed.data; + const rawToken = `ma_${randomBytes(32).toString("hex")}`; + const tokenPrefix = `${rawToken.slice(0, 12)}...`; + const keyHash = hashApiKeyToken(rawToken); + const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null; + + // Keep a single active key per user: creating a new key invalidates old ones. + await db + .update(apiKeys) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(apiKeys.userId, authUser.id), eq(apiKeys.isActive, true))); + + const [created] = await db + .insert(apiKeys) + .values({ + userId: authUser.id, + name, + keyHash, + tokenPrefix, + scope, + expiresAt, + }) + .returning({ + id: apiKeys.id, + name: apiKeys.name, + tokenPrefix: apiKeys.tokenPrefix, + scope: apiKeys.scope, + isActive: apiKeys.isActive, + lastUsedAt: apiKeys.lastUsedAt, + expiresAt: apiKeys.expiresAt, + createdAt: apiKeys.createdAt, + updatedAt: apiKeys.updatedAt, + }); + + return reply.status(201).send({ + key: serializeApiKeyMetadata(created), + token: rawToken, + note: "Store this token now. It cannot be retrieved again.", + }); + } + ); + + app.delete<{ Params: { id: string } }>( + "/auth/api-keys/:id", + { + schema: { + tags: ["api-keys"], + summary: "Deactivate API key", + description: "Deactivates one API key belonging to the current user.", + security: protectedEndpointSecurity, + params: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", pattern: "^\\d+$" }, + }, + }, + response: { + 204: { type: "null" }, + 400: { anyOf: [genericErrorSchema, { type: "object" }] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + if (!env.AUTH_ENABLED) { + return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" }); + } + + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.status(400).send(parsedParams.error.format()); + } + + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" }); + } + + const keyId = Number(parsedParams.data.id); + const [existing] = await db + .select({ id: apiKeys.id, userId: apiKeys.userId }) + .from(apiKeys) + .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id))); + if (!existing) { + return reply.status(404).send({ error: "API key not found", code: "API_KEY_NOT_FOUND" }); + } + + await db + .update(apiKeys) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id))); + + return reply.status(204).send(); + } + ); +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2148113..c5d249e 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -85,6 +85,38 @@ const updateProfileSchema = z.object({ .optional(), }); +const authEndpointSecurity: ReadonlyArray> = [{ bearerAuth: [] }, { cookieAuth: [] }]; +const authErrorSchema = { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + }, +}; + +function normalizeDateTime(value: unknown): string | null { + if (value == null) { + return null; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + if (typeof value === "number") { + const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value; + const date = new Date(timestampMs); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + return null; +} + // ============================================================================= // Auth Routes // ============================================================================= @@ -99,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) { // GET /auth/state - Public auth state (needed before login) // Exempt from rate limit - lightweight state check called frequently // --------------------------------------------------------------------------- - app.get("/auth/state", { config: { rateLimit: false } }, async () => { - return getAuthState(); - }); + app.get( + "/auth/state", + { + config: { rateLimit: false }, + schema: { + tags: ["auth"], + summary: "Get authentication state", + description: "Returns auth and login mode state before user login.", + response: { + 200: { + type: "object", + properties: { + authEnabled: { type: "boolean" }, + registrationEnabled: { type: "boolean" }, + formLoginEnabled: { type: "boolean" }, + oidcEnabled: { type: "boolean" }, + hasUsers: { type: "boolean" }, + oidcProviderName: { type: "string" }, + }, + }, + }, + }, + }, + async () => { + return getAuthState(); + } + ); // --------------------------------------------------------------------------- // POST /auth/register - User registration @@ -110,6 +166,36 @@ export async function authRoutes(app: FastifyInstance) { "/auth/register", { config: { rateLimit: sensitiveRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Register local user", + body: { + type: "object", + required: ["username", "password"], + properties: { + username: { type: "string", minLength: 3, maxLength: 50 }, + password: { type: "string", minLength: 8, maxLength: 128 }, + }, + }, + response: { + 201: { + type: "object", + properties: { + ok: { type: "boolean" }, + user: { + type: "object", + properties: { + id: { type: "number" }, + username: { type: "string" }, + }, + }, + message: { type: "string" }, + }, + }, + 400: authErrorSchema, + 409: authErrorSchema, + }, + }, }, async (request, reply) => { // Check auth state @@ -177,6 +263,37 @@ export async function authRoutes(app: FastifyInstance) { "/auth/login", { config: { rateLimit: sensitiveRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Login with username and password", + body: { + type: "object", + required: ["username", "password"], + properties: { + username: { type: "string" }, + password: { type: "string" }, + rememberMe: { type: "boolean" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + ok: { type: "boolean" }, + user: { + type: "object", + properties: { + id: { type: "number" }, + username: { type: "string" }, + avatarUrl: { type: ["string", "null"] }, + }, + }, + }, + }, + 400: authErrorSchema, + 401: authErrorSchema, + }, + }, }, async (request, reply) => { const state = await getAuthState(); @@ -281,6 +398,15 @@ export async function authRoutes(app: FastifyInstance) { "/auth/refresh", { config: { rateLimit: authRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Refresh access token", + description: "Requires refresh token cookie context.", + response: { + 200: { type: "object", properties: { ok: { type: "boolean" } } }, + 401: authErrorSchema, + }, + }, }, async (request, reply) => { const refreshTokenCookie = request.cookies.refresh_token; @@ -350,6 +476,13 @@ export async function authRoutes(app: FastifyInstance) { "/auth/logout", { config: { rateLimit: authRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Logout and clear auth cookies", + response: { + 200: { type: "object", properties: { ok: { type: "boolean" } } }, + }, + }, }, async (request, reply) => { const refreshTokenCookie = request.cookies.refresh_token; @@ -375,26 +508,56 @@ export async function authRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // GET /auth/me - Get current user profile // --------------------------------------------------------------------------- - app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => { - const authUser = request.user as unknown as AuthUser | null; - if (!authUser) { - return reply.status(401).send({ error: "Not authenticated" }); - } + app.get( + "/auth/me", + { + preHandler: requireAuth, + schema: { + tags: ["auth"], + summary: "Get current user profile", + security: authEndpointSecurity, + response: { + 200: { + type: "object", + properties: { + id: { type: "number" }, + username: { type: "string" }, + avatarUrl: { type: ["string", "null"] }, + authProvider: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + lastLoginAt: { type: ["string", "null"], format: "date-time" }, + }, + }, + 401: authErrorSchema, + 404: authErrorSchema, + }, + }, + }, + async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } - const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); - if (!user) { - return reply.status(404).send({ error: "User not found" }); - } + const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); + if (!user) { + return reply.status(404).send({ error: "User not found" }); + } - return { - id: user.id, - username: user.username, - avatarUrl: user.avatarUrl, - authProvider: user.authProvider, - createdAt: user.createdAt, - lastLoginAt: user.lastLoginAt, - }; - }); + const createdAt = + normalizeDateTime(user.createdAt) ?? normalizeDateTime(user.updatedAt) ?? new Date(0).toISOString(); + const lastLoginAt = normalizeDateTime(user.lastLoginAt); + + return { + id: user.id, + username: user.username, + avatarUrl: user.avatarUrl, + authProvider: user.authProvider ?? "local", + createdAt, + lastLoginAt, + }; + } + ); // --------------------------------------------------------------------------- // PUT /auth/me - Update current user profile @@ -404,6 +567,30 @@ export async function authRoutes(app: FastifyInstance) { { preHandler: requireAuth, config: { rateLimit: authRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Update current user profile", + security: authEndpointSecurity, + body: { + type: "object", + properties: { + currentPassword: { type: "string" }, + newPassword: { type: "string", minLength: 8, maxLength: 128 }, + }, + }, + response: { + 200: { + type: "object", + properties: { + ok: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 400: authErrorSchema, + 401: authErrorSchema, + 404: authErrorSchema, + }, + }, }, async (request, reply) => { const authUser = request.user as unknown as AuthUser | null; @@ -462,6 +649,24 @@ export async function authRoutes(app: FastifyInstance) { { preHandler: requireAuth, config: { rateLimit: authRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Upload user avatar", + description: "Uploads and optimizes a profile image using multipart/form-data.", + security: authEndpointSecurity, + consumes: ["multipart/form-data"], + response: { + 200: { + type: "object", + properties: { + ok: { type: "boolean" }, + avatarUrl: { type: "string" }, + }, + }, + 400: authErrorSchema, + 401: authErrorSchema, + }, + }, }, async (request, reply) => { const authUser = request.user as unknown as AuthUser | null; @@ -517,6 +722,16 @@ export async function authRoutes(app: FastifyInstance) { { preHandler: requireAuth, config: { rateLimit: authRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Delete user avatar", + security: authEndpointSecurity, + response: { + 200: { type: "object", properties: { ok: { type: "boolean" } } }, + 401: authErrorSchema, + 404: authErrorSchema, + }, + }, }, async (request, reply) => { const authUser = request.user as unknown as AuthUser | null; @@ -547,6 +762,22 @@ export async function authRoutes(app: FastifyInstance) { { preHandler: requireAuth, config: { rateLimit: sensitiveRateLimitConfig }, + schema: { + tags: ["auth"], + summary: "Delete current user account", + description: "Deletes the current account and related data (cascade delete).", + security: authEndpointSecurity, + response: { + 200: { + type: "object", + properties: { + ok: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 401: authErrorSchema, + }, + }, }, async (request, reply) => { const authUser = request.user as unknown as AuthUser | null; diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index eaaebcb..92bc5b2 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -267,6 +267,7 @@ export async function doseRoutes(app: FastifyInstance) { userId, doseId, markedBy: null, + takenAt: new Date(0), dismissed: true, }); dismissedCount++; @@ -291,7 +292,9 @@ export async function doseRoutes(app: FastifyInstance) { .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true))); for (const d of dismissed) { - if (d.markedBy !== null || d.takenAt) { + const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt); + + if (d.markedBy !== null || hasRealTakenTimestamp) { // This was also marked as taken - just remove dismissed flag await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id)); } else { @@ -307,28 +310,41 @@ export async function doseRoutes(app: FastifyInstance) { // GET /share/:token/doses - PUBLIC: Get taken doses for a share link // Suppress request logs — polled every 5s by SharedSchedule // --------------------------------------------------------------------------- - app.get<{ Params: { token: string } }>("/share/:token/doses", { logLevel: "warn" }, async (request, reply) => { - const { token } = request.params; + app.get<{ Params: { token: string } }>( + "/share/:token/doses", + { + logLevel: "warn", + config: { + rateLimit: { + max: 60, + timeWindow: "1 minute", + errorResponseBuilder: () => ({ error: "rate_limited" }), + }, + }, + }, + async (request, reply) => { + const { token } = request.params; - const { share, reason } = await getActiveShareToken(token); - if (!share) { - request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`); - return reply.notFound("Share link not found"); + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`); + return reply.notFound("Share link not found"); + } + + // Get all taken doses for this user (no time limit) + const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)); + + return { + doses: doses.map((d) => ({ + doseId: d.doseId, + takenAt: d.takenAt?.getTime() ?? Date.now(), + markedBy: d.markedBy, + takenSource: d.takenSource ?? "manual", + dismissed: d.dismissed ?? false, + })), + }; } - - // Get all taken doses for this user (no time limit) - const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)); - - return { - doses: doses.map((d) => ({ - doseId: d.doseId, - takenAt: d.takenAt?.getTime() ?? Date.now(), - markedBy: d.markedBy, - takenSource: d.takenSource ?? "manual", - dismissed: d.dismissed ?? false, - })), - }; - }); + ); // --------------------------------------------------------------------------- // POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index 20b5164..52c0fd1 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -6,6 +6,7 @@ import { medications, refillHistory } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; const refillSchema = z .object({ @@ -52,9 +53,34 @@ export async function refillRoutes(app: FastifyInstance) { if (!med) return reply.notFound("Medication not found"); const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; - const isBottle = (med.packageType ?? "blister") === "bottle"; - const effectivePacksAdded = isBottle ? 0 : packsAdded; - const effectiveLoosePillsAdded = loosePillsAdded; + const packageType = normalizePackageType(med.packageType); + const isBottle = packageType === "bottle"; + const isAmountBased = isAmountBasedPackageType(packageType); + const isCountBasedAmountPackage = isAmountBased && !isBottle; + + const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); + const fallbackAmountPerPackage = Math.max( + 1, + Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1)) + ); + const amountPerPackage = + Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0 + ? configuredAmountPerPackage + : fallbackAmountPerPackage; + + const requestedPackAdds = Math.max(0, packsAdded); + const requestedAmountAdds = Math.max(0, loosePillsAdded); + const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage)); + + let effectivePacksAdded = requestedPackAdds; + if (isBottle) { + effectivePacksAdded = 0; + } else if (isCountBasedAmountPackage) { + effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount); + } + const effectiveLoosePillsAdded = isCountBasedAmountPackage + ? effectivePacksAdded * amountPerPackage + : requestedAmountAdds; const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { @@ -76,6 +102,8 @@ export async function refillRoutes(app: FastifyInstance) { // Update medication stock const newPackCount = med.packCount + effectivePacksAdded; const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; + const previousAmountBase = med.totalPills ?? med.looseTablets; + const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded; let consumedRefills = 0; if (usePrescription) { @@ -85,14 +113,28 @@ export async function refillRoutes(app: FastifyInstance) { ? Math.max(0, remainingPrescriptionRefills - consumedRefills) : (med.prescriptionRemainingRefills ?? null); + const updatePayload: { + packCount: number; + looseTablets: number; + totalPills?: number; + packageAmountValue?: number; + prescriptionRemainingRefills: number | null; + updatedAt: Date; + } = { + packCount: newPackCount, + looseTablets: newLooseTablets, + prescriptionRemainingRefills: newRemainingRefills, + updatedAt: new Date(), + }; + + if (isCountBasedAmountPackage) { + updatePayload.totalPills = newTotalAmount; + updatePayload.packageAmountValue = amountPerPackage; + } + await db .update(medications) - .set({ - packCount: newPackCount, - looseTablets: newLooseTablets, - prescriptionRemainingRefills: newRemainingRefills, - updatedAt: new Date(), - }) + .set(updatePayload) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); // Create refill history entry @@ -109,12 +151,15 @@ export async function refillRoutes(app: FastifyInstance) { // Calculate pills added for response (packageType-aware) const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; - const totalPillsAdded = isBottle + const totalPillsAdded = isAmountBased ? effectiveLoosePillsAdded : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; - const newTotalPills = isBottle - ? newLooseTablets + (med.stockAdjustment ?? 0) - : newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); + let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); + if (isCountBasedAmountPackage) { + newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0); + } else if (isBottle) { + newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0); + } return { success: true, @@ -158,17 +203,19 @@ export async function refillRoutes(app: FastifyInstance) { const refills = await db .select() .from(refillHistory) - .where(eq(refillHistory.medicationId, medId)) + .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))) .orderBy(desc(refillHistory.refillDate)); - const isBottle = (med.packageType ?? "blister") === "bottle"; + const packageType = normalizePackageType(med.packageType); + const isBottle = packageType === "bottle"; + const isAmountBased = isAmountBasedPackageType(packageType); const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; return refills.map((r) => ({ id: r.id, packsAdded: r.packsAdded, loosePillsAdded: r.loosePillsAdded, - totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, + totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, usedPrescription: r.usedPrescription ?? false, refillDate: r.refillDate, })); diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts index c40f251..62e69d4 100644 --- a/backend/src/routes/report.ts +++ b/backend/src/routes/report.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; @@ -90,8 +90,11 @@ export async function reportRoutes(app: FastifyInstance) { const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); - // Get refills for this medication - const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId)); + // Get refills for this medication scoped to the authenticated user. + const refills = await db + .select() + .from(refillHistory) + .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))); result[medId] = { dosesTaken: takenDoses.length, diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 67e5d1d..53ea015 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -85,6 +85,18 @@ type TestShoutrrrBody = { url: string; }; +const settingsEndpointSecurity: ReadonlyArray> = [ + { bearerAuth: [] }, + { cookieAuth: [] }, +]; +const settingsErrorSchema = { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + }, +}; + function maskEmail(email: string): string { const [localPart, domain] = email.split("@"); if (!domain) return "invalid-email"; @@ -122,6 +134,38 @@ function getDeliveryError(info: MailDeliveryInfo): string | null { return "SMTP did not confirm accepted recipients."; } +function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const normalizedMessage = errorMessage.toLowerCase(); + + if ( + normalizedMessage.includes("smtp rejected all recipients") || + normalizedMessage.includes("all recipients were rejected") || + normalizedMessage.includes("recipient address rejected") || + normalizedMessage.includes("nullmx") + ) { + return { + status: 400, + code: "EMAIL_RECIPIENT_REJECTED", + message: `Failed to send email: ${errorMessage}`, + }; + } + + if (errorMessage.includes("SMTP did not confirm accepted recipients")) { + return { + status: 502, + code: "SMTP_DELIVERY_UNCONFIRMED", + message: `Failed to send email: ${errorMessage}`, + }; + } + + return { + status: 500, + code: "TEST_EMAIL_FAILED", + message: `Failed to send email: ${errorMessage}`, + }; +} + function getNotificationProvider(url: string): string { if (url.startsWith("discord://")) return "discord"; if (url.startsWith("telegram://")) return "telegram"; @@ -322,201 +366,313 @@ export async function settingsRoutes(app: FastifyInstance) { // Get settings for current user // Suppress request logs — polled every 30s for reminder status refresh - app.get("/settings", { logLevel: "warn" }, async (request, reply) => { - const userId = await getUserId(request, reply); + app.get( + "/settings", + { + logLevel: "warn", + schema: { + tags: ["settings"], + summary: "Get current user settings", + security: settingsEndpointSecurity, + response: { + 200: { type: "object", additionalProperties: true }, + 401: settingsErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); - const settings = await getOrCreateUserSettings(userId); - const reminderHour = envInt("REMINDER_HOUR", 6); - const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15); + const settings = await getOrCreateUserSettings(userId); + const reminderHour = envInt("REMINDER_HOUR", 6); + const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15); - return reply.send({ - // User notification settings (from DB) - emailEnabled: settings.emailEnabled, - notificationEmail: settings.notificationEmail ?? "", - reminderDaysBefore: settings.reminderDaysBefore, - repeatDailyReminders: settings.repeatDailyReminders, - lowStockDays: settings.lowStockDays, - normalStockDays: settings.normalStockDays, - highStockDays: settings.highStockDays, - shoutrrrEnabled: settings.shoutrrrEnabled, - shoutrrrUrl: settings.shoutrrrUrl ?? "", - emailStockReminders: settings.emailStockReminders, - emailIntakeReminders: settings.emailIntakeReminders, - emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, - shoutrrrStockReminders: settings.shoutrrrStockReminders, - shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, - shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, - skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, - repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, - reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, - maxNaggingReminders: settings.maxNaggingReminders ?? 5, - language: settings.language, - stockCalculationMode: settings.stockCalculationMode ?? "automatic", - shareStockStatus: settings.shareStockStatus ?? true, - upcomingTodayOnly: settings.upcomingTodayOnly ?? false, - shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, - swapDashboardMainSections: settings.swapDashboardMainSections ?? false, - // SMTP settings (from .env - shared/server-configured) - smtpHost: process.env.SMTP_HOST ?? "", - smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10), - smtpUser: process.env.SMTP_USER ?? "", - smtpFrom: process.env.SMTP_FROM ?? "", - smtpSecure: process.env.SMTP_SECURE === "true", - hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS), - // Reminder state for this user - lastAutoEmailSent: settings.lastAutoEmailSent, - lastNotificationType: settings.lastNotificationType, - lastNotificationChannel: settings.lastNotificationChannel, - lastReminderMedName: settings.lastReminderMedName ?? null, - lastReminderTakenBy: settings.lastReminderTakenBy ?? null, - // Stock reminder tracking (separate from intake) - lastStockReminderSent: settings.lastStockReminderSent ?? null, - lastStockReminderChannel: settings.lastStockReminderChannel ?? null, - lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, - // Prescription reminder tracking (separate from stock/intake) - lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, - lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, - lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, - // Server settings (from .env, read-only) - reminderHour, - reminderMinutesBefore, - expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10), - }); - }); + return reply.send({ + // User notification settings (from DB) + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail ?? "", + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + shoutrrrEnabled: settings.shoutrrrEnabled, + shoutrrrUrl: settings.shoutrrrUrl ?? "", + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, + repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, + language: settings.language, + stockCalculationMode: settings.stockCalculationMode ?? "automatic", + shareStockStatus: settings.shareStockStatus ?? true, + upcomingTodayOnly: settings.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: settings.swapDashboardMainSections ?? false, + // SMTP settings (from .env - shared/server-configured) + smtpHost: process.env.SMTP_HOST ?? "", + smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10), + smtpUser: process.env.SMTP_USER ?? "", + smtpFrom: process.env.SMTP_FROM ?? "", + smtpSecure: process.env.SMTP_SECURE === "true", + hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS), + // Reminder state for this user + lastAutoEmailSent: settings.lastAutoEmailSent, + lastNotificationType: settings.lastNotificationType, + lastNotificationChannel: settings.lastNotificationChannel, + lastReminderMedName: settings.lastReminderMedName ?? null, + lastReminderTakenBy: settings.lastReminderTakenBy ?? null, + // Stock reminder tracking (separate from intake) + lastStockReminderSent: settings.lastStockReminderSent ?? null, + lastStockReminderChannel: settings.lastStockReminderChannel ?? null, + lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, + // Prescription reminder tracking (separate from stock/intake) + lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, + lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, + lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, + // Server settings (from .env, read-only) + reminderHour, + reminderMinutesBefore, + expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10), + }); + } + ); // Update settings for current user - app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => { - const userId = await getUserId(request, reply); + app.put<{ Body: SettingsBody }>( + "/settings", + { + schema: { + tags: ["settings"], + summary: "Update current user settings", + security: settingsEndpointSecurity, + body: { + type: "object", + required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"], + properties: { + emailEnabled: { type: "boolean" }, + notificationEmail: { type: "string" }, + reminderDaysBefore: { type: "number" }, + repeatDailyReminders: { type: "boolean" }, + lowStockDays: { type: "number" }, + normalStockDays: { type: "number" }, + highStockDays: { type: "number" }, + shoutrrrEnabled: { type: "boolean" }, + shoutrrrUrl: { type: "string" }, + emailStockReminders: { type: "boolean" }, + emailIntakeReminders: { type: "boolean" }, + emailPrescriptionReminders: { type: "boolean" }, + shoutrrrStockReminders: { type: "boolean" }, + shoutrrrIntakeReminders: { type: "boolean" }, + shoutrrrPrescriptionReminders: { type: "boolean" }, + skipRemindersForTakenDoses: { type: "boolean" }, + repeatRemindersEnabled: { type: "boolean" }, + reminderRepeatIntervalMinutes: { type: "number" }, + maxNaggingReminders: { type: "number" }, + language: { type: "string", enum: ["en", "de"] }, + stockCalculationMode: { type: "string", enum: ["automatic", "manual"] }, + shareStockStatus: { type: "boolean" }, + upcomingTodayOnly: { type: "boolean" }, + shareScheduleTodayOnly: { type: "boolean" }, + swapDashboardMainSections: { type: "boolean" }, + }, + }, + response: { + 200: { type: "object", properties: { success: { type: "boolean" } } }, + 401: settingsErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); - const body = request.body; + const body = request.body; - // Check if any stock reminders are configured - const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail; - const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl; - const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock; + // Check if any stock reminders are configured + const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail; + const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl; + const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock; - // Disable repeatDailyReminders if no stock reminders are configured - const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false; + // Disable repeatDailyReminders if no stock reminders are configured + const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false; - // Update or insert user settings - const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + // Update or insert user settings + const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); - const settingsData = { - emailEnabled: body.emailEnabled, - notificationEmail: body.notificationEmail || null, - emailStockReminders: body.emailStockReminders ?? true, - emailIntakeReminders: body.emailIntakeReminders ?? true, - emailPrescriptionReminders: body.emailPrescriptionReminders ?? true, - shoutrrrEnabled: body.shoutrrrEnabled ?? false, - shoutrrrUrl: body.shoutrrrUrl || null, - shoutrrrStockReminders: body.shoutrrrStockReminders ?? true, - shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, - shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true, - reminderDaysBefore: body.reminderDaysBefore, - repeatDailyReminders, - skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false, - repeatRemindersEnabled: body.repeatRemindersEnabled ?? false, - reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30, - maxNaggingReminders: body.maxNaggingReminders ?? 5, - lowStockDays: body.lowStockDays ?? 30, - normalStockDays: body.normalStockDays ?? 90, - highStockDays: body.highStockDays ?? 180, - language: body.language ?? "en", - stockCalculationMode: body.stockCalculationMode ?? "automatic", - shareStockStatus: body.shareStockStatus ?? true, - upcomingTodayOnly: body.upcomingTodayOnly ?? false, - shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false, - swapDashboardMainSections: body.swapDashboardMainSections ?? false, - updatedAt: new Date(), - }; + const settingsData = { + emailEnabled: body.emailEnabled, + notificationEmail: body.notificationEmail || null, + emailStockReminders: body.emailStockReminders ?? true, + emailIntakeReminders: body.emailIntakeReminders ?? true, + emailPrescriptionReminders: body.emailPrescriptionReminders ?? true, + shoutrrrEnabled: body.shoutrrrEnabled ?? false, + shoutrrrUrl: body.shoutrrrUrl || null, + shoutrrrStockReminders: body.shoutrrrStockReminders ?? true, + shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, + shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true, + reminderDaysBefore: body.reminderDaysBefore, + repeatDailyReminders, + skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: body.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: body.maxNaggingReminders ?? 5, + lowStockDays: body.lowStockDays ?? 30, + normalStockDays: body.normalStockDays ?? 90, + highStockDays: body.highStockDays ?? 180, + language: body.language ?? "en", + stockCalculationMode: body.stockCalculationMode ?? "automatic", + shareStockStatus: body.shareStockStatus ?? true, + upcomingTodayOnly: body.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: body.swapDashboardMainSections ?? false, + updatedAt: new Date(), + }; - if (existingSettings.length > 0) { - await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId)); - } else { - await db.insert(userSettings).values({ - userId: userId, - ...settingsData, - }); + if (existingSettings.length > 0) { + await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId)); + } else { + await db.insert(userSettings).values({ + userId: userId, + ...settingsData, + }); + } + + return reply.send({ success: true }); } - - return reply.send({ success: true }); - }); + ); // Update only the language setting (lightweight, called on dropdown change) - app.put<{ Body: { language: string } }>("/settings/language", async (request, reply) => { - const userId = await getUserId(request, reply); - const { language } = request.body; + app.put<{ Body: { language: string } }>( + "/settings/language", + { + schema: { + tags: ["settings"], + summary: "Update UI language", + security: settingsEndpointSecurity, + body: { + type: "object", + required: ["language"], + properties: { + language: { type: "string", enum: ["en", "de"] }, + }, + }, + response: { + 200: { type: "object", properties: { success: { type: "boolean" } } }, + 400: settingsErrorSchema, + 401: settingsErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const { language } = request.body; - if (!language || !["en", "de"].includes(language)) { - return reply.status(400).send({ error: "Invalid language" }); + if (!language || !["en", "de"].includes(language)) { + return reply.status(400).send({ error: "Invalid language" }); + } + + const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + + if (existingSettings.length > 0) { + await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId)); + } else { + await db.insert(userSettings).values({ + userId, + ...getDefaultSettings(), + language, + }); + } + + return reply.send({ success: true }); } - - const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); - - if (existingSettings.length > 0) { - await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId)); - } else { - await db.insert(userSettings).values({ - userId, - ...getDefaultSettings(), - language, - }); - } - - return reply.send({ success: true }); - }); + ); // Test email - use SMTP settings from process.env - app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => { - const { email } = request.body; - - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - - request.log.info( - { - to: maskEmail(email), - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - hasSmtpPass: Boolean(smtpPass), - hasSmtpFrom: Boolean(smtpFrom), - smtpPort, - smtpSecure, - }, - "[Settings] Test email request received" - ); - - if (!smtpHost || !smtpUser) { - request.log.warn( - { to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) }, - "[Settings] Test email skipped: SMTP not configured" - ); - return reply.status(400).send({ error: "SMTP not configured" }); - } - - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", + app.post<{ Body: TestEmailBody }>( + "/settings/test-email", + { + schema: { + tags: ["settings"], + summary: "Send test email", + description: "Sends a test message using configured SMTP settings.", + security: settingsEndpointSecurity, + body: { + type: "object", + required: ["email"], + properties: { + email: { type: "string", format: "email" }, + }, }, - }); + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 400: settingsErrorSchema, + 401: settingsErrorSchema, + 500: settingsErrorSchema, + 502: settingsErrorSchema, + }, + }, + }, + async (request, reply) => { + const { email } = request.body; - request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email"); + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - const mailResult = await transporter.sendMail({ - from: smtpFrom, - to: email, - subject: "MedAssist-ng - Test Email", - text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!", - html: ` + request.log.info( + { + to: maskEmail(email), + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + hasSmtpFrom: Boolean(smtpFrom), + smtpPort, + smtpSecure, + }, + "[Settings] Test email request received" + ); + + if (!smtpHost || !smtpUser) { + request.log.warn( + { to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) }, + "[Settings] Test email skipped: SMTP not configured" + ); + return reply.status(400).send({ error: "SMTP not configured" }); + } + + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); + + request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email"); + + const mailResult = await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: "MedAssist-ng - Test Email", + text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!", + html: `

MedAssist-ng - Test Email

This is a test email from MedAssist-ng.

@@ -525,55 +681,86 @@ export async function settingsRoutes(app: FastifyInstance) {

Sent from MedAssist-ng Medication Planner

`, - }); + }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent"); + + return reply.send({ success: true, message: "Test email sent successfully" }); + } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed"); + const failure = classifyTestEmailFailure(error); + return reply.status(failure.status).send({ error: failure.message, code: failure.code }); } - - request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent"); - - return reply.send({ success: true, message: "Test email sent successfully" }); - } catch (error) { - request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed"); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` }); } - }); + ); // Test Shoutrrr/ntfy notification - app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => { - const { url } = request.body; + app.post<{ Body: TestShoutrrrBody }>( + "/settings/test-shoutrrr", + { + schema: { + tags: ["settings"], + summary: "Send test push notification", + description: "Sends a test notification via a Shoutrrr-compatible URL.", + security: settingsEndpointSecurity, + body: { + type: "object", + required: ["url"], + properties: { + url: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 400: settingsErrorSchema, + 401: settingsErrorSchema, + 500: settingsErrorSchema, + }, + }, + }, + async (request, reply) => { + const { url } = request.body; - if (!url) { - return reply.status(400).send({ error: "Notification URL is required" }); - } - - try { - const provider = getNotificationProvider(url); - const result = await sendShoutrrrNotification( - url, - "MedAssist-ng Test", - "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!" - ); - - if (result.success) { - request.log.info({ provider }, "[Settings] Test push notification sent"); - return reply.send({ success: true, message: "Test notification sent successfully" }); - } else { - request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed"); - return reply.status(500).send({ error: result.error }); + if (!url) { + return reply.status(400).send({ error: "Notification URL is required" }); + } + + try { + const provider = getNotificationProvider(url); + const result = await sendShoutrrrNotification( + url, + "MedAssist-ng Test", + "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!" + ); + + if (result.success) { + request.log.info({ provider }, "[Settings] Test push notification sent"); + return reply.send({ success: true, message: "Test notification sent successfully" }); + } else { + request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed"); + return reply.status(500).send({ error: result.error }); + } + } catch (error) { + request.log.error( + { provider: getNotificationProvider(url), error }, + "[Settings] Unexpected error while sending test push notification" + ); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` }); } - } catch (error) { - request.log.error( - { provider: getNotificationProvider(url), error }, - "[Settings] Unexpected error while sending test push notification" - ); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` }); } - }); + ); } // Validate and sanitize URL to prevent SSRF attacks diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index cf53c3c..7d4a231 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -3,9 +3,10 @@ import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; -import { medications, shareTokens, userSettings, users } from "../db/schema.js"; +import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { buildSharedMedicationOverview } from "../services/coverage.js"; import type { AuthUser } from "../types/fastify.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; import { @@ -23,6 +24,8 @@ const createShareSchema = z.object({ scheduleDays: z.number().int().min(1).max(365).default(30), }); +const shareTokenPattern = /^[a-f0-9]{16}$/; + function maskToken(token: string): string { if (token.length <= 8) return token; return `${token.slice(0, 4)}...${token.slice(-4)}`; @@ -51,119 +54,202 @@ export async function shareRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // GET /share/:token - PUBLIC: Get shared schedule by token // --------------------------------------------------------------------------- - app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => { - const { token } = request.params; - - // Find share token - const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); - if (!share) { - request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`); - return reply.status(404).send({ - error: "Share link not found", - code: "NOT_FOUND", - }); - } - - // Check if token has expired - if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { - request.log.warn( - `[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})` - ); - // Get the username of the owner to show in the expired message - const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); - return reply.status(410).send({ - error: "Share link has expired", - code: "EXPIRED", - ownerUsername: owner?.username ?? "the owner", - takenBy: share.takenBy, - expiredAt: share.expiresAt.toISOString(), - }); - } - - // Get user settings for stock thresholds - const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId)); - - // Get the username of the owner who created this share link - const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); - - // Get medications for this user filtered by takenBy (search in JSON array) - // Use SQLite JSON function to check if takenBy is in the array - const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId)); - - // Filter medications where takenBy matches either medication-level OR any intake-level takenBy - const meds = allMeds.filter((med) => { - const takenByArray = parseTakenByJson(med.takenByJson); - const intakes = parseIntakesJson( - med.intakesJson, - { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, - med.intakeRemindersEnabled ?? false - ); - return personTakesMedication(share.takenBy, takenByArray, intakes); - }); - - // Parse blisters and build schedule data - const medicationsWithBlisters = meds.map((med) => { - // Parse intakes from new format, falling back to legacy - const intakes = parseIntakesJson( - med.intakesJson, - { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, - med.intakeRemindersEnabled ?? false - ); - - // Convert to legacy blisters format for backward compat - const blisters = intakes.map((i) => ({ - usage: i.usage, - every: i.every, - start: i.start, - })); - - // Parse takenBy JSON array - const takenByArray = parseTakenByJson(med.takenByJson); - - const totalPills = isAmountBasedPackageType(med.packageType) - ? med.looseTablets + (med.stockAdjustment ?? 0) - : med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); - return { - id: med.id, - name: med.name, - genericName: med.genericName, - pillWeightMg: med.pillWeightMg, - doseUnit: med.doseUnit ?? "mg", - imageUrl: med.imageUrl, - totalPills, - packageType: normalizePackageType(med.packageType), - packCount: med.packCount, - blistersPerPack: med.blistersPerPack, - looseTablets: med.looseTablets, - pillsPerBlister: med.pillsPerBlister, - takenBy: takenByArray, - intakes, // New unified format with per-intake takenBy - blisters, // Legacy format for backward compat - dismissedUntil: med.dismissedUntil, - updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations - lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null, - stockAdjustment: med.stockAdjustment ?? 0, - }; - }); - - return { - takenBy: share.takenBy, - sharedBy: owner?.username ?? null, - scheduleDays: share.scheduleDays, - medications: medicationsWithBlisters, - stockThresholds: { - lowStockDays: settings?.lowStockDays ?? 30, - normalStockDays: settings?.normalStockDays ?? 60, - highStockDays: settings?.highStockDays ?? 90, - reminderDaysBefore: settings?.reminderDaysBefore ?? 7, - expiryWarningDays: settings?.expiryWarningDays ?? 90, + app.get<{ Params: { token: string } }>( + "/share/:token", + { + config: { + rateLimit: { + max: 60, + timeWindow: "1 minute", + errorResponseBuilder: () => ({ error: "rate_limited" }), + }, }, - stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", - shareStockStatus: settings?.shareStockStatus ?? true, - upcomingTodayOnly: settings?.upcomingTodayOnly ?? false, - shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false, - }; - }); + }, + async (request, reply) => { + const { token } = request.params; + + // Find share token + const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + if (!share) { + request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`); + return reply.status(404).send({ + error: "Share link not found", + code: "NOT_FOUND", + }); + } + + // Check if token has expired + if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { + request.log.warn( + `[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})` + ); + // Get the username of the owner to show in the expired message + const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); + return reply.status(410).send({ + error: "Share link has expired", + code: "EXPIRED", + ownerUsername: owner?.username ?? "the owner", + takenBy: share.takenBy, + expiredAt: share.expiresAt.toISOString(), + }); + } + + // Get user settings for stock thresholds + const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId)); + + // Get the username of the owner who created this share link + const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); + + // Get medications for this user filtered by takenBy (search in JSON array) + // Use SQLite JSON function to check if takenBy is in the array + const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId)); + + // Filter medications where takenBy matches either medication-level OR any intake-level takenBy + const meds = allMeds.filter((med) => { + const takenByArray = parseTakenByJson(med.takenByJson); + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + return personTakesMedication(share.takenBy, takenByArray, intakes); + }); + + // Parse blisters and build schedule data + const medicationsWithBlisters = meds.map((med) => { + // Parse intakes from new format, falling back to legacy + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + + // Convert to legacy blisters format for backward compat + const blisters = intakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, + })); + + // Parse takenBy JSON array + const takenByArray = parseTakenByJson(med.takenByJson); + + const totalPills = isAmountBasedPackageType(med.packageType) + ? med.looseTablets + (med.stockAdjustment ?? 0) + : med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); + return { + id: med.id, + name: med.name, + genericName: med.genericName, + pillWeightMg: med.pillWeightMg, + doseUnit: med.doseUnit ?? "mg", + imageUrl: med.imageUrl, + totalPills, + packageType: normalizePackageType(med.packageType), + packCount: med.packCount, + blistersPerPack: med.blistersPerPack, + looseTablets: med.looseTablets, + pillsPerBlister: med.pillsPerBlister, + takenBy: takenByArray, + intakes, // New unified format with per-intake takenBy + blisters, // Legacy format for backward compat + dismissedUntil: med.dismissedUntil, + updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations + lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null, + stockAdjustment: med.stockAdjustment ?? 0, + }; + }); + + return { + takenBy: share.takenBy, + sharedBy: owner?.username ?? null, + scheduleDays: share.scheduleDays, + medications: medicationsWithBlisters, + stockThresholds: { + lowStockDays: settings?.lowStockDays ?? 30, + normalStockDays: settings?.normalStockDays ?? 60, + highStockDays: settings?.highStockDays ?? 90, + reminderDaysBefore: settings?.reminderDaysBefore ?? 7, + expiryWarningDays: settings?.expiryWarningDays ?? 90, + }, + stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", + shareStockStatus: settings?.shareStockStatus ?? true, + upcomingTodayOnly: settings?.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false, + }; + } + ); + + // --------------------------------------------------------------------------- + // GET /share/:token/overview - PUBLIC: Read-only medication overview by token + // --------------------------------------------------------------------------- + app.get<{ Params: { token: string } }>( + "/share/:token/overview", + { + config: { + rateLimit: { + max: 60, + timeWindow: "1 minute", + errorResponseBuilder: () => ({ error: "rate_limited" }), + }, + }, + }, + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + + const { token } = request.params; + if (!shareTokenPattern.test(token)) { + request.log.warn(`[ShareOverview] Rejected invalid token format: ${maskToken(token)}`); + return reply.status(404).send({ error: "not_found" }); + } + + const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + if (!share) { + request.log.warn(`[ShareOverview] Unknown token requested: ${maskToken(token)}`); + return reply.status(404).send({ error: "not_found" }); + } + + if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { + request.log.warn( + `[ShareOverview] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})` + ); + return reply.status(410).send({ + error: "expired", + expiredAt: share.expiresAt.toISOString(), + }); + } + + const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId)); + const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); + + const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId)); + const meds = allMeds.filter((med) => { + const takenByArray = parseTakenByJson(med.takenByJson); + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + return personTakesMedication(share.takenBy, takenByArray, intakes); + }); + + const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)); + + const overview = buildSharedMedicationOverview({ + medications: meds, + doses, + thresholdDays: settings?.lowStockDays ?? 30, + shareStockStatus: settings?.shareStockStatus ?? true, + }); + + return { + takenBy: share.takenBy, + sharedBy: owner?.username ?? null, + generatedAt: new Date().toISOString(), + medications: overview, + }; + } + ); // --------------------------------------------------------------------------- // POST /share - PROTECTED: Create a new share link diff --git a/backend/src/services/coverage.ts b/backend/src/services/coverage.ts new file mode 100644 index 0000000..660cf31 --- /dev/null +++ b/backend/src/services/coverage.ts @@ -0,0 +1,205 @@ +import type { doseTracking, medications } from "../db/schema.js"; +import { isAmountBasedPackageType } from "../utils/package-profiles.js"; +import { + getTodayInTimezone, + type Intake, + normalizeIntakeUsageForStock, + parseIntakesJson, + parseLocalDateTime, +} from "../utils/scheduler-utils.js"; + +const MS_PER_DAY = 86_400_000; +const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; + +type MedicationRow = typeof medications.$inferSelect; +type DoseRow = typeof doseTracking.$inferSelect; + +export type SharedMedicationOverviewItem = { + name: string; + genericName: string | null; + imageUrl: string | null; + packageType: string; + packCount: number; + blistersPerPack: number; + pillsPerBlister: number; + totalPills: number | null; + looseTablets: number; + currentStock: number | null; + capacity: number | null; + daysLeft: number | null; + nextIntakeDate: string | null; + depletionDate: string | null; + priority: "normal" | "high" | null; + expiryDate: string | null; + medicationStartDate: string | null; + prescriptionEnabled: boolean; + prescriptionRemainingRefills: number | null; +}; + +function toDateOnlyString(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function parseDateOnly(dateOnly: string): Date { + const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10)); + return new Date(year, month - 1, day, 0, 0, 0, 0); +} + +function computeCapacity(medication: MedicationRow): number { + if (isAmountBasedPackageType(medication.packageType)) { + return medication.totalPills ?? medication.looseTablets; + } + + return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister; +} + +function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number { + return intakes.reduce((sum, intake) => { + if (intake.every <= 0) return sum; + const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType); + return sum + normalizedUsage / intake.every; + }, 0); +} + +function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null { + const today = parseDateOnly(todayDateOnly); + let nextDate: Date | null = null; + + for (const intake of intakes) { + if (intake.every <= 0) continue; + + const startDate = parseLocalDateTime(intake.start); + const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0); + + let candidate = startDateOnly; + if (candidate.getTime() < today.getTime()) { + const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY); + const intervals = Math.ceil(elapsedDays / intake.every); + candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY); + } + + if (!nextDate || candidate.getTime() < nextDate.getTime()) { + nextDate = candidate; + } + } + + return nextDate ? toDateOnlyString(nextDate) : null; +} + +function computeTakenAmount( + medication: MedicationRow, + intakes: Intake[], + dosesByMedication: Map +): number { + const doseRows = dosesByMedication.get(medication.id) ?? []; + if (doseRows.length === 0) return 0; + + const correctionDateOnlyMs = medication.lastStockCorrectionAt + ? new Date( + medication.lastStockCorrectionAt.getFullYear(), + medication.lastStockCorrectionAt.getMonth(), + medication.lastStockCorrectionAt.getDate(), + 0, + 0, + 0, + 0 + ).getTime() + : 0; + + let takenAmount = 0; + for (const dose of doseRows) { + if (dose.dismissed) continue; + + const match = doseIdPattern.exec(dose.doseId); + if (!match) continue; + + const intakeIndex = Number.parseInt(match[2], 10); + const doseDateOnlyMs = Number.parseInt(match[3], 10); + if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue; + if (doseDateOnlyMs < correctionDateOnlyMs) continue; + + const intake = intakes[intakeIndex]; + if (!intake) continue; + + takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType); + } + + return takenAmount; +} + +function toNullableDate(value: string | null): string | null { + if (!value) return null; + return value.trim() ? value : null; +} + +export function buildSharedMedicationOverview(options: { + medications: MedicationRow[]; + doses: DoseRow[]; + thresholdDays: number; + shareStockStatus: boolean; +}): SharedMedicationOverviewItem[] { + const { medications: medicationRows, doses, thresholdDays, shareStockStatus } = options; + + const dosesByMedication = new Map(); + for (const dose of doses) { + const match = doseIdPattern.exec(dose.doseId); + if (!match) continue; + + const medicationId = Number.parseInt(match[1], 10); + if (Number.isNaN(medicationId)) continue; + + const existing = dosesByMedication.get(medicationId) ?? []; + existing.push(dose); + dosesByMedication.set(medicationId, existing); + } + + const todayDateOnly = getTodayInTimezone(); + const todayDate = parseDateOnly(todayDateOnly); + + return medicationRows.map((medication) => { + const intakes = parseIntakesJson( + medication.intakesJson, + { + usageJson: medication.usageJson, + everyJson: medication.everyJson, + startJson: medication.startJson, + }, + medication.intakeRemindersEnabled ?? false + ); + + const capacity = computeCapacity(medication); + const dailyDoseRate = computeDailyDoseRate(intakes, medication); + const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication); + const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount; + const currentStock = Math.max(0, Math.floor(rawCurrentStock)); + const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null; + const depletionDate = + daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY)); + const priority: "normal" | "high" = daysLeft !== null && daysLeft <= thresholdDays ? "high" : "normal"; + + return { + name: medication.name, + genericName: medication.genericName, + imageUrl: medication.imageUrl, + packageType: medication.packageType, + packCount: medication.packCount, + blistersPerPack: medication.blistersPerPack, + pillsPerBlister: medication.pillsPerBlister, + totalPills: medication.totalPills, + looseTablets: medication.looseTablets, + currentStock: shareStockStatus ? currentStock : null, + capacity: shareStockStatus ? capacity : null, + daysLeft: shareStockStatus ? daysLeft : null, + nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly), + depletionDate: shareStockStatus ? depletionDate : null, + priority: shareStockStatus ? priority : null, + expiryDate: toNullableDate(medication.expiryDate), + medicationStartDate: toNullableDate(medication.medicationStartDate), + prescriptionEnabled: medication.prescriptionEnabled ?? false, + prescriptionRemainingRefills: medication.prescriptionRemainingRefills, + }; + }); +} diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index b33c4b0..1b9dd44 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -4,7 +4,7 @@ import { and, eq, gte, lte } from "drizzle-orm"; import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { getDataDir } from "../db/db-utils.js"; -import { doseTracking, medications } from "../db/schema.js"; +import { doseTracking, medications, users } from "../db/schema.js"; import { getDateLocale, getFooterHtml, @@ -89,6 +89,21 @@ function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; b return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`; } +async function resolveSchedulerUserDisplayName(userId: number): Promise { + const [userRow] = await db.select({ username: users.username }).from(users).where(eq(users.id, userId)).limit(1); + return userRow?.username?.trim() || `unknown-user-${userId}`; +} + +function formatIntakeDescriptor( + definitionIndex: number, + medicationName: string, + medicationId: number, + intake: { every: number; usage: number; start: string; intakeRemindersEnabled: boolean; takenBy: string | null } +): string { + const takenByPart = intake.takenBy ? `, takenBy=${intake.takenBy}` : ""; + return `Intake #${definitionIndex + 1} (index=${definitionIndex}, medication=${medicationName}, medicationId=${medicationId}, start=${intake.start}, every=${intake.every}d, usage=${intake.usage}, reminderEnabled=${intake.intakeRemindersEnabled}${takenByPart})`; +} + async function autoMarkDueIntakesAsTaken( settings: UserSettings & { userId: number }, rows: (typeof medications.$inferSelect)[], @@ -182,7 +197,7 @@ async function autoMarkDueIntakesAsTaken( } if (inserted > 0) { - logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`); + logger.info(`[IntakeReminder] Auto-marked ${inserted} due intake dose(s) as taken`); } return inserted; @@ -375,9 +390,20 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise return; // No users with settings } - logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`); + const intakeEligibleSettings = allUserSettings.filter((settings) => { + const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders; + const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; + return Boolean(emailEnabled || shoutrrrEnabled); + }); - for (const userSettings of allUserSettings) { + if (intakeEligibleSettings.length === 0) { + logger.debug("[IntakeReminder] No intake notification channels enabled"); + return; + } + + logger.debug(`[IntakeReminder] Evaluating ${intakeEligibleSettings.length} intake reminder profile(s)`); + + for (const userSettings of intakeEligibleSettings) { await checkAndSendIntakeRemindersForUser(userSettings, logger); } } @@ -388,10 +414,9 @@ async function checkAndSendIntakeRemindersForUser( ): Promise { const language = settings.language; const tr = getTranslations(language); + const schedulerUserName = await resolveSchedulerUserDisplayName(settings.userId); - logger.debug( - `[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}` - ); + logger.debug(`[IntakeReminder] Evaluating intake reminder profile for user '${schedulerUserName}'`); const rows = await db .select() @@ -409,14 +434,11 @@ async function checkAndSendIntakeRemindersForUser( const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; if (!emailEnabled && !shoutrrrEnabled) { - logger.debug( - `[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})` - ); return; // No intake reminder notifications enabled for this user } logger.debug( - `[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})` + `[IntakeReminder] Notifications enabled for current scheduler context (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})` ); // Build medication entries that have at least one reminder-enabled intake. @@ -434,25 +456,26 @@ async function checkAndSendIntakeRemindersForUser( .filter((entry) => entry.intakesWithReminders.length > 0); if (reminderEntries.length === 0) { - logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`); + logger.debug("[IntakeReminder] No medications have reminders enabled for current scheduler context"); return; // No medications have reminders enabled for this user } - logger.debug(`[IntakeReminder] User ${settings.userId}: Found ${reminderEntries.length} medications with reminders`); + logger.debug(`[IntakeReminder] Found ${reminderEntries.length} medications with reminders`); const state = loadIntakeReminderState(); const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; + let scheduledIntakesTodayCount = 0; // Get start and end of today in user's timezone (for filtering today's doses only) const now = new Date(); + const checkMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000); + const checkMinuteEnd = new Date(checkMinuteStart.getTime() + 60000); const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz })); todayStart.setHours(0, 0, 0, 0); const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz })); todayEnd.setHours(23, 59, 59, 999); - logger.debug( - `[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}` - ); + logger.debug(`[IntakeReminder] Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`); // Find intakes: upcoming ones in reminder window + past ones for repeat reminders for (const { med, intakes, intakesWithReminders } of reminderEntries) { @@ -461,15 +484,26 @@ async function checkAndSendIntakeRemindersForUser( const medDisplayName = med.name || med.genericName || ""; logger.debug( - `[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes` + `[IntakeReminder] Processing medication '${medDisplayName}' (id=${med.id}) with ${intakes.length} intake definition(s)` ); // Process each intake separately to track blisterIndex intakesWithReminders.forEach((intake, _blisterIndex) => { const actualIndex = intakes.indexOf(intake); // Get the actual index in original array - logger.debug( - `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}` + const intakeDescriptor = formatIntakeDescriptor(actualIndex, medDisplayName, med.id, intake); + logger.debug(`[IntakeReminder] ${intakeDescriptor}`); + + const todaysIntakesForThisDefinition = getTodaysIntakes( + medDisplayName, + [intake], + medicationTakenBy, + med.pillWeightMg, + locale, + tz, + med.id, + med.doseUnit ?? "mg" ); + scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length; // Always get upcoming intakes (15 min before) for first reminders const upcomingIntakes = getUpcomingIntakes( @@ -485,7 +519,10 @@ async function checkAndSendIntakeRemindersForUser( med.doseUnit ?? "mg" ); logger.debug( - `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)` + `[IntakeReminder] ${intakeDescriptor} -> ${upcomingIntakes.length} intake(s) currently due for advance reminder (default ${REMINDER_MINUTES_BEFORE} min before intake, with catch-up while intake is still in the future)` + ); + logger.debug( + `[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} scheduled intake(s) today (independent of reminder window)` ); // Add upcoming intakes for first reminders @@ -499,24 +536,14 @@ async function checkAndSendIntakeRemindersForUser( // If repeat reminders enabled, also check for missed intakes (past the intake time) if (settings.repeatRemindersEnabled) { - const allTodaysIntakes = getTodaysIntakes( - medDisplayName, - [intake], - medicationTakenBy, - med.pillWeightMg, - locale, - tz, - med.id, - med.doseUnit ?? "mg" - ); logger.debug( - `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}` + `[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} candidate intake(s) for repeat reminders` ); - const missedIntakes = allTodaysIntakes.filter( + const missedIntakes = todaysIntakesForThisDefinition.filter( (todayIntake) => todayIntake.intakeTime.getTime() < now.getTime() ); logger.debug( - `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)` + `[IntakeReminder] ${intakeDescriptor} -> ${missedIntakes.length} missed intake(s) (past intake time)` ); // Add missed intakes for repeat reminders (only if not already in upcoming list) @@ -534,10 +561,13 @@ async function checkAndSendIntakeRemindersForUser( }); } - logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`); + logger.debug(`[IntakeReminder] Total scheduled intakes for today: ${scheduledIntakesTodayCount}`); + logger.debug(`[IntakeReminder] Total reminder candidates in current check: ${allUpcoming.length}`); if (allUpcoming.length === 0) { - logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`); + logger.debug( + `[IntakeReminder] No reminder due in this check window (minute=${checkMinuteStart.toISOString()}..${checkMinuteEnd.toISOString()}, advanceLead=${REMINDER_MINUTES_BEFORE}m, plus catch-up while intake is still future)` + ); return; // No upcoming intakes for today } @@ -569,7 +599,7 @@ async function checkAndSendIntakeRemindersForUser( // Send a catch-up reminder (counts as first nagging reminder). remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false }); logger.info( - `[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)` + `[IntakeReminder] Catch-up reminder for recently missed intake (${Math.round(minutesSinceIntake)} min ago)` ); } else { // Long ago — seed state without notification (user likely already noticed) @@ -580,15 +610,13 @@ async function checkAndSendIntakeRemindersForUser( advanceSent: false, }; logger.debug( - `[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)` + `[IntakeReminder] Seeding state for old past intake (no notification — ${Math.round(minutesSinceIntake)} min ago)` ); } } else { // Upcoming - this is advance reminder (no counter) remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true }); - logger.debug( - `[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}` - ); + logger.debug("[IntakeReminder] Advance reminder candidate added"); } } else if (settings.repeatRemindersEnabled && isIntakePast) { // Intake time passed - check if we need to send nagging reminder @@ -601,15 +629,11 @@ async function checkAndSendIntakeRemindersForUser( if (currentNaggingCount >= maxReminders) { // Max nagging reminders reached - stop - logger.debug( - `[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}` - ); + logger.debug(`[IntakeReminder] Max nagging (${maxReminders}) reached for intake reminder key`); } else if (timeSinceLastReminder >= intervalMs) { const nextSendCount = currentNaggingCount + 1; remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false }); - logger.debug( - `[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})` - ); + logger.debug(`[IntakeReminder] Nagging reminder candidate added (${nextSendCount}/${maxReminders})`); } } // Else: Already sent and either repeats disabled or intake not yet past - skip @@ -647,9 +671,7 @@ async function checkAndSendIntakeRemindersForUser( const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`; const isTaken = takenDoseIds.has(doseId); if (isTaken) { - logger.debug( - `[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken` - ); + logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken"); } return !isTaken; } else { @@ -657,21 +679,19 @@ async function checkAndSendIntakeRemindersForUser( const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`; const isTaken = takenDoseIds.has(doseId); if (isTaken) { - logger.debug( - `[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken` - ); + logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken"); } return !isTaken; } }); if (remindersToSend.length === 0) { - logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`); + logger.debug("[IntakeReminder] All doses taken, skipping reminders"); return; } } - logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`); + logger.info(`[IntakeReminder] Sending reminder for ${remindersToSend.length} intakes...`); // Determine if this is a repeat reminder: // - Any intake already has a state entry AND is past (repeat after first reminder) @@ -703,11 +723,9 @@ async function checkAndSendIntakeRemindersForUser( ); emailSuccess = result.success; if (result.success) { - logger.info( - `[IntakeReminder] User ${settings.userId}: Email sent successfully (to: ${settings.notificationEmail}, messageId: ${result.messageId}, smtp: ${result.smtpResponse})` - ); + logger.info("[IntakeReminder] Email sent successfully"); } else { - logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`); + logger.error(`[IntakeReminder] Failed to send email: ${result.error}`); } } @@ -771,9 +789,9 @@ async function checkAndSendIntakeRemindersForUser( const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (result.success) { - logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`); + logger.info("[IntakeReminder] Push notification sent successfully"); } else { - logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`); + logger.error(`[IntakeReminder] Failed to send push: ${result.error}`); } } diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index e054219..94f9605 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -687,12 +687,10 @@ async function checkAndSendReminderForUser( if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) { const stockSendLock = acquireReminderSendLock(userStockNotifiedKey); if (!stockSendLock) { - logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`); + logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send"); } else { try { - logger.info( - `[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...` - ); + logger.info(`[Reminder] Sending stock reminder for ${allLowStock.length} medications...`); let emailSuccess = false; let shoutrrrSuccess = false; @@ -706,7 +704,7 @@ async function checkAndSendReminderForUser( ); emailSuccess = result.success; if (!result.success) { - logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`); + logger.error(`[Reminder] Failed to send stock email: ${result.error}`); } } @@ -748,7 +746,7 @@ async function checkAndSendReminderForUser( const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { - logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`); + logger.error(`[Reminder] Failed to send stock push: ${result.error}`); } } @@ -780,9 +778,7 @@ async function checkAndSendReminderForUser( if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) { const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey); if (!prescriptionSendLock) { - logger.debug( - `[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send` - ); + logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send"); } else { try { // Re-check using fresh state after acquiring lock and pre-mark today as notified. @@ -791,9 +787,7 @@ async function checkAndSendReminderForUser( const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey); const shouldSend = !alreadyNotified || settings.repeatDailyReminders; if (!shouldSend) { - logger.debug( - `[Reminder] User ${settings.userId}: prescription reminder already marked as sent today, skipping` - ); + logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping"); } const preMarkedNotified = @@ -813,9 +807,7 @@ async function checkAndSendReminderForUser( } if (shouldSend) { - logger.info( - `[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...` - ); + logger.info(`[Reminder] Sending prescription reminder for ${allPrescriptionLow.length} medications...`); const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0); const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0); @@ -947,9 +939,7 @@ async function checkAndSendReminderForUser( emailSuccess = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - logger.error( - `[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}` - ); + logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`); } } } @@ -986,7 +976,7 @@ async function checkAndSendReminderForUser( const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { - logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`); + logger.error(`[Reminder] Failed to send prescription push: ${result.error}`); } } diff --git a/backend/src/test/auth.test.ts b/backend/src/test/auth.test.ts index 1111162..e005368 100644 --- a/backend/src/test/auth.test.ts +++ b/backend/src/test/auth.test.ts @@ -228,7 +228,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }); expect(response.statusCode).toBe(400); - expect(response.json().code).toBe("VALIDATION_ERROR"); + expect(response.json().code).toBe("FST_ERR_VALIDATION"); }); it("should reject short username", async () => { @@ -242,7 +242,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }); expect(response.statusCode).toBe(400); - expect(response.json().code).toBe("VALIDATION_ERROR"); + expect(response.json().code).toBe("FST_ERR_VALIDATION"); }); it("should register with trimmed username when input has whitespace", async () => { diff --git a/backend/src/test/business-authz-real.test.ts b/backend/src/test/business-authz-real.test.ts new file mode 100644 index 0000000..64f797a --- /dev/null +++ b/backend/src/test/business-authz-real.test.ts @@ -0,0 +1,485 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import sensible from "@fastify/sensible"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import Fastify, { type FastifyInstance } from "fastify"; +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: { + AUTH_ENABLED: true, + REGISTRATION_ENABLED: true, + FORM_LOGIN_ENABLED: true, + OIDC_ENABLED: false, + OIDC_PROVIDER_NAME: "SSO", + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + JWT_SECRET: "test-jwt-secret", + REFRESH_SECRET: "test-refresh-secret", + COOKIE_SECRET: "test-cookie-secret", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + OPENAPI_DOCS_ENABLED: false, + }, + }; +}); + +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +const { medicationRoutes } = await import("../routes/medications.js"); +const { doseRoutes } = await import("../routes/doses.js"); +const { refillRoutes } = await import("../routes/refills.js"); +const { shareRoutes } = await import("../routes/share.js"); +const { reportRoutes } = await import("../routes/report.js"); +const { exportRoutes } = await import("../routes/export.js"); +const { hashApiKeyToken } = await import("../plugins/auth.js"); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +async function clearTables() { + await testClient.execute("DELETE FROM refill_history"); + await testClient.execute("DELETE FROM dose_tracking"); + await testClient.execute("DELETE FROM share_tokens"); + await testClient.execute("DELETE FROM user_settings"); + await testClient.execute("DELETE FROM medications"); + await testClient.execute("DELETE FROM api_keys"); + await testClient.execute("DELETE FROM refresh_tokens"); + 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); +} + +function buildSessionCookie(app: FastifyInstance, userId: number, username: string) { + const token = app.jwt.sign({ sub: userId, username }); + return `access_token=${token}`; +} + +async function insertApiKey(options: { + userId: number; + token: string; + scope?: "read" | "write"; + isActive?: boolean; + expiresAt?: Date | null; +}) { + const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null; + + await testClient.execute({ + sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [ + options.userId, + "Seeded Key", + hashApiKeyToken(options.token), + `${options.token.slice(0, 12)}...`, + options.scope ?? "write", + options.isActive === false ? 0 : 1, + expiresAtValue, + ], + }); +} + +async function seedMedication(options: { + userId: number; + name: string; + takenBy?: string[]; + packCount?: number; + looseTablets?: number; + start?: string; +}) { + const start = options.start ?? "2026-01-01T08:00:00.000Z"; + const takenBy = options.takenBy ?? ["Daniel"]; + const result = await testClient.execute({ + sql: `INSERT INTO medications ( + user_id, name, generic_name, taken_by_json, medication_form, package_type, + pack_count, blisters_per_pack, pills_per_blister, loose_tablets, + usage_json, every_json, start_json, intakes_json, + stock_adjustment, intake_reminders_enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + options.userId, + options.name, + `${options.name} Generic`, + JSON.stringify(takenBy), + "tablet", + "blister", + options.packCount ?? 1, + 1, + 10, + options.looseTablets ?? 0, + JSON.stringify([1]), + JSON.stringify([1]), + JSON.stringify([start]), + JSON.stringify([ + { + usage: 1, + every: 1, + start, + takenBy: takenBy[0] ?? null, + intakeRemindersEnabled: true, + }, + ]), + 0, + 1, + ], + }); + + return Number(result.rows[0].id); +} + +async function seedDose(options: { userId: number; doseId: string; dismissed?: boolean }) { + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, ?)", + args: [options.userId, options.doseId, options.dismissed ? 1 : 0], + }); +} + +async function seedRefill(options: { + userId: number; + medicationId: number; + packsAdded?: number; + loosePillsAdded?: number; +}) { + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription) + VALUES (?, ?, ?, ?, 0)`, + args: [options.medicationId, options.userId, options.packsAdded ?? 1, options.loosePillsAdded ?? 0], + }); +} + +function buildMedicationPayload(name: string) { + return { + name, + genericName: `${name} Generic`, + takenBy: ["Daniel"], + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }], + }; +} + +function buildImportPayload() { + return { + version: "1.3", + exportedAt: new Date().toISOString(), + includeSensitiveData: false, + medications: [], + doseHistory: [], + refillHistory: [], + settings: { + emailEnabled: false, + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + reminderDaysBefore: 7, + repeatDailyReminders: false, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + }, + shareLinks: [], + }; +} + +describe("Real business route authz contracts", () => { + let app: FastifyInstance; + + beforeAll(async () => { + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + + app = Fastify({ logger: false }); + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwt, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(medicationRoutes); + await app.register(doseRoutes); + await app.register(refillRoutes); + await app.register(shareRoutes); + await app.register(reportRoutes); + await app.register(exportRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await clearTables(); + }); + + it("rejects protected business endpoints without authentication", async () => { + const endpoints: Array<{ + method: "GET" | "POST"; + url: string; + payload?: Record; + }> = [ + { method: "GET", url: "/medications" }, + { method: "GET", url: "/doses/taken" }, + { method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 7 } }, + { method: "GET", url: "/export" }, + { method: "POST", url: "/medications/report-data", payload: { medicationIds: [1] } }, + { method: "POST", url: "/medications/1/refill", payload: { packsAdded: 1, loosePillsAdded: 0 } }, + ]; + + for (const endpoint of endpoints) { + const response = await app.inject({ method: endpoint.method, url: endpoint.url, payload: endpoint.payload }); + expect(response.statusCode, `${endpoint.method} ${endpoint.url}`).toBe(401); + expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" }); + } + }); + + it("scopes medication listing and export output to the authenticated user", async () => { + const ownerId = await createUser("owner-medications"); + const otherId = await createUser("other-medications"); + const ownerCookie = buildSessionCookie(app, ownerId, "owner-medications"); + + await seedMedication({ userId: ownerId, name: "Owner Only Med" }); + await seedMedication({ userId: otherId, name: "Other User Med" }); + + const listResponse = await app.inject({ + method: "GET", + url: "/medications", + headers: { cookie: ownerCookie }, + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.body).toContain("Owner Only Med"); + expect(listResponse.body).not.toContain("Other User Med"); + + const exportResponse = await app.inject({ + method: "GET", + url: "/export", + headers: { cookie: ownerCookie }, + }); + + expect(exportResponse.statusCode).toBe(200); + expect(exportResponse.body).toContain("Owner Only Med"); + expect(exportResponse.body).not.toContain("Other User Med"); + }); + + it("returns 404 when a user updates or deletes another user's medication", async () => { + const ownerId = await createUser("owner-update"); + const otherId = await createUser("other-update"); + const otherCookie = buildSessionCookie(app, otherId, "other-update"); + const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" }); + + const updateResponse = await app.inject({ + method: "PUT", + url: `/medications/${medicationId}`, + headers: { cookie: otherCookie }, + payload: buildMedicationPayload("Updated By Stranger"), + }); + + expect(updateResponse.statusCode).toBe(404); + + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/medications/${medicationId}`, + headers: { cookie: otherCookie }, + }); + + expect(deleteResponse.statusCode).toBe(404); + + const dbState = await testClient.execute({ + sql: "SELECT name FROM medications WHERE id = ?", + args: [medicationId], + }); + expect(dbState.rows).toEqual([expect.objectContaining({ name: "Protected Medication" })]); + }); + + it("scopes dose reads and writes to the authenticated user", async () => { + const ownerId = await createUser("owner-dose"); + const otherId = await createUser("other-dose"); + const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose"); + const otherCookie = buildSessionCookie(app, otherId, "other-dose"); + + await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" }); + await seedDose({ userId: otherId, doseId: "202-0-1760000000000" }); + + const listResponse = await app.inject({ + method: "GET", + url: "/doses/taken", + headers: { cookie: ownerCookie }, + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.body).toContain("101-0-1760000000000"); + expect(listResponse.body).not.toContain("202-0-1760000000000"); + + const deleteResponse = await app.inject({ + method: "DELETE", + url: "/doses/taken/101-0-1760000000000", + headers: { cookie: otherCookie }, + }); + + expect(deleteResponse.statusCode).toBe(200); + + const ownerDose = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [ownerId, "101-0-1760000000000"], + }); + expect(Number(ownerDose.rows[0].count)).toBe(1); + }); + + it("enforces medication ownership on refill history and report generation", async () => { + const ownerId = await createUser("owner-refill"); + const otherId = await createUser("other-refill"); + const otherCookie = buildSessionCookie(app, otherId, "other-refill"); + const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 }); + await seedRefill({ userId: ownerId, medicationId }); + + const refillListResponse = await app.inject({ + method: "GET", + url: `/medications/${medicationId}/refills`, + headers: { cookie: otherCookie }, + }); + + expect(refillListResponse.statusCode).toBe(404); + + const refillMutationResponse = await app.inject({ + method: "POST", + url: `/medications/${medicationId}/refill`, + headers: { cookie: otherCookie }, + payload: { packsAdded: 1, loosePillsAdded: 0 }, + }); + + expect(refillMutationResponse.statusCode).toBe(404); + + const reportResponse = await app.inject({ + method: "POST", + url: "/medications/report-data", + headers: { cookie: otherCookie }, + payload: { medicationIds: [medicationId] }, + }); + + expect(reportResponse.statusCode).toBe(403); + expect(reportResponse.json()).toMatchObject({ error: "Access denied to medication" }); + }); + + it("scopes share people to the authenticated user's medications", async () => { + const ownerId = await createUser("owner-share"); + const otherId = await createUser("other-share"); + const ownerCookie = buildSessionCookie(app, ownerId, "owner-share"); + + await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] }); + await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] }); + + const response = await app.inject({ + method: "GET", + url: "/share/people", + headers: { cookie: ownerCookie }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ people: ["Daniel"] }); + }); + + it("rejects mutation routes for read-only API keys across business endpoints", async () => { + const userId = await createUser("readonly-business-key"); + const medicationId = await seedMedication({ userId, name: "Readonly Med" }); + const apiToken = "ma_readonly_business_routes_123456789"; + await insertApiKey({ userId, token: apiToken, scope: "read" }); + + const responses = await Promise.all([ + app.inject({ + method: "POST", + url: "/medications", + headers: { authorization: `Bearer ${apiToken}` }, + payload: buildMedicationPayload("Blocked Create"), + }), + app.inject({ + method: "POST", + url: "/doses/taken", + headers: { authorization: `Bearer ${apiToken}` }, + payload: { doseId: "1-0-1760000000000" }, + }), + app.inject({ + method: "POST", + url: `/medications/${medicationId}/refill`, + headers: { authorization: `Bearer ${apiToken}` }, + payload: { packsAdded: 1, loosePillsAdded: 0 }, + }), + app.inject({ + method: "POST", + url: "/share", + headers: { authorization: `Bearer ${apiToken}` }, + payload: { takenBy: "Daniel", scheduleDays: 7 }, + }), + app.inject({ + method: "POST", + url: "/import", + headers: { authorization: `Bearer ${apiToken}` }, + payload: buildImportPayload(), + }), + ]); + + for (const response of responses) { + expect(response.statusCode).toBe(403); + expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" }); + } + }); + + it("allows read-only API keys to use read endpoints while keeping data scoped to the key owner", async () => { + const userId = await createUser("readonly-export-user"); + const otherId = await createUser("readonly-export-other"); + await seedMedication({ userId, name: "Readable Owner Med" }); + await seedMedication({ userId: otherId, name: "Unreadable Other Med" }); + const apiToken = "ma_readonly_export_access_123456789"; + await insertApiKey({ userId, token: apiToken, scope: "read" }); + + const response = await app.inject({ + method: "GET", + url: "/export", + headers: { authorization: `Bearer ${apiToken}` }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain("Readable Owner Med"); + expect(response.body).not.toContain("Unreadable Other Med"); + }); +}); diff --git a/backend/src/test/doses.test.ts b/backend/src/test/doses.test.ts index 3cdc06a..f44dd1a 100644 --- a/backend/src/test/doses.test.ts +++ b/backend/src/test/doses.test.ts @@ -1,487 +1,333 @@ -/** - * Tests for /doses/taken API endpoints. - * Tests marking doses as taken, listing taken doses, and unmarking. - */ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runAlterMigrations } from "../db/db-utils.js"; -// ============================================================================= -// Route Registration -// Since we can't easily import routes that depend on the global db, -// we'll create simplified route handlers for testing the core logic. -// ============================================================================= +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); -async function registerDoseRoutes(ctx: TestContext) { - const { app, client } = ctx; + return { + testClient: client, + testDb: db, + mockedEnv: { + AUTH_ENABLED: true, + REGISTRATION_ENABLED: true, + FORM_LOGIN_ENABLED: true, + OIDC_ENABLED: false, + OIDC_PROVIDER_NAME: "SSO", + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + JWT_SECRET: "test-jwt-secret", + REFRESH_SECRET: "test-refresh-secret", + COOKIE_SECRET: "test-cookie-secret", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + OPENAPI_DOCS_ENABLED: false, + }, + }; +}); - // GET /doses/taken - List all taken doses - app.get("/doses/taken", async (_request, _reply) => { - // In test mode, use user ID 1 (will be created in tests) - const userId = 1; +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); - const result = await client.execute({ - sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`, - args: [userId], - }); +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); - return { - doses: result.rows.map((d) => ({ - doseId: d.dose_id, - takenAt: (d.taken_at as number) * 1000, // Convert to ms - markedBy: d.marked_by, - })), - }; +const { doseRoutes } = await import("../routes/doses.js"); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +async function clearTables() { + await testClient.execute("DELETE FROM dose_tracking"); + await testClient.execute("DELETE FROM share_tokens"); + await testClient.execute("DELETE FROM api_keys"); + await testClient.execute("DELETE FROM refresh_tokens"); + await testClient.execute("DELETE FROM medications"); + await testClient.execute("DELETE FROM user_settings"); + 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], }); - // POST /doses/taken - Mark a dose as taken - app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => { - const userId = 1; - const { doseId } = request.body || {}; + return Number(result.rows[0].id); +} - if (!doseId || typeof doseId !== "string" || doseId.length === 0) { - return reply.status(400).send({ error: "doseId is required" }); - } +function buildSessionCookie(app: FastifyInstance, userId: number, username: string) { + const token = app.jwt.sign({ sub: userId, username }); + return `access_token=${token}`; +} - // Check if already marked - const existing = await client.execute({ - sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, - args: [userId, doseId], - }); - - if (existing.rows.length > 0) { - return { success: true, message: "Already marked" }; - } - - // Insert new record - await client.execute({ - sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`, - args: [userId, doseId], - }); - - return { success: true }; - }); - - // DELETE /doses/taken/:doseId - Unmark a dose - app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, _reply) => { - const userId = 1; - const { doseId } = request.params; - - // Check if this dose was also dismissed - const existing = await client.execute({ - sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, - args: [userId, doseId], - }); - - if (existing.rows.length > 0 && existing.rows[0].dismissed) { - // Already dismissed - keep the record as-is (don't delete) - // The dose stays dismissed, we just ignore the undo request - } else { - // Not dismissed - delete the record entirely - await client.execute({ - sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, - args: [userId, doseId], - }); - } - - return { success: true }; - }); - - // POST /doses/dismiss - Dismiss missed doses without deducting stock - app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => { - const userId = 1; - const { doseIds } = request.body || {}; - - if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) { - return reply.status(400).send({ error: "doseIds array is required" }); - } - - let dismissedCount = 0; - for (const doseId of doseIds) { - // Check if already exists - const existing = await client.execute({ - sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, - args: [userId, doseId], - }); - - if (existing.rows.length > 0) { - // Update to dismissed if not already - if (!existing.rows[0].dismissed) { - await client.execute({ - sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`, - args: [existing.rows[0].id], - }); - dismissedCount++; - } - } else { - // Insert new dismissed record - await client.execute({ - sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`, - args: [userId, doseId], - }); - dismissedCount++; - } - } - - return { success: true, dismissedCount }; +async function insertDose(options: { + userId: number; + doseId: string; + markedBy?: string | null; + dismissed?: boolean; + takenAt?: number | null; + takenSource?: "manual" | "automatic"; +}) { + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, dismissed, taken_at, taken_source) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [ + options.userId, + options.doseId, + options.markedBy ?? null, + options.dismissed ? 1 : 0, + options.takenAt === undefined ? Math.floor(Date.now() / 1000) : (options.takenAt ?? 0), + options.takenSource ?? "manual", + ], }); } -// ============================================================================= -// Tests -// ============================================================================= - describe("Dose Tracking API", () => { - let ctx: TestContext; + let app: FastifyInstance; let userId: number; + let cookieHeader: string; beforeAll(async () => { - ctx = await buildTestApp(); - await registerDoseRoutes(ctx); - await ctx.app.ready(); + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + + app = Fastify({ logger: false }); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwt, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(doseRoutes); + await app.ready(); }); afterAll(async () => { - await closeTestApp(ctx); + await app.close(); + testClient.close(); }); beforeEach(async () => { - await clearTestData(ctx.client); - // Create test user - will get ID 1 since table is cleared - userId = await createTestUser(ctx.client, { username: "testuser" }); - // Reset SQLite autoincrement so user gets ID 1 - await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); - await clearTestData(ctx.client); - userId = await createTestUser(ctx.client, { username: "testuser" }); + await clearTables(); + userId = await createUser("dose-test-user"); + cookieHeader = buildSessionCookie(app, userId, "dose-test-user"); }); - // --------------------------------------------------------------------------- - // POST /doses/taken - // --------------------------------------------------------------------------- - describe("POST /doses/taken", () => { - it("should mark a dose as taken", async () => { + it("marks a dose as taken", async () => { const doseId = "1-0-1735344000000"; - const response = await ctx.app.inject({ + const response = await app.inject({ method: "POST", url: "/doses/taken", + headers: { cookie: cookieHeader }, payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); - // Verify in database - const result = await ctx.client.execute({ - sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + const result = await testClient.execute({ + sql: "SELECT dose_id, marked_by, taken_source FROM dose_tracking WHERE user_id = ? AND dose_id = ?", args: [userId, doseId], }); - expect(result.rows.length).toBe(1); - expect(result.rows[0].dose_id).toBe(doseId); - expect(result.rows[0].marked_by).toBeNull(); + expect(result.rows).toEqual([ + expect.objectContaining({ dose_id: doseId, marked_by: null, taken_source: "manual" }), + ]); }); - it("should return idempotent response when dose already marked", async () => { + it("returns an idempotent response when the dose is already marked", async () => { const doseId = "1-0-1735344000000"; + await insertDose({ userId, doseId }); - // Mark once - await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId }, - }); - - // Mark again - const response = await ctx.app.inject({ + const response = await app.inject({ method: "POST", url: "/doses/taken", + headers: { cookie: cookieHeader }, payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, message: "Already marked" }); - // Should still only have one record - const result = await ctx.client.execute({ - sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + const countResult = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?", args: [userId, doseId], }); - expect(result.rows[0].count).toBe(1); + expect(Number(countResult.rows[0].count)).toBe(1); }); - it("should reject request without doseId", async () => { - const response = await ctx.app.inject({ + it("rejects requests without a doseId", async () => { + const response = await app.inject({ method: "POST", url: "/doses/taken", + headers: { cookie: cookieHeader }, payload: {}, }); expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "doseId is required" }); + expect(response.json()).toEqual({ error: "Required" }); }); - it("should reject request with empty doseId", async () => { - const response = await ctx.app.inject({ + it("accepts dose IDs with a person suffix and special characters", async () => { + const doseId = "5-0-1735344000000-Max Müller"; + + const response = await app.inject({ method: "POST", url: "/doses/taken", - payload: { doseId: "" }, + headers: { cookie: cookieHeader }, + payload: { doseId }, }); - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "doseId is required" }); + expect(response.statusCode).toBe(200); + + const getResponse = await app.inject({ + method: "GET", + url: "/doses/taken", + headers: { cookie: cookieHeader }, + }); + + expect(getResponse.statusCode).toBe(200); + expect(getResponse.json().doses[0].doseId).toBe(doseId); }); }); - // --------------------------------------------------------------------------- - // GET /doses/taken - // --------------------------------------------------------------------------- - describe("GET /doses/taken", () => { - it("should return empty array when no doses taken", async () => { - const response = await ctx.app.inject({ + it("returns an empty array when no doses were taken", async () => { + const response = await app.inject({ method: "GET", url: "/doses/taken", + headers: { cookie: cookieHeader }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ doses: [] }); }); - it("should return list of taken doses", async () => { - const doseId1 = "1-0-1735344000000"; - const doseId2 = "1-0-1735430400000"; - - // Mark two doses - await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId: doseId1 }, - }); - await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId: doseId2 }, + it("returns only the authenticated user's taken doses with metadata", async () => { + const otherUserId = await createUser("dose-other-user"); + await insertDose({ + userId, + doseId: "1-0-1735344000000", + markedBy: "Daniel", + takenSource: "automatic", }); + await insertDose({ userId, doseId: "1-0-1735430400000" }); + await insertDose({ userId: otherUserId, doseId: "9-0-1735516800000" }); - const response = await ctx.app.inject({ + const response = await app.inject({ method: "GET", url: "/doses/taken", + headers: { cookie: cookieHeader }, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.doses).toHaveLength(2); - expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort()); - // Each dose should have a takenAt timestamp - for (const dose of data.doses) { - expect(dose.takenAt).toBeTypeOf("number"); - expect(dose.takenAt).toBeGreaterThan(0); - expect(dose.markedBy).toBeNull(); - } - }); - - it("should include markedBy when present", async () => { - const doseId = "1-0-1735344000000"; - - // Insert directly with markedBy - await ctx.client.execute({ - sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, - args: [userId, doseId, "Daniel"], - }); - - const response = await ctx.app.inject({ - method: "GET", - url: "/doses/taken", - }); - - expect(response.statusCode).toBe(200); - const data = response.json(); - expect(data.doses).toHaveLength(1); - expect(data.doses[0].markedBy).toBe("Daniel"); + expect(data.doses.map((dose: { doseId: string }) => dose.doseId).sort()).toEqual([ + "1-0-1735344000000", + "1-0-1735430400000", + ]); + expect(data.doses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ markedBy: "Daniel", takenSource: "automatic" }), + expect.objectContaining({ markedBy: null, takenSource: "manual" }), + ]) + ); }); }); - // --------------------------------------------------------------------------- - // DELETE /doses/taken/:doseId - // --------------------------------------------------------------------------- - describe("DELETE /doses/taken/:doseId", () => { - it("should unmark a dose", async () => { + it("unmarks an existing dose", async () => { const doseId = "1-0-1735344000000"; + await insertDose({ userId, doseId }); - // Mark first - await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId }, - }); - - // Verify marked - let result = await ctx.client.execute({ - sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, - args: [doseId], - }); - expect(result.rows[0].count).toBe(1); - - // Unmark - const response = await ctx.app.inject({ + const response = await app.inject({ method: "DELETE", url: `/doses/taken/${encodeURIComponent(doseId)}`, + headers: { cookie: cookieHeader }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); - // Verify unmarked - result = await ctx.client.execute({ - sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, - args: [doseId], + const countResult = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, doseId], }); - expect(result.rows[0].count).toBe(0); + expect(Number(countResult.rows[0].count)).toBe(0); }); - it("should succeed even if dose was not marked", async () => { - const doseId = "nonexistent-dose-id"; + it("keeps the record when the dose is dismissed", async () => { + const doseId = "1-0-1735344000000"; + await insertDose({ userId, doseId, dismissed: true }); - const response = await ctx.app.inject({ + const response = await app.inject({ method: "DELETE", url: `/doses/taken/${encodeURIComponent(doseId)}`, + headers: { cookie: cookieHeader }, }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ success: true }); + + const result = await testClient.execute({ + sql: "SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, doseId], + }); + expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, dismissed: 1 })]); }); - it("should preserve dismissed status when unmarking a dose", async () => { - const doseId = "1-0-1735344000000"; - - // First dismiss the dose - await ctx.app.inject({ - method: "POST", - url: "/doses/dismiss", - payload: { doseIds: [doseId] }, - }); - - // Verify it's dismissed - let result = await ctx.client.execute({ - sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`, - args: [doseId], - }); - expect(result.rows[0].dismissed).toBe(1); - const originalTakenAt = result.rows[0].taken_at; - - // Now try to unmark it (undo) - should keep the dismissed record - const response = await ctx.app.inject({ + it("still succeeds when the dose does not exist", async () => { + const response = await app.inject({ method: "DELETE", - url: `/doses/taken/${encodeURIComponent(doseId)}`, + url: "/doses/taken/nonexistent-dose-id", + headers: { cookie: cookieHeader }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); - - // Verify the record still exists and is still dismissed - result = await ctx.client.execute({ - sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`, - args: [doseId], - }); - expect(result.rows.length).toBe(1); - expect(result.rows[0].dismissed).toBe(1); - expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged }); }); - // --------------------------------------------------------------------------- - // Dose ID Format Tests - // --------------------------------------------------------------------------- - - describe("Dose ID Format", () => { - it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => { - const doseId = "5-0-1735344000000"; - - const response = await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ success: true }); - }); - - it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => { - const doseId = "5-0-1735344000000-Daniel"; - - const response = await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ success: true }); - }); - - it("should handle special characters in dose ID", async () => { - // Dose ID with URL-unsafe characters (edge case) - const doseId = "5-0-1735344000000-Max Müller"; - - const response = await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId }, - }); - - expect(response.statusCode).toBe(200); - - // Can retrieve it - const getResponse = await ctx.app.inject({ - method: "GET", - url: "/doses/taken", - }); - - expect(getResponse.json().doses[0].doseId).toBe(doseId); - }); - }); - - // --------------------------------------------------------------------------- - // Dismiss Doses Tests (POST /doses/dismiss) - // --------------------------------------------------------------------------- - describe("POST /doses/dismiss", () => { - it("should dismiss multiple doses", async () => { - const doseIds = ["1-0-1735344000000", "1-0-1735430400000"]; - - const response = await ctx.app.inject({ + it("dismisses multiple doses", async () => { + const response = await app.inject({ method: "POST", url: "/doses/dismiss", - payload: { doseIds }, + headers: { cookie: cookieHeader }, + payload: { doseIds: ["1-0-1735344000000", "1-0-1735430400000"] }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, dismissedCount: 2 }); - // Verify in database - const result = await ctx.client.execute({ - sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`, + const result = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dismissed = 1", args: [userId], }); - expect(result.rows.length).toBe(2); + expect(Number(result.rows[0].count)).toBe(2); }); - it("should not double-count already dismissed doses", async () => { + it("does not double-count already dismissed doses", async () => { const doseId = "1-0-1735344000000"; + await insertDose({ userId, doseId, dismissed: true }); - // Dismiss once - await ctx.app.inject({ - method: "POST", - url: "/doses/dismiss", - payload: { doseIds: [doseId] }, - }); - - // Dismiss again - const response = await ctx.app.inject({ + const response = await app.inject({ method: "POST", url: "/doses/dismiss", + headers: { cookie: cookieHeader }, payload: { doseIds: [doseId] }, }); @@ -489,54 +335,71 @@ describe("Dose Tracking API", () => { expect(response.json()).toEqual({ success: true, dismissedCount: 0 }); }); - it("should reject empty doseIds array", async () => { - const response = await ctx.app.inject({ - method: "POST", - url: "/doses/dismiss", - payload: { doseIds: [] }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "doseIds array is required" }); - }); - - it("should reject missing doseIds", async () => { - const response = await ctx.app.inject({ - method: "POST", - url: "/doses/dismiss", - payload: {}, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "doseIds array is required" }); - }); - - it("should dismiss a dose that was already taken (convert to dismissed)", async () => { + it("converts a taken dose into a dismissed one", async () => { const doseId = "1-0-1735344000000"; + await insertDose({ userId, doseId, dismissed: false }); - // First mark as taken - await ctx.app.inject({ - method: "POST", - url: "/doses/taken", - payload: { doseId }, - }); - - // Then dismiss it - const response = await ctx.app.inject({ + const response = await app.inject({ method: "POST", url: "/doses/dismiss", + headers: { cookie: cookieHeader }, payload: { doseIds: [doseId] }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, dismissedCount: 1 }); - // Verify it's now dismissed - const result = await ctx.client.execute({ - sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + const result = await testClient.execute({ + sql: "SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?", args: [userId, doseId], }); - expect(result.rows[0].dismissed).toBe(1); + expect(result.rows).toEqual([expect.objectContaining({ dismissed: 1 })]); + }); + + it("rejects missing or empty doseIds", async () => { + const emptyResponse = await app.inject({ + method: "POST", + url: "/doses/dismiss", + headers: { cookie: cookieHeader }, + payload: { doseIds: [] }, + }); + + expect(emptyResponse.statusCode).toBe(400); + expect(emptyResponse.json()).toEqual({ error: "At least one doseId is required" }); + + const missingResponse = await app.inject({ + method: "POST", + url: "/doses/dismiss", + headers: { cookie: cookieHeader }, + payload: {}, + }); + + expect(missingResponse.statusCode).toBe(400); + expect(missingResponse.json()).toEqual({ error: "Required" }); + }); + }); + + describe("DELETE /doses/dismiss", () => { + it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => { + await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null }); + await insertDose({ userId, doseId: "1-0-1735430400000", dismissed: true, markedBy: "Daniel" }); + + const response = await app.inject({ + method: "DELETE", + url: "/doses/dismiss", + headers: { cookie: cookieHeader }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, clearedCount: 2 }); + + const rows = await testClient.execute({ + sql: "SELECT dose_id, dismissed, marked_by FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC", + args: [userId], + }); + expect(rows.rows).toEqual([ + expect.objectContaining({ dose_id: "1-0-1735430400000", dismissed: 0, marked_by: "Daniel" }), + ]); }); }); }); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 0ce661f..8bd00fb 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -345,6 +345,37 @@ describe("E2E Tests with Real Routes", () => { usedPrescription: true, }); }); + + it("should not include refill history entries from another user for the same medication", async () => { + const medId = await createMedication(testClient, userId, "Report Isolation Med", ["Daniel"]); + const otherUserId = await _createUser(testClient, "report-isolation-other-user"); + + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [medId, userId, 1, 0, 0, 1735603200], + }); + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [medId, otherUserId, 9, 99, 1, 1735689600], + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [medId] }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[medId].refills).toHaveLength(1); + expect(data[medId].refills[0]).toMatchObject({ + packsAdded: 1, + loosePillsAdded: 0, + usedPrescription: false, + }); + }); }); afterAll(async () => { @@ -503,6 +534,80 @@ describe("E2E Tests with Real Routes", () => { expect(response.statusCode).toBe(404); }); + + it("should return shared medication overview for a valid token", async () => { + await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const token = "abcdef0123456789"; + await createShareToken(testClient, userId, "Daniel", token); + + const response = await app.inject({ + method: "GET", + url: `/share/${token}/overview`, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + + const data = response.json(); + expect(data.takenBy).toBe("Daniel"); + expect(data.sharedBy).toBe("__anonymous__"); + expect(Array.isArray(data.medications)).toBe(true); + expect(data.medications).toHaveLength(1); + expect(data.medications[0].name).toBe("Aspirin"); + expect(data.medications[0].currentStock).toBeTypeOf("number"); + }); + + it("should return 404 for unknown overview token", async () => { + const response = await app.inject({ + method: "GET", + url: "/share/abcdef0123456789/overview", + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: "not_found" }); + }); + + it("should return 410 for expired overview token", async () => { + const token = "fedcba9876543210"; + await testClient.execute({ + sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, 30, ?)", + args: [userId, token, "Daniel", Math.floor(Date.now() / 1000) - 60], + }); + + const response = await app.inject({ + method: "GET", + url: `/share/${token}/overview`, + }); + + expect(response.statusCode).toBe(410); + const data = response.json(); + expect(data.error).toBe("expired"); + expect(data.expiredAt).toBeTypeOf("string"); + }); + + it("should hide stock fields in overview when share_stock_status is disabled", async () => { + await createMedication(testClient, userId, "Ibuprofen", ["Daniel"]); + const token = "0123456789abcdef"; + await createShareToken(testClient, userId, "Daniel", token); + + await testClient.execute({ + sql: "INSERT INTO user_settings (user_id, share_stock_status, low_stock_days) VALUES (?, 0, 30)", + args: [userId], + }); + + const response = await app.inject({ + method: "GET", + url: `/share/${token}/overview`, + }); + + expect(response.statusCode).toBe(200); + const [medication] = response.json().medications; + expect(medication.currentStock).toBeNull(); + expect(medication.capacity).toBeNull(); + expect(medication.daysLeft).toBeNull(); + expect(medication.depletionDate).toBeNull(); + expect(medication.priority).toBeNull(); + }); }); // --------------------------------------------------------------------------- @@ -834,7 +939,7 @@ describe("E2E Tests with Real Routes", () => { }); expect(response.statusCode).toBe(400); - expect(response.json().error).toBe("Invalid language"); + expect(response.json().error).toMatch(/Invalid language|Bad Request/); }); it("should create and update language via lightweight language endpoint", async () => { @@ -1929,6 +2034,47 @@ describe("E2E Tests with Real Routes", () => { expect(hasLooseRefill).toBe(true); }); + it("should not return refill history entries from another user for the same medication", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Refill Isolation Med", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + const otherUserId = await _createUser(testClient, "refill-isolation-other-user"); + + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [medId, userId, 2, 3, 0, 1735603200], + }); + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [medId, otherUserId, 8, 88, 1, 1735689600], + }); + + const response = await app.inject({ + method: "GET", + url: `/medications/${medId}/refills`, + }); + + expect(response.statusCode).toBe(200); + const refills = response.json(); + expect(refills).toHaveLength(1); + expect(refills[0]).toMatchObject({ + packsAdded: 2, + loosePillsAdded: 3, + usedPrescription: false, + }); + }); + it("should return 404 for non-existent medication", async () => { const response = await app.inject({ method: "GET", @@ -2302,6 +2448,29 @@ describe("E2E Tests with Real Routes", () => { payload: { emailEnabled: true, notificationEmail: "test@example.com", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + upcomingTodayOnly: false, + shareScheduleTodayOnly: false, + swapDashboardMainSections: false, }, }); @@ -2506,10 +2675,10 @@ describe("E2E Tests with Real Routes", () => { }); // --------------------------------------------------------------------------- - // Package Type (blister, bottle, liquid_container) Tests + // Package Type (blister, bottle, tube, liquid_container) Tests // --------------------------------------------------------------------------- - describe("Package type handling (blister, bottle, liquid_container)", () => { + describe("Package type handling (blister, bottle, tube, liquid_container)", () => { const bottleMedication = { name: "Vitamin D Drops", packageType: "bottle", @@ -2542,6 +2711,21 @@ describe("E2E Tests with Real Routes", () => { blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }], }; + const tubeMedication = { + name: "Topical Cream", + medicationForm: "topical", + packageType: "tube", + doseUnit: "units", + packCount: 2, + packageAmountValue: 40, + packageAmountUnit: "g", + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 80, + looseTablets: 80, + blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }; + it("should create and return bottle type medication", async () => { const response = await app.inject({ method: "POST", @@ -2698,6 +2882,72 @@ describe("E2E Tests with Real Routes", () => { expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5 }); + it("should keep liquid_container refill additive and preserve amount baseline", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + ...liquidContainerMedication, + packCount: 1, + packageAmountValue: 180, + totalPills: 180, + looseTablets: 180, + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + 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.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" }); + expect(medsResponse.statusCode).toBe(200); + const med = medsResponse.json().find((m: Record) => m.id === medId); + expect(med).toBeTruthy(); + expect(med.totalPills).toBe(360); + expect(med.looseTablets).toBe(360); + }); + + it("should keep tube refill additive and preserve amount baseline", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: tubeMedication, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + 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.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" }); + expect(medsResponse.statusCode).toBe(200); + const med = medsResponse.json().find((m: Record) => m.id === medId); + expect(med).toBeTruthy(); + expect(med.totalPills).toBe(120); + expect(med.looseTablets).toBe(120); + }); + it("should return correct totalPillsAdded in refill history for bottle type", async () => { const createResponse = await app.inject({ method: "POST", diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts index cf0112a..e62bd18 100644 --- a/backend/src/test/routes-real.test.ts +++ b/backend/src/test/routes-real.test.ts @@ -45,7 +45,9 @@ vi.mock("nodemailer", () => ({ }, })); -const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js"); +const { settingsRoutes, sendShoutrrrNotification, loadUserSettings, getAllUserSettings } = await import( + "../routes/settings.js" +); const { exportRoutes } = await import("../routes/export.js"); const { reportRoutes } = await import("../routes/report.js"); @@ -142,6 +144,73 @@ describe("Real route coverage: settings/export/report", () => { expect(body.shareScheduleTodayOnly).toBe(false); }); + it("GET /settings returns a non-empty serialized payload with SMTP fields", async () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "2525"; + process.env.SMTP_USER = "mailer@example.com"; + process.env.SMTP_FROM = "MedAssist "; + process.env.SMTP_PASS = "secret"; + + await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: true, + notificationEmail: "person@example.com", + reminderDaysBefore: 5, + repeatDailyReminders: true, + lowStockDays: 14, + normalStockDays: 45, + highStockDays: 90, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 20, + maxNaggingReminders: 4, + language: "en", + stockCalculationMode: "manual", + shareStockStatus: true, + upcomingTodayOnly: true, + shareScheduleTodayOnly: true, + swapDashboardMainSections: true, + }, + }); + + const response = await app.inject({ method: "GET", url: "/settings" }); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toBe("{}"); + + const body = response.json(); + expect(body).toEqual( + expect.objectContaining({ + emailEnabled: true, + notificationEmail: "person@example.com", + reminderDaysBefore: 5, + repeatDailyReminders: true, + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 20, + maxNaggingReminders: 4, + stockCalculationMode: "manual", + upcomingTodayOnly: true, + shareScheduleTodayOnly: true, + swapDashboardMainSections: true, + smtpHost: "smtp.example.com", + smtpPort: 2525, + smtpUser: "mailer@example.com", + smtpFrom: "MedAssist ", + hasSmtpPassword: true, + }) + ); + }); + it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => { const response = await app.inject({ method: "PUT", @@ -190,7 +259,30 @@ describe("Real route coverage: settings/export/report", () => { payload: { language: "fr" }, }); expect(response.statusCode).toBe(400); - expect(response.json().error).toBe("Invalid language"); + expect(response.json().error).toMatch(/Invalid language|Bad Request/); + }); + + it("PUT /settings/language creates and updates the stored language", async () => { + let response = await app.inject({ + method: "PUT", + url: "/settings/language", + payload: { language: "de" }, + }); + + expect(response.statusCode).toBe(200); + + response = await app.inject({ + method: "PUT", + url: "/settings/language", + payload: { language: "en" }, + }); + + expect(response.statusCode).toBe(200); + + const stored = await testClient.execute({ + sql: "SELECT language FROM user_settings WHERE user_id = 1", + }); + expect(stored.rows[0].language).toBe("en"); }); it("POST /settings/test-email fails when SMTP is not configured", async () => { @@ -224,6 +316,22 @@ describe("Real route coverage: settings/export/report", () => { expect(nodemailerSendMail).toHaveBeenCalledTimes(1); }); + it("POST /settings/test-email maps generic transport failures to HTTP 500", async () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_USER = "mailer@example.com"; + process.env.SMTP_PASS = "secret"; + nodemailerSendMail.mockRejectedValue(new Error("socket hang up")); + + const response = await app.inject({ + method: "POST", + url: "/settings/test-email", + payload: { email: "person@example.com" }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json()).toMatchObject({ code: "TEST_EMAIL_FAILED" }); + }); + it("POST /settings/test-shoutrrr validates URL presence", async () => { const response = await app.inject({ method: "POST", @@ -233,6 +341,30 @@ describe("Real route coverage: settings/export/report", () => { expect(response.statusCode).toBe(400); }); + it("POST /settings/test-shoutrrr returns 500 when notification delivery fails", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "ftp://invalid.example.com/topic" }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toMatch(/Only HTTP\/HTTPS protocols are allowed|Unsupported URL format/); + }); + + it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => { + fetchMock.mockResolvedValue({ ok: true }); + + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "ntfy://ntfy.sh/medassist" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" }); + }); + it("sendShoutrrrNotification blocks localhost/private targets", async () => { const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message"); expect(result.success).toBe(false); @@ -266,6 +398,169 @@ describe("Real route coverage: settings/export/report", () => { expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" }); }); + it("sendShoutrrrNotification returns HTTP response errors for ntfy-style endpoints", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 429, text: () => Promise.resolve("rate limited") }); + + const result = await sendShoutrrrNotification("https://ntfy.sh/medassist", "Title", "Body"); + + expect(result).toEqual({ success: false, error: "HTTP 429: rate limited" }); + }); + + it("sendShoutrrrNotification rejects invalid Discord webhook identifiers", async () => { + const result = await sendShoutrrrNotification("discord://bad-token@not-a-number", "Title", "Body"); + + expect(result).toEqual({ success: false, error: "Invalid Discord webhook ID" }); + }); + + it("sendShoutrrrNotification validates Pushover URL credentials", async () => { + const result = await sendShoutrrrNotification("pushover://missing-token", "Title", "Body"); + + expect(result).toEqual({ success: false, error: "Invalid Pushover URL format" }); + }); + + it("sendShoutrrrNotification requires Telegram chats and validates tokens", async () => { + let result = await sendShoutrrrNotification("telegram://123:abc@telegram", "Title", "Body"); + expect(result).toEqual({ success: false, error: "Telegram URL requires chats parameter" }); + + result = await sendShoutrrrNotification("telegram://invalid@telegram?chats=123", "Title", "Body"); + expect(result).toEqual({ success: false, error: "Invalid Telegram token format" }); + }); + + it("sendShoutrrrNotification converts Gotify URLs and supports disabletls", async () => { + fetchMock.mockResolvedValue({ ok: true }); + + const result = await sendShoutrrrNotification( + "gotify://push.example.com/basepath/token123?disabletls=yes&priority=8", + "Title", + "Body" + ); + + expect(result).toEqual({ success: true }); + const [targetUrl, requestInit] = fetchMock.mock.calls[0]; + expect(targetUrl).toBe("http://push.example.com/basepath/message?token=token123"); + expect(requestInit.body).toBe("Body\n\n(priority=8)"); + expect(requestInit.headers).toMatchObject({ Tags: "pill" }); + }); + + it("loadUserSettings creates defaults for users without settings", async () => { + const settings = await loadUserSettings(1); + + expect(settings).toEqual( + expect.objectContaining({ + userId: 1, + emailEnabled: false, + emailPrescriptionReminders: true, + shoutrrrPrescriptionReminders: true, + stockCalculationMode: "automatic", + shareStockStatus: true, + }) + ); + }); + + it("loadUserSettings maps persisted settings", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings ( + user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders, + email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders, + shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before, + repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language, + stock_calculation_mode, share_stock_status, skip_reminders_for_taken_doses, + repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders, + upcoming_today_only, share_schedule_today_only, swap_dashboard_main_sections + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + 1, + 1, + "person@example.com", + 1, + 1, + 1, + 0, + null, + 1, + 1, + 1, + 4, + 0, + 12, + 30, + 90, + "de", + "manual", + 1, + 0, + 0, + 30, + 5, + 0, + 0, + 0, + ], + }); + + const settings = await loadUserSettings(1); + + expect(settings).toEqual( + expect.objectContaining({ + notificationEmail: "person@example.com", + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + stockCalculationMode: "manual", + shareStockStatus: true, + upcomingTodayOnly: false, + shareScheduleTodayOnly: false, + swapDashboardMainSections: false, + }) + ); + }); + + it("getAllUserSettings returns mapped entries for each persisted user", async () => { + await testClient.execute({ + sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)", + args: [2, "second-user", "local"], + }); + await testClient.execute({ + sql: `INSERT INTO user_settings ( + user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders, + email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders, + shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before, + repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language, + stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only, + swap_dashboard_main_sections + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [1, 0, null, 1, 1, 1, 1, "ntfy://ntfy.sh/topic", 1, 1, 1, 7, 1, 30, 60, 120, "en", "manual", 1, 1, 0, 1], + }); + await testClient.execute({ + sql: `INSERT INTO user_settings ( + user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders, + email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders, + shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before, + repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language, + stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only, + swap_dashboard_main_sections + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [2, 1, "second@example.com", 0, 1, 1, 0, null, 1, 1, 1, 10, 0, 20, 50, 100, "de", "automatic", 1, 0, 0, 0], + }); + + const allSettings = await getAllUserSettings(); + + expect(allSettings).toHaveLength(2); + expect(allSettings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: 1, stockCalculationMode: "manual", upcomingTodayOnly: true }), + expect.objectContaining({ + userId: 2, + emailPrescriptionReminders: true, + shoutrrrPrescriptionReminders: true, + stockCalculationMode: "automatic", + shareStockStatus: true, + }), + ]) + ); + }); + it("POST /medications/report-data returns 403 for meds not owned by user", async () => { await seedMedication("Owned Med"); const response = await app.inject({ diff --git a/backend/src/test/settings-auth-security.test.ts b/backend/src/test/settings-auth-security.test.ts new file mode 100644 index 0000000..8c7a198 --- /dev/null +++ b/backend/src/test/settings-auth-security.test.ts @@ -0,0 +1,395 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import cookie from "@fastify/cookie"; +import jwt from "@fastify/jwt"; +import sensible from "@fastify/sensible"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runAlterMigrations } from "../db/db-utils.js"; + +const { testClient, testDb, mockedEnv, nodemailerSendMail } = 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: { + AUTH_ENABLED: true, + REGISTRATION_ENABLED: true, + FORM_LOGIN_ENABLED: true, + OIDC_ENABLED: false, + OIDC_PROVIDER_NAME: "SSO", + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + JWT_SECRET: "test-jwt-secret", + REFRESH_SECRET: "test-refresh-secret", + COOKIE_SECRET: "test-cookie-secret", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + OPENAPI_DOCS_ENABLED: false, + }, + nodemailerSendMail: vi.fn(), + }; +}); + +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: () => ({ + sendMail: nodemailerSendMail, + }), + }, +})); + +const { settingsRoutes } = await import("../routes/settings.js"); +const { apiKeyRoutes } = await import("../routes/api-keys.js"); +const { hashApiKeyToken } = await import("../plugins/auth.js"); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +async function clearTables() { + await testClient.execute("DELETE FROM api_keys"); + await testClient.execute("DELETE FROM refresh_tokens"); + await testClient.execute("DELETE FROM user_settings"); + 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); +} + +function buildSessionCookie(app: FastifyInstance, userId: number, username: string) { + const token = app.jwt.sign({ sub: userId, username }); + return `access_token=${token}`; +} + +async function insertApiKey(options: { + userId: number; + token: string; + scope?: "read" | "write"; + isActive?: boolean; + expiresAt?: Date | null; +}) { + const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null; + + const result = await testClient.execute({ + sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + options.userId, + "Seeded Key", + hashApiKeyToken(options.token), + `${options.token.slice(0, 12)}...`, + options.scope ?? "write", + options.isActive === false ? 0 : 1, + expiresAtValue, + ], + }); + + return Number(result.rows[0].id); +} + +describe("Settings and API key security contracts", () => { + let app: FastifyInstance; + + beforeAll(async () => { + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + + app = Fastify({ logger: false }); + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwt, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(settingsRoutes); + await app.register(apiKeyRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await clearTables(); + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_TOKEN; + delete process.env.SMTP_PASS; + delete process.env.SMTP_FROM; + delete process.env.SMTP_PORT; + delete process.env.SMTP_SECURE; + }); + + it("rejects GET /settings without authentication when auth is enabled", async () => { + const response = await app.inject({ method: "GET", url: "/settings" }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" }); + }); + + it("returns settings defaults for an authenticated session cookie", async () => { + const userId = await createUser("settings-session-user"); + const response = await app.inject({ + method: "GET", + url: "/settings", + headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual( + expect.objectContaining({ + emailEnabled: false, + language: "en", + stockCalculationMode: "automatic", + }) + ); + }); + + it("allows GET /settings with a read-only API key", async () => { + const userId = await createUser("settings-read-user"); + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "2525"; + + const apiToken = "ma_read_only_valid_token_123456789"; + await insertApiKey({ userId, token: apiToken, scope: "read" }); + + const response = await app.inject({ + method: "GET", + url: "/settings", + headers: { authorization: `Bearer ${apiToken}` }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual( + expect.objectContaining({ + smtpHost: "smtp.example.com", + smtpPort: 2525, + }) + ); + }); + + it("rejects PUT /settings with a read-only API key", async () => { + const userId = await createUser("settings-read-mutation-user"); + const apiToken = "ma_read_only_mutation_token_123456789"; + await insertApiKey({ userId, token: apiToken, scope: "read" }); + + const response = await app.inject({ + method: "PUT", + url: "/settings", + headers: { authorization: `Bearer ${apiToken}` }, + payload: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + upcomingTodayOnly: false, + shareScheduleTodayOnly: false, + swapDashboardMainSections: false, + }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" }); + }); + + it("rejects invalid API key bearer tokens for GET /settings", async () => { + const response = await app.inject({ + method: "GET", + url: "/settings", + headers: { authorization: "Bearer definitely-not-a-medassist-key" }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ code: "INVALID_API_KEY" }); + }); + + it("rejects expired API keys for GET /settings", async () => { + const userId = await createUser("settings-expired-key-user"); + const apiToken = "ma_expired_token_for_settings_123456789"; + await insertApiKey({ + userId, + token: apiToken, + scope: "read", + expiresAt: new Date(Date.now() - 60_000), + }); + + const response = await app.inject({ + method: "GET", + url: "/settings", + headers: { authorization: `Bearer ${apiToken}` }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ code: "API_KEY_EXPIRED" }); + }); + + it("rotates API keys and does not leak raw tokens from the list endpoint", async () => { + const userId = await createUser("api-key-session-user"); + const cookieHeader = buildSessionCookie(app, userId, "api-key-session-user"); + + const firstCreate = await app.inject({ + method: "POST", + url: "/auth/api-keys", + headers: { cookie: cookieHeader }, + payload: { name: "Primary key", scope: "write", expiresInDays: 30 }, + }); + + expect(firstCreate.statusCode).toBe(201); + const firstBody = firstCreate.json(); + expect(firstBody.token).toMatch(/^ma_/); + + const secondCreate = await app.inject({ + method: "POST", + url: "/auth/api-keys", + headers: { cookie: cookieHeader }, + payload: { name: "Rotated key", scope: "write", expiresInDays: 30 }, + }); + + expect(secondCreate.statusCode).toBe(201); + const secondBody = secondCreate.json(); + + const listResponse = await app.inject({ + method: "GET", + url: "/auth/api-keys", + headers: { cookie: cookieHeader }, + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.body).not.toContain(firstBody.token); + expect(listResponse.body).not.toContain(secondBody.token); + expect(listResponse.body).not.toContain("keyHash"); + expect(listResponse.json().keys).toHaveLength(2); + + const dbState = await testClient.execute({ + sql: "SELECT name, is_active FROM api_keys WHERE user_id = ? ORDER BY id ASC", + args: [userId], + }); + expect(dbState.rows).toEqual([ + expect.objectContaining({ name: "Primary key", is_active: 0 }), + expect.objectContaining({ name: "Rotated key", is_active: 1 }), + ]); + }); + + it("rejects API key rotation when authenticated with a read-only API key", async () => { + const userId = await createUser("api-key-readonly-rotate-user"); + const readOnlyToken = "ma_readonly_rotation_denied_123456789"; + await insertApiKey({ userId, token: readOnlyToken, scope: "read" }); + + const response = await app.inject({ + method: "POST", + url: "/auth/api-keys", + headers: { authorization: `Bearer ${readOnlyToken}` }, + payload: { name: "Blocked rotation", scope: "write", expiresInDays: 30 }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" }); + }); + + it("returns 404 when deleting an API key owned by a different user", async () => { + const ownerUserId = await createUser("api-key-owner"); + const otherUserId = await createUser("api-key-other-user"); + const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user"); + + const keyId = await insertApiKey({ + userId: ownerUserId, + token: "ma_write_owner_token_123456789", + scope: "write", + }); + + const response = await app.inject({ + method: "DELETE", + url: `/auth/api-keys/${keyId}`, + headers: { cookie: otherCookieHeader }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toMatchObject({ code: "API_KEY_NOT_FOUND" }); + }); + + it("maps SMTP recipient rejection to HTTP 400 instead of a generic 500", async () => { + const userId = await createUser("settings-email-recipient-user"); + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_USER = "mailer@example.com"; + process.env.SMTP_PASS = "secret"; + nodemailerSendMail.mockResolvedValue({ + accepted: [], + rejected: ["missing@example.com"], + response: "550 5.1.1 recipient address rejected", + }); + + const response = await app.inject({ + method: "POST", + url: "/settings/test-email", + headers: { cookie: buildSessionCookie(app, userId, "settings-email-recipient-user") }, + payload: { email: "missing@example.com" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ code: "EMAIL_RECIPIENT_REJECTED" }); + }); + + it("maps missing SMTP acceptance to HTTP 502 for test email", async () => { + const userId = await createUser("settings-email-unconfirmed-user"); + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_USER = "mailer@example.com"; + process.env.SMTP_PASS = "secret"; + nodemailerSendMail.mockResolvedValue({ + accepted: [], + rejected: [], + response: "250 queued without explicit acceptance", + }); + + const response = await app.inject({ + method: "POST", + url: "/settings/test-email", + headers: { cookie: buildSessionCookie(app, userId, "settings-email-unconfirmed-user") }, + payload: { email: "person@example.com" }, + }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toMatchObject({ code: "SMTP_DELIVERY_UNCONFIRMED" }); + }); +}); diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts index 463c93f..6fc5216 100644 --- a/backend/src/types/fastify.d.ts +++ b/backend/src/types/fastify.d.ts @@ -5,7 +5,12 @@ import "@fastify/jwt"; export interface AuthUser { id: number; username: string; - role: string; +} + +export interface AuthContext { + method: "session" | "api_key"; + scope: "read" | "write"; + apiKeyId?: number; } declare module "fastify" { @@ -22,6 +27,7 @@ declare module "fastify" { interface FastifyRequest { user?: AuthUser | null; + authContext?: AuthContext; correlationId?: string; } }