diff --git a/backend/drizzle/0015_add_intake_journal.sql b/backend/drizzle/0015_add_intake_journal.sql new file mode 100644 index 0000000..5e6f05a --- /dev/null +++ b/backend/drizzle/0015_add_intake_journal.sql @@ -0,0 +1,15 @@ +CREATE TABLE `intake_journal` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `dose_tracking_id` integer NOT NULL, + `medication_id` integer NOT NULL, + `scheduled_for` integer NOT NULL, + `note` text NOT NULL, + `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, + FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`); diff --git a/backend/drizzle/0016_add_share_allow_journal_notes.sql b/backend/drizzle/0016_add_share_allow_journal_notes.sql new file mode 100644 index 0000000..9272a2f --- /dev/null +++ b/backend/drizzle/0016_add_share_allow_journal_notes.sql @@ -0,0 +1 @@ +ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL; diff --git a/backend/drizzle/meta/0015_snapshot.json b/backend/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..250ac65 --- /dev/null +++ b/backend/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1568 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "99838de4-6d3e-42e5-a319-21863bc2c7e3", + "prevId": "0f9ec2df-4f3a-4ca2-931f-5eac5b8734ee", + "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": {} + }, + "intake_journal": { + "name": "intake_journal", + "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_tracking_id": { + "name": "dose_tracking_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "medication_id": { + "name": "medication_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": { + "intake_journal_dose_tracking_id_unique": { + "name": "intake_journal_dose_tracking_id_unique", + "columns": [ + "dose_tracking_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "intake_journal_user_id_users_id_fk": { + "name": "intake_journal_user_id_users_id_fk", + "tableFrom": "intake_journal", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intake_journal_dose_tracking_id_dose_tracking_id_fk": { + "name": "intake_journal_dose_tracking_id_dose_tracking_id_fk", + "tableFrom": "intake_journal", + "tableTo": "dose_tracking", + "columnsFrom": [ + "dose_tracking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intake_journal_medication_id_medications_id_fk": { + "name": "intake_journal_medication_id_medications_id_fk", + "tableFrom": "intake_journal", + "tableTo": "medications", + "columnsFrom": [ + "medication_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": {} + }, + "notification_action_groups": { + "name": "notification_action_groups", + "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 + }, + "group_key": { + "name": "group_key", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence_id": { + "name": "sequence_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ntfy_original_message_id": { + "name": "ntfy_original_message_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dose_ids_json": { + "name": "dose_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_action": { + "name": "resolved_action", + "type": "text(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_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": { + "notification_action_groups_group_key_unique": { + "name": "notification_action_groups_group_key_unique", + "columns": [ + "group_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_action_groups_user_id_users_id_fk": { + "name": "notification_action_groups_user_id_users_id_fk", + "tableFrom": "notification_action_groups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_action_tokens": { + "name": "notification_action_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_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" + } + }, + "indexes": { + "notification_action_tokens_token_hash_unique": { + "name": "notification_action_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_action_tokens_group_id_notification_action_groups_id_fk": { + "name": "notification_action_tokens_group_id_notification_action_groups_id_fk", + "tableFrom": "notification_action_tokens", + "tableTo": "notification_action_groups", + "columnsFrom": [ + "group_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'" + }, + "timezone": { + "name": "timezone", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "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 + }, + "share_medication_overview": { + "name": "share_medication_overview", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "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/0016_snapshot.json b/backend/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..a3da078 --- /dev/null +++ b/backend/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1576 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8e4c065c-4d73-4613-862a-91a69e7548e3", + "prevId": "99838de4-6d3e-42e5-a319-21863bc2c7e3", + "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": {} + }, + "intake_journal": { + "name": "intake_journal", + "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_tracking_id": { + "name": "dose_tracking_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "medication_id": { + "name": "medication_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": { + "intake_journal_dose_tracking_id_unique": { + "name": "intake_journal_dose_tracking_id_unique", + "columns": [ + "dose_tracking_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "intake_journal_user_id_users_id_fk": { + "name": "intake_journal_user_id_users_id_fk", + "tableFrom": "intake_journal", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intake_journal_dose_tracking_id_dose_tracking_id_fk": { + "name": "intake_journal_dose_tracking_id_dose_tracking_id_fk", + "tableFrom": "intake_journal", + "tableTo": "dose_tracking", + "columnsFrom": [ + "dose_tracking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intake_journal_medication_id_medications_id_fk": { + "name": "intake_journal_medication_id_medications_id_fk", + "tableFrom": "intake_journal", + "tableTo": "medications", + "columnsFrom": [ + "medication_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": {} + }, + "notification_action_groups": { + "name": "notification_action_groups", + "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 + }, + "group_key": { + "name": "group_key", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence_id": { + "name": "sequence_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ntfy_original_message_id": { + "name": "ntfy_original_message_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dose_ids_json": { + "name": "dose_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_action": { + "name": "resolved_action", + "type": "text(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_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": { + "notification_action_groups_group_key_unique": { + "name": "notification_action_groups_group_key_unique", + "columns": [ + "group_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_action_groups_user_id_users_id_fk": { + "name": "notification_action_groups_user_id_users_id_fk", + "tableFrom": "notification_action_groups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_action_tokens": { + "name": "notification_action_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_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" + } + }, + "indexes": { + "notification_action_tokens_token_hash_unique": { + "name": "notification_action_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_action_tokens_group_id_notification_action_groups_id_fk": { + "name": "notification_action_tokens_group_id_notification_action_groups_id_fk", + "tableFrom": "notification_action_tokens", + "tableTo": "notification_action_groups", + "columnsFrom": [ + "group_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 + }, + "allow_journal_notes": { + "name": "allow_journal_notes", + "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" + }, + "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'" + }, + "timezone": { + "name": "timezone", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "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 + }, + "share_medication_overview": { + "name": "share_medication_overview", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "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 58499b8..f9c75ea 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1775849300000, "tag": "0014_add_user_settings_timezone", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1778962021119, + "tag": "0015_add_intake_journal", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1779044316043, + "tag": "0016_add_share_allow_journal_notes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/migration-utils.ts b/backend/src/db/migration-utils.ts index a6fc65e..f6a7f47 100644 --- a/backend/src/db/migration-utils.ts +++ b/backend/src/db/migration-utils.ts @@ -76,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`, `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`, `ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`, + `ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`, ]; for (const sql of alterMigrations) { @@ -97,6 +98,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo loose_pills_added INTEGER NOT NULL DEFAULT 0, refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now')) )`, + `CREATE TABLE IF NOT EXISTS intake_journal ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE, + medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE, + scheduled_for INTEGER NOT NULL, + note TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, `CREATE TABLE IF NOT EXISTS notification_action_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -164,6 +175,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo const createIndexMigrations = [ `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, `CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`, + `CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`, `CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`, `CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`, diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts index a0fdcfe..8d12f79 100644 --- a/backend/src/db/schema-sql.ts +++ b/backend/src/db/schema-sql.ts @@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] { token text NOT NULL UNIQUE, taken_by text NOT NULL, schedule_days integer NOT NULL DEFAULT 30, + allow_journal_notes integer NOT NULL DEFAULT 0, created_at integer NOT NULL DEFAULT (strftime('%s','now')), expires_at integer, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index b2d3ae5..0bf81b3 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -180,6 +180,7 @@ export const shareTokens = sqliteTable("share_tokens", { token: text("token", { length: 64 }).notNull().unique(), takenBy: text("taken_by", { length: 100 }).notNull(), scheduleDays: integer("schedule_days").notNull().default(30), + allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires }); @@ -236,6 +237,27 @@ export const doseTracking = sqliteTable("dose_tracking", { dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction }); +// ============================================================================= +// Intake Journal - Optional owner-scoped note for a tracked dose event +// ============================================================================= +export const intakeJournal = sqliteTable("intake_journal", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + doseTrackingId: integer("dose_tracking_id") + .notNull() + .unique() + .references(() => doseTracking.id, { onDelete: "cascade" }), + medicationId: integer("medication_id") + .notNull() + .references(() => medications.id, { onDelete: "cascade" }), + scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(), + note: text("note").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), +}); + // ============================================================================= // Refill History - Tracks when medication stock was refilled // ============================================================================= diff --git a/backend/src/index.ts b/backend/src/index.ts index 576934f..07e3f06 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js"; import { doseRoutes } from "./routes/doses.js"; import { exportRoutes } from "./routes/export.js"; import { healthRoutes } from "./routes/health.js"; +import { intakeJournalRoutes } from "./routes/intake-journal.js"; import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js"; import { medicationRoutes } from "./routes/medications.js"; import { notificationActionRoutes } from "./routes/notification-actions.js"; @@ -109,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) { { name: "health", description: "Service health endpoints" }, { name: "auth", description: "Authentication and profile endpoints" }, { name: "api-keys", description: "Programmatic API key management" }, + { name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" }, { name: "medication-enrichment", description: "Medication search and enrichment endpoints" }, { name: "settings", description: "User settings and notification test endpoints" }, ], @@ -248,6 +250,7 @@ export async function createApp(options?: { await app.register(notificationActionRoutes); await app.register(shareRoutes); await app.register(doseRoutes); + await app.register(intakeJournalRoutes); await app.register(exportRoutes); await app.register(refillRoutes); await app.register(reportRoutes); @@ -349,6 +352,7 @@ await app.register(plannerRoutes); await app.register(notificationActionRoutes); await app.register(shareRoutes); await app.register(doseRoutes); +await app.register(intakeJournalRoutes); await app.register(exportRoutes); await app.register(refillRoutes); await app.register(reportRoutes); diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index b18f34f..fe3baf7 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -1,19 +1,26 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; -import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js"; +import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { computeMedicationCurrentStock } from "../services/current-stock.js"; import { markDoseTakenForUser } from "../services/dose-tracking-service.js"; +import { + getIntakeJournalForDoseEvent, + resolveTrackedDoseEventForUser, + upsertIntakeJournalForDoseEvent, +} from "../services/intake-journal-service.js"; import type { AuthUser } from "../types/fastify.js"; +import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js"; import { applyOpenApiRouteStandards, genericErrorSchema, tokenParamsSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; +import { redactTokenForLog } from "../utils/redaction.js"; import { parseIntakesJson, parseLocalDateTime, @@ -32,6 +39,10 @@ const shareDoseSchema = z.object({ doseId: z.string().min(1, "doseId is required"), }); +const shareJournalUpsertSchema = z.object({ + note: z.string().max(4000), +}); + const dismissDosesSchema = z.object({ doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"), }); @@ -56,12 +67,52 @@ const doseReadResponseSchema = { markedBy: { type: ["string", "null"] }, takenSource: { type: "string" }, dismissed: { type: "boolean" }, + hasJournalNote: { type: "boolean" }, }, }, }, }, } as const; +const shareJournalEntrySchema = { + type: "object", + required: [ + "doseTrackingId", + "doseId", + "medicationId", + "medicationName", + "scheduledFor", + "dismissed", + "takenSource", + "note", + "updatedAt", + ], + properties: { + doseTrackingId: { type: "integer" }, + doseId: { type: "string" }, + medicationId: { type: "integer" }, + medicationName: { type: "string" }, + scheduledFor: { type: "string", format: "date-time" }, + takenAt: { type: ["string", "null"], format: "date-time" }, + dismissed: { type: "boolean" }, + takenSource: { type: "string", enum: ["manual", "automatic"] }, + markedBy: { type: ["string", "null"] }, + note: { type: ["string", "null"] }, + updatedAt: { type: ["string", "null"], format: "date-time" }, + createdAt: { type: ["string", "null"], format: "date-time" }, + }, + additionalProperties: false, +} as const; + +const shareJournalResponseSchema = { + type: "object", + required: ["entry"], + properties: { + entry: shareJournalEntrySchema, + }, + additionalProperties: false, +} as const; + function getValidationErrorMessage(error: z.ZodError): string { const firstIssue = error.issues[0]; if (!firstIssue) { @@ -71,6 +122,18 @@ function getValidationErrorMessage(error: z.ZodError): string { return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message; } +function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return null; + } + + if (dismissed && value.getTime() <= 0) { + return null; + } + + return value.toISOString(); +} + // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { @@ -135,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI return false; } + if (!isDoseInsideShareScheduleWindow(share, parsedDose)) { + return false; + } + const [medication] = await db .select() .from(medications) @@ -172,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI return expectedPersons.includes(parsedDose.personSuffix); } +function getLocalDayStartMs(value: Date | number): number { + const date = typeof value === "number" ? new Date(value) : new Date(value.getTime()); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean { + const scheduleDays = Math.max(1, share.scheduleDays ?? 30); + const todayStart = getLocalDayStartMs(new Date()); + const earliestVisible = new Date(todayStart); + earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1)); + const latestVisibleExclusive = new Date(todayStart); + latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays); + const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs); + + return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime(); +} + async function isDoseOutOfStock(options: { userId: number; doseId: string; @@ -226,6 +311,81 @@ async function isDoseOutOfStock(options: { ); } +async function markDoseSkippedForUser(input: { + userId: number; + doseId: string; +}): Promise<"created" | "updated" | "already_skipped"> { + const [existing] = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId))); + + if (existing) { + if (existing.dismissed) { + return "already_skipped"; + } + + await db + .update(doseTracking) + .set({ dismissed: true }) + .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId))); + return "updated"; + } + + await db.insert(doseTracking).values({ + userId: input.userId, + doseId: input.doseId, + markedBy: null, + takenAt: new Date(0), + dismissed: true, + }); + + return "created"; +} + +async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise { + const [existing] = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId))); + + if (!existing?.dismissed) { + return false; + } + + const hasRealTakenTimestamp = + existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt); + if (existing.markedBy !== null || hasRealTakenTimestamp) { + await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id)); + return true; + } + + await db.delete(doseTracking).where(eq(doseTracking.id, existing.id)); + return true; +} + +function buildSharedJournalEntryDto(input: { + event: NonNullable>>; + journalEntry: Awaited>; +}) { + const { event, journalEntry } = input; + + return { + doseTrackingId: event.doseTrackingId, + doseId: event.doseId, + medicationId: event.medicationId, + medicationName: event.medicationName, + scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor), + takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed), + dismissed: event.dismissed, + takenSource: event.takenSource, + markedBy: event.markedBy, + note: journalEntry?.note ?? null, + updatedAt: journalEntry?.updatedAt?.toISOString() ?? null, + createdAt: journalEntry?.createdAt?.toISOString() ?? null, + }; +} + // ============================================================================= // Dose Tracking Routes // ============================================================================= @@ -233,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) { applyOpenApiRouteStandards(app, { tag: "doses", protectedByDefault: false, - protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/], + protectedPaths: [ + /^\/doses\/taken$/, + /^\/doses\/taken\/:doseId$/, + /^\/doses\/dismiss$/, + /^\/doses\/skip$/, + /^\/doses\/skip\/:doseId$/, + ], }); // --------------------------------------------------------------------------- @@ -383,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) { } ); + // --------------------------------------------------------------------------- + // POST /doses/skip - PROTECTED: Mark a single dose as skipped + // --------------------------------------------------------------------------- + app.post<{ Body: z.infer }>( + "/doses/skip", + { + preHandler: requireAuth, + schema: { + tags: ["doses"], + security: protectedEndpointSecurity, + body: { + type: "object", + required: ["doseId"], + properties: { + doseId: { type: "string", minLength: 1 }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const parsed = markDoseSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) }); + } + + const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId }); + if (status === "already_skipped") { + return { success: true, message: "Already skipped" }; + } + + return { success: true }; + } + ); + + // --------------------------------------------------------------------------- + // DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose + // --------------------------------------------------------------------------- + app.delete<{ Params: { doseId: string } }>( + "/doses/skip/:doseId", + { + preHandler: requireAuth, + schema: { + tags: ["doses"], + security: protectedEndpointSecurity, + params: { + type: "object", + required: ["doseId"], + properties: { + doseId: { type: "string", minLength: 1 }, + }, + }, + response: { + 200: { type: "object", properties: { success: { type: "boolean" } } }, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + await undoDoseSkippedForUser({ userId, doseId: request.params.doseId }); + + return { success: true }; + } + ); + // --------------------------------------------------------------------------- // POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock // --------------------------------------------------------------------------- @@ -431,27 +674,8 @@ export async function doseRoutes(app: FastifyInstance) { // becomes dismissed, regardless of whether it already has a taken timestamp. let dismissedCount = 0; for (const doseId of doseIds) { - const [existing] = await db - .select() - .from(doseTracking) - .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); - - if (existing) { - if (!existing.dismissed) { - await db - .update(doseTracking) - .set({ dismissed: true }) - .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); - dismissedCount++; - } - } else { - await db.insert(doseTracking).values({ - userId, - doseId, - markedBy: null, - takenAt: new Date(0), - dismissed: true, - }); + const status = await markDoseSkippedForUser({ userId, doseId }); + if (status !== "already_skipped") { dismissedCount++; } } @@ -533,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) { }, async (request, reply) => { const { token } = request.params; + const tokenRef = redactTokenForLog(token); const { share, reason } = await getActiveShareToken(token); if (!share) { - request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`); + request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`); return reply.notFound("Share link not found"); } - // Get all taken doses for this user (no time limit) + // Keep public dose reads scoped to the selected share person and visible schedule window. const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)); + const visibleDoses: (typeof doseTracking.$inferSelect)[] = []; + for (const dose of doses) { + if (await validateShareDoseId(share, dose.doseId)) { + visibleDoses.push(dose); + } + } + + const journalDoseTrackingIds = new Set(); + if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) { + const journalRows = await db + .select({ doseTrackingId: intakeJournal.doseTrackingId }) + .from(intakeJournal) + .where( + and( + eq(intakeJournal.userId, share.userId), + inArray( + intakeJournal.doseTrackingId, + visibleDoses.map((dose) => dose.id) + ) + ) + ); + + for (const row of journalRows) { + journalDoseTrackingIds.add(row.doseTrackingId); + } + } return { - doses: doses.map((d) => ({ + doses: visibleDoses.map((d) => ({ doseId: d.doseId, takenAt: d.takenAt?.getTime() ?? Date.now(), markedBy: d.markedBy, takenSource: d.takenSource ?? "manual", dismissed: d.dismissed ?? false, + hasJournalNote: journalDoseTrackingIds.has(d.id), })), }; } ); + app.get<{ Params: { token: string; doseId: string } }>( + "/share/:token/journal/event/:doseId", + { + schema: { + params: { + type: "object", + required: ["token", "doseId"], + properties: { + token: tokenParamsSchema.properties.token, + doseId: { type: "string", minLength: 1 }, + }, + }, + response: { + 200: shareJournalResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 403: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { token, doseId } = request.params; + const tokenRef = redactTokenForLog(token); + + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); + return reply.notFound("Share link not found"); + } + + if (!(share.allowJournalNotes ?? false)) { + return reply + .status(403) + .send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" }); + } + + const isValidShareDoseId = await validateShareDoseId(share, doseId); + if (!isValidShareDoseId) { + return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" }); + } + + const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId }); + if (!event) { + return reply + .status(404) + .send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" }); + } + + const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId }); + return { entry: buildSharedJournalEntryDto({ event, journalEntry }) }; + } + ); + + app.put<{ Params: { token: string; doseId: string }; Body: z.infer }>( + "/share/:token/journal/event/:doseId", + { + schema: { + params: { + type: "object", + required: ["token", "doseId"], + properties: { + token: tokenParamsSchema.properties.token, + doseId: { type: "string", minLength: 1 }, + }, + }, + body: { + type: "object", + required: ["note"], + properties: { + note: { type: "string", maxLength: 4000 }, + }, + additionalProperties: false, + }, + response: { + 200: shareJournalResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 403: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { token, doseId } = request.params; + const tokenRef = redactTokenForLog(token); + + const parsed = shareJournalUpsertSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" }); + } + + const normalizedNote = parsed.data.note.trim(); + if (normalizedNote.length === 0) { + return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" }); + } + + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); + return reply.notFound("Share link not found"); + } + + if (!(share.allowJournalNotes ?? false)) { + return reply + .status(403) + .send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" }); + } + + const isValidShareDoseId = await validateShareDoseId(share, doseId); + if (!isValidShareDoseId) { + return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" }); + } + + const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId }); + if (!event) { + return reply + .status(404) + .send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" }); + } + + const journalEntry = await upsertIntakeJournalForDoseEvent({ + userId: share.userId, + doseId, + note: normalizedNote, + }); + + return { entry: buildSharedJournalEntryDto({ event, journalEntry }) }; + } + ); + + app.delete<{ Params: { token: string; doseId: string } }>( + "/share/:token/journal/event/:doseId", + { + schema: { + params: { + type: "object", + required: ["token", "doseId"], + properties: { + token: tokenParamsSchema.properties.token, + doseId: { type: "string", minLength: 1 }, + }, + }, + response: { + 403: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { token, doseId } = request.params; + const tokenRef = redactTokenForLog(token); + + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); + return reply.notFound("Share link not found"); + } + + if (!(share.allowJournalNotes ?? false)) { + return reply + .status(403) + .send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" }); + } + + return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" }); + } + ); + + // --------------------------------------------------------------------------- + // POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link + // --------------------------------------------------------------------------- + app.post<{ Params: { token: string }; Body: z.infer }>( + "/share/:token/doses/skip", + { + schema: { + params: tokenParamsSchema, + body: { + type: "object", + required: ["doseId"], + properties: { + doseId: { type: "string", minLength: 1 }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { token } = request.params; + const tokenRef = redactTokenForLog(token); + + const parsed = shareDoseSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) }); + } + + const { doseId } = parsed.data; + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); + return reply.notFound("Share link not found"); + } + + const isValidShareDoseId = await validateShareDoseId(share, doseId); + if (!isValidShareDoseId) { + request.log.warn( + `[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + ); + return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); + } + + const status = await markDoseSkippedForUser({ userId: share.userId, doseId }); + if (status === "already_skipped") { + return { success: true, message: "Already skipped" }; + } + + request.log.info( + `[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + ); + return { success: true }; + } + ); + + // --------------------------------------------------------------------------- + // DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link + // --------------------------------------------------------------------------- + app.delete<{ Params: { token: string; doseId: string } }>( + "/share/:token/doses/skip/:doseId", + { + schema: { + params: { + type: "object", + required: ["token", "doseId"], + properties: { + token: tokenParamsSchema.properties.token, + doseId: { type: "string", minLength: 1 }, + }, + }, + response: { + 200: { type: "object", properties: { success: { type: "boolean" } } }, + 400: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { token, doseId } = request.params; + const tokenRef = redactTokenForLog(token); + + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); + return reply.notFound("Share link not found"); + } + + const isValidShareDoseId = await validateShareDoseId(share, doseId); + if (!isValidShareDoseId) { + request.log.warn( + `[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + ); + return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); + } + + await undoDoseSkippedForUser({ userId: share.userId, doseId }); + return { success: true }; + } + ); + // --------------------------------------------------------------------------- // POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link // --------------------------------------------------------------------------- @@ -582,6 +1110,7 @@ export async function doseRoutes(app: FastifyInstance) { }, async (request, reply) => { const { token } = request.params; + const tokenRef = redactTokenForLog(token); const parsed = shareDoseSchema.safeParse(request.body); if (!parsed.success) { @@ -594,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) { const { share, reason } = await getActiveShareToken(token); if (!share) { - request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`); + request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); return reply.notFound("Share link not found"); } const isValidShareDoseId = await validateShareDoseId(share, doseId); if (!isValidShareDoseId) { request.log.warn( - `[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + `[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); } @@ -614,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) { if (existing) { request.log.debug( - `[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + `[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return { success: true, message: "Already marked" }; } @@ -627,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) { }); if (outOfStock) { request.log.info( - `[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + `[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" }); } @@ -644,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) { }); request.log.info( - `[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}` + `[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}` ); return { success: true }; @@ -675,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) { }, async (request, reply) => { const { token, doseId } = request.params; + const tokenRef = redactTokenForLog(token); const { share, reason } = await getActiveShareToken(token); if (!share) { - request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`); + request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`); return reply.notFound("Share link not found"); } const isValidShareDoseId = await validateShareDoseId(share, doseId); if (!isValidShareDoseId) { request.log.warn( - `[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + `[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); } @@ -699,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) { if (existing?.dismissed) { // Already dismissed - keep the record as-is request.log.debug( - `[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + `[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); } else { // Not dismissed - delete the record entirely @@ -707,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) { .delete(doseTracking) .where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId))); request.log.info( - `[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` + `[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); } diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index 53a8cc3..c0e0330 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { getDataDir } from "../db/path-utils.js"; -import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js"; +import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { + listIntakeJournalExportPayloadsForUser, + restoreIntakeJournalForImportedDose, +} from "../services/intake-journal-export.js"; import type { AuthUser } from "../types/fastify.js"; import { applyOpenApiRouteStandards, @@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images"); // ============================================================================= // Export Format Version (bump this when format changes) // ============================================================================= -const EXPORT_VERSION = "1.5"; +const EXPORT_VERSION = "1.6"; // ============================================================================= // Zod Schemas for Import Validation @@ -91,6 +95,9 @@ const doseHistorySchema = z.object({ takenSource: z.enum(["manual", "automatic"]).default("manual"), dismissed: z.boolean().default(false), takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel") + journalNote: z.string().nullable().optional(), + journalCreatedAt: z.string().nullable().optional(), + journalUpdatedAt: z.string().nullable().optional(), }); const refillHistoryExportSchema = z.object({ @@ -105,6 +112,7 @@ const refillHistoryExportSchema = z.object({ const shareLinkSchema = z.object({ takenBy: z.string().min(1), scheduleDays: z.number().int().min(1).default(30), + allowJournalNotes: z.boolean().default(false), expiresAt: z.string().nullable().optional(), // ISO datetime regenerateToken: z.boolean().default(true), }); @@ -195,7 +203,7 @@ const importBodyOpenApiSchema = { shareLinks: { type: "array", items: { type: "object", additionalProperties: true } }, }, example: { - version: "1.8.0", + version: "1.6", exportedAt: "2026-03-11T10:15:00.000Z", includeSensitiveData: true, medications: [ @@ -215,13 +223,72 @@ const importBodyOpenApiSchema = { ], }, ], - doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }], + doseHistory: [ + { + medicationRef: "med-1", + scheduleIndex: 0, + scheduledTime: "2026-03-11T08:00:00.000Z", + takenAt: "2026-03-11T08:03:00.000Z", + markedBy: "Daniel", + takenSource: "manual", + dismissed: false, + takenByPerson: "Daniel", + journalNote: "Took after breakfast.", + journalUpdatedAt: "2026-03-11T08:05:00.000Z", + }, + ], refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }], settings: { language: "en", stockCalculationMode: "automatic" }, shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }], }, } as const; +const importPreviewResponseSchema = { + type: "object", + properties: { + success: { type: "boolean" }, + preview: { + type: "object", + properties: { + version: { type: "string" }, + exportedAt: { type: "string", format: "date-time" }, + includeSensitiveData: { type: "boolean" }, + incoming: { + type: "object", + properties: { + medications: { type: "integer" }, + doseHistory: { type: "integer" }, + refillHistory: { type: "integer" }, + shareLinks: { type: "integer" }, + journalEntries: { type: "integer" }, + imageCount: { type: "integer" }, + hasSettings: { type: "boolean" }, + }, + }, + current: { + type: "object", + properties: { + medications: { type: "integer" }, + doseHistory: { type: "integer" }, + refillHistory: { type: "integer" }, + shareLinks: { type: "integer" }, + hasSettings: { type: "boolean" }, + }, + }, + warnings: { + type: "object", + properties: { + replacesExistingData: { type: "boolean" }, + regeneratesShareLinks: { type: "boolean" }, + containsImages: { type: "boolean" }, + containsSensitiveData: { type: "boolean" }, + }, + }, + }, + }, + }, +} as const; + // ============================================================================= // Helper Functions // ============================================================================= @@ -321,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null { } } +function removeFileIfPresent(filePath: string): string | null { + if (!existsSync(filePath)) { + return null; + } + + try { + unlinkSync(filePath); + return null; + } catch (error) { + return error instanceof Error ? error.message : "Unknown file removal error"; + } +} + +function buildImportPreview( + importData: z.infer, + currentData: { + medications: number; + doseHistory: number; + refillHistory: number; + shareLinks: number; + hasSettings: boolean; + } +) { + const journalEntries = importData.doseHistory.filter( + (dose) => typeof dose.journalNote === "string" && dose.journalNote.trim() + ).length; + const imageCount = importData.medications.filter( + (med) => typeof med.image === "string" && med.image.startsWith("data:") + ).length; + + return { + version: importData.version, + exportedAt: importData.exportedAt, + includeSensitiveData: importData.includeSensitiveData, + incoming: { + medications: importData.medications.length, + doseHistory: importData.doseHistory.length, + refillHistory: importData.refillHistory.length, + shareLinks: importData.shareLinks.length, + journalEntries, + imageCount, + hasSettings: Boolean(importData.settings), + }, + current: currentData, + warnings: { + replacesExistingData: + currentData.medications > 0 || + currentData.doseHistory > 0 || + currentData.refillHistory > 0 || + currentData.shareLinks > 0 || + currentData.hasSettings, + regeneratesShareLinks: importData.shareLinks.length > 0, + containsImages: imageCount > 0, + containsSensitiveData: importData.includeSensitiveData, + }, + }; +} + // Parse dose ID to extract medication ID and timestamp // Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}" function parseDoseId( @@ -442,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) { // 2. Load all dose tracking entries const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); + const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId); const exportDoseHistory = doses .map((dose) => { @@ -484,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) { takenSource: dose.takenSource === "automatic" ? "automatic" : "manual", dismissed: dose.dismissed ?? false, takenByPerson: parsed.person, + ...journalPayloadsByDoseTrackingId.get(dose.id), }; }) .filter((d): d is NonNullable => d !== null); @@ -542,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) { return { takenBy: share.takenBy, scheduleDays: share.scheduleDays, + allowJournalNotes: share.allowJournalNotes ?? false, expiresAt: expiresAtIso, regenerateToken: true, // Always regenerate tokens on import for security }; @@ -617,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) { } ); + // --------------------------------------------------------------------------- + // POST /import/preview - Validate and summarize import data without writing + // --------------------------------------------------------------------------- + app.post( + "/import/preview", + { + config: { + rawBody: true, + }, + bodyLimit: 50 * 1024 * 1024, + schema: { + body: importBodyOpenApiSchema, + response: { + 200: importPreviewResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + + const parsed = importDataSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid import data format", + details: parsed.error.format(), + }); + } + + const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] = + await Promise.all([ + db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)), + db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)), + db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)), + db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)), + db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)), + ]); + + return { + success: true, + preview: buildImportPreview(parsed.data, { + medications: existingMeds.length, + doseHistory: existingDoseHistory.length, + refillHistory: existingRefillHistory.length, + shareLinks: existingShareLinks.length, + hasSettings: existingSettings.length > 0, + }), + }; + } + ); + // --------------------------------------------------------------------------- // POST /import - Import user data (replaces all existing data!) // --------------------------------------------------------------------------- @@ -649,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) { }, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, + 500: genericErrorSchema, }, }, }, @@ -666,193 +847,208 @@ export async function exportRoutes(app: FastifyInstance) { const importData = parsed.data; - // 2. Delete all existing user data (in correct order to respect foreign keys) - // Note: CASCADE delete should handle this, but let's be explicit - - // First, delete images for existing medications + // Existing image files are removed only after the DB import commits. const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId)); - for (const med of existingMeds) { - if (med.imageUrl) { - const imagePath = resolve(IMAGES_DIR, med.imageUrl); - if (existsSync(imagePath)) { - try { - unlinkSync(imagePath); - } catch { - /* ignore */ + const oldImagePaths = existingMeds + .map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null)) + .filter((path): path is string => path !== null); + const newImagePaths: string[] = []; + + try { + await db.transaction(async (tx) => { + // Delete in order: journal entries, refill history, doses, share tokens, medications, settings. + await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId)); + await tx.delete(refillHistory).where(eq(refillHistory.userId, userId)); + await tx.delete(doseTracking).where(eq(doseTracking.userId, userId)); + await tx.delete(shareTokens).where(eq(shareTokens.userId, userId)); + await tx.delete(medications).where(eq(medications.userId, userId)); + await tx.delete(userSettings).where(eq(userSettings.userId, userId)); + + const exportIdToNewId = new Map(); + + for (const med of importData.medications) { + const normalizedSchedules = med.schedules.map((schedule) => + normalizeIntake({ + usage: schedule.usage, + every: schedule.every, + start: schedule.start, + scheduleMode: schedule.scheduleMode, + weekdays: schedule.weekdays, + intakeUnit: schedule.intakeUnit ?? null, + takenBy: schedule.takenBy || null, + intakeRemindersEnabled: schedule.remind ?? false, + }) + ); + const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage)); + const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every)); + const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start)); + const takenByJson = JSON.stringify(med.takenBy); + const intakesJson = JSON.stringify(normalizedSchedules); + const intakeRemindersEnabled = + normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled; + + const [inserted] = await tx + .insert(medications) + .values({ + userId, + name: med.name, + genericName: med.genericName || null, + takenByJson, + medicationForm: med.medicationForm ?? "tablet", + pillForm: med.pillForm || null, + lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty", + packageType: normalizePackageType(med.inventory.packageType), + packageAmountValue: med.inventory.packageAmountValue ?? 0, + packageAmountUnit: med.inventory.packageAmountUnit ?? "ml", + packCount: med.inventory.packCount, + blistersPerPack: med.inventory.blistersPerPack, + pillsPerBlister: med.inventory.pillsPerBlister, + looseTablets: med.inventory.looseTablets, + totalPills: med.inventory.totalPills ?? null, + stockAdjustment: med.inventory.stockAdjustment ?? 0, + lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null, + pillWeightMg: med.pillWeightMg || null, + doseUnit: med.doseUnit ?? "mg", + medicationStartDate: med.medicationStartDate || "", + medicationEndDate: med.medicationEndDate || null, + autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true, + intakesJson, + usageJson, + everyJson, + startJson, + expiryDate: med.expiryDate || null, + notes: med.notes || null, + intakeRemindersEnabled, + isObsolete: med.isObsolete ?? false, + obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null, + prescriptionEnabled: med.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: med.prescriptionEnabled + ? (med.prescriptionAuthorizedRefills ?? null) + : null, + prescriptionRemainingRefills: med.prescriptionEnabled + ? (med.prescriptionRemainingRefills ?? null) + : null, + prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: med.prescriptionExpiryDate || null, + dismissedUntil: med.dismissedUntil || null, + imageUrl: null, + }) + .returning(); + + exportIdToNewId.set(med._exportId, inserted.id); + + if (med.image) { + const imageUrl = base64ToImage(med.image, inserted.id); + if (imageUrl) { + newImagePaths.push(resolve(IMAGES_DIR, imageUrl)); + await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id)); + } } } - } - } - // Delete in order: refill history, doses, share tokens, medications, settings - await db.delete(refillHistory).where(eq(refillHistory.userId, userId)); - await db.delete(doseTracking).where(eq(doseTracking.userId, userId)); - await db.delete(shareTokens).where(eq(shareTokens.userId, userId)); - await db.delete(medications).where(eq(medications.userId, userId)); - await db.delete(userSettings).where(eq(userSettings.userId, userId)); + for (const dose of importData.doseHistory) { + const newMedId = exportIdToNewId.get(dose.medicationRef); + if (!newMedId) continue; - // 3. Import medications and build ID mapping - const exportIdToNewId = new Map(); + const scheduledFor = new Date(dose.scheduledTime); + const timestampMs = scheduledFor.getTime(); + const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson); - for (const med of importData.medications) { - const normalizedSchedules = med.schedules.map((schedule) => - normalizeIntake({ - usage: schedule.usage, - every: schedule.every, - start: schedule.start, - scheduleMode: schedule.scheduleMode, - weekdays: schedule.weekdays, - intakeUnit: schedule.intakeUnit ?? null, - takenBy: schedule.takenBy || null, - intakeRemindersEnabled: schedule.remind ?? false, - }) - ); - const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage)); - const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every)); - const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start)); - const takenByJson = JSON.stringify(med.takenBy); + const [insertedDose] = await tx + .insert(doseTracking) + .values({ + userId, + doseId, + takenAt: new Date(dose.takenAt), + markedBy: dose.markedBy || null, + takenSource: dose.takenSource ?? "manual", + dismissed: dose.dismissed ?? false, + }) + .returning({ id: doseTracking.id }); - const intakesJson = JSON.stringify(normalizedSchedules); + await restoreIntakeJournalForImportedDose({ + userId, + doseTrackingId: insertedDose.id, + medicationId: newMedId, + scheduledFor, + journalNote: dose.journalNote, + journalCreatedAt: dose.journalCreatedAt, + journalUpdatedAt: dose.journalUpdatedAt, + database: tx, + }); + } - // Check if any schedule has remind enabled - const intakeRemindersEnabled = - normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled; + if (importData.settings) { + await tx.insert(userSettings).values({ + userId, + emailEnabled: importData.settings.emailEnabled ?? false, + notificationEmail: importData.settings.notificationEmail || null, + emailStockReminders: importData.settings.emailStockReminders ?? true, + emailIntakeReminders: importData.settings.emailIntakeReminders ?? true, + emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true, + shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false, + shoutrrrUrl: importData.settings.shoutrrrUrl || null, + shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true, + shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true, + shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true, + reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7, + repeatDailyReminders: importData.settings.repeatDailyReminders ?? false, + skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5, + lowStockDays: importData.settings.lowStockDays ?? 30, + normalStockDays: importData.settings.normalStockDays ?? 90, + highStockDays: importData.settings.highStockDays ?? 180, + expiryWarningDays: importData.settings.expiryWarningDays ?? 90, + language: importData.settings.language ?? "en", + stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic", + shareMedicationOverview: importData.settings.shareMedicationOverview ?? false, + }); + } - const [inserted] = await db - .insert(medications) - .values({ - userId, - name: med.name, - genericName: med.genericName || null, - takenByJson, - medicationForm: med.medicationForm ?? "tablet", - pillForm: med.pillForm || null, - lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty", - packageType: normalizePackageType(med.inventory.packageType), - packageAmountValue: med.inventory.packageAmountValue ?? 0, - packageAmountUnit: med.inventory.packageAmountUnit ?? "ml", - packCount: med.inventory.packCount, - blistersPerPack: med.inventory.blistersPerPack, - pillsPerBlister: med.inventory.pillsPerBlister, - looseTablets: med.inventory.looseTablets, - totalPills: med.inventory.totalPills ?? null, - stockAdjustment: med.inventory.stockAdjustment ?? 0, - lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null, - pillWeightMg: med.pillWeightMg || null, - doseUnit: med.doseUnit ?? "mg", - medicationStartDate: med.medicationStartDate || "", - medicationEndDate: med.medicationEndDate || null, - autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true, - intakesJson, - usageJson, - everyJson, - startJson, - expiryDate: med.expiryDate || null, - notes: med.notes || null, - intakeRemindersEnabled, - isObsolete: med.isObsolete ?? false, - obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null, - prescriptionEnabled: med.prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null, - prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null, - prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: med.prescriptionExpiryDate || null, - dismissedUntil: med.dismissedUntil || null, - imageUrl: null, // Will be set after image is saved - }) - .returning(); + for (const share of importData.shareLinks) { + await tx.insert(shareTokens).values({ + userId, + token: randomBytes(8).toString("hex"), + takenBy: share.takenBy, + scheduleDays: share.scheduleDays, + allowJournalNotes: share.allowJournalNotes ?? false, + expiresAt: share.expiresAt ? new Date(share.expiresAt) : null, + }); + } - // Save mapping - exportIdToNewId.set(med._exportId, inserted.id); + for (const refill of importData.refillHistory) { + const newMedId = exportIdToNewId.get(refill.medicationRef); + if (!newMedId) continue; - // Save image if present - if (med.image) { - const imageUrl = base64ToImage(med.image, inserted.id); - if (imageUrl) { - await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id)); + await tx.insert(refillHistory).values({ + medicationId: newMedId, + userId, + packsAdded: refill.packsAdded ?? 0, + loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0, + usedPrescription: refill.usedPrescription ?? false, + refillDate: new Date(refill.refillDate), + }); + } + }); + } catch (error) { + for (const imagePath of newImagePaths) { + const removalError = removeFileIfPresent(imagePath); + if (removalError) { + request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`); } } + + request.log.error({ err: error }, "[Import] Failed to import data"); + return reply.status(500).send({ error: "Import failed" }); } - // 4. Import dose history with remapped medication IDs - for (const dose of importData.doseHistory) { - const newMedId = exportIdToNewId.get(dose.medicationRef); - if (!newMedId) continue; // Skip orphaned doses - - // Convert ISO timestamp back to milliseconds for dose ID - const timestampMs = new Date(dose.scheduledTime).getTime(); - // Rebuild dose ID with optional person suffix - const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson); - - await db.insert(doseTracking).values({ - userId, - doseId, - takenAt: new Date(dose.takenAt), - markedBy: dose.markedBy || null, - takenSource: dose.takenSource ?? "manual", - dismissed: dose.dismissed ?? false, - }); - } - - // 5. Import settings - if (importData.settings) { - // Legacy exports may still contain shareStockStatus. The current app no longer - // uses that setting, so imports accept it for compatibility and then ignore it. - await db.insert(userSettings).values({ - userId, - emailEnabled: importData.settings.emailEnabled ?? false, - notificationEmail: importData.settings.notificationEmail || null, - emailStockReminders: importData.settings.emailStockReminders ?? true, - emailIntakeReminders: importData.settings.emailIntakeReminders ?? true, - emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true, - shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false, - shoutrrrUrl: importData.settings.shoutrrrUrl || null, - shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true, - shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true, - shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true, - reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7, - repeatDailyReminders: importData.settings.repeatDailyReminders ?? false, - skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false, - repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false, - reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30, - maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5, - lowStockDays: importData.settings.lowStockDays ?? 30, - normalStockDays: importData.settings.normalStockDays ?? 90, - highStockDays: importData.settings.highStockDays ?? 180, - expiryWarningDays: importData.settings.expiryWarningDays ?? 90, - language: importData.settings.language ?? "en", - stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic", - shareMedicationOverview: importData.settings.shareMedicationOverview ?? false, - }); - } - - // 6. Import share links (with new tokens) - for (const share of importData.shareLinks) { - // Always generate new token for security - const token = randomBytes(8).toString("hex"); - - await db.insert(shareTokens).values({ - userId, - token, - takenBy: share.takenBy, - scheduleDays: share.scheduleDays, - expiresAt: share.expiresAt ? new Date(share.expiresAt) : null, - }); - } - - // 7. Import refill history with remapped medication IDs - for (const refill of importData.refillHistory) { - const newMedId = exportIdToNewId.get(refill.medicationRef); - if (!newMedId) continue; // Skip orphaned refill records - - await db.insert(refillHistory).values({ - medicationId: newMedId, - userId, - packsAdded: refill.packsAdded ?? 0, - loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0, - usedPrescription: refill.usedPrescription ?? false, - refillDate: new Date(refill.refillDate), - }); + for (const imagePath of oldImagePaths) { + const removalError = removeFileIfPresent(imagePath); + if (removalError) { + request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`); + } } return { diff --git a/backend/src/routes/intake-journal.ts b/backend/src/routes/intake-journal.ts new file mode 100644 index 0000000..8b11ba0 --- /dev/null +++ b/backend/src/routes/intake-journal.ts @@ -0,0 +1,373 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; +import { env } from "../plugins/env.js"; +import { + deleteIntakeJournalForDoseEvent, + getIntakeJournalForDoseEvent, + isTrackedDoseIdFormat, + listIntakeJournalEntriesForUser, + resolveTrackedDoseEventForUser, + upsertIntakeJournalForDoseEvent, +} from "../services/intake-journal-service.js"; +import type { AuthUser } from "../types/fastify.js"; +import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; + +const intakeJournalEndpointSecurity: ReadonlyArray> = [ + { bearerAuth: [] }, + { cookieAuth: [] }, +]; + +const doseIdParamsSchema = { + type: "object", + required: ["doseId"], + properties: { + doseId: { type: "string", minLength: 1 }, + }, +} as const; + +const intakeJournalEntrySchema = { + type: "object", + required: [ + "doseTrackingId", + "doseId", + "medicationId", + "medicationName", + "scheduledFor", + "dismissed", + "takenSource", + "note", + "updatedAt", + ], + properties: { + doseTrackingId: { type: "integer" }, + doseId: { type: "string" }, + medicationId: { type: "integer" }, + medicationName: { type: "string" }, + scheduledFor: { type: "string", format: "date-time" }, + takenAt: { type: ["string", "null"], format: "date-time" }, + dismissed: { type: "boolean" }, + takenSource: { type: "string", enum: ["manual", "automatic"] }, + markedBy: { type: ["string", "null"] }, + note: { type: ["string", "null"] }, + updatedAt: { type: ["string", "null"], format: "date-time" }, + createdAt: { type: ["string", "null"], format: "date-time" }, + }, + additionalProperties: false, +} as const; + +const intakeJournalEventResponseSchema = { + type: "object", + required: ["entry"], + properties: { + entry: intakeJournalEntrySchema, + }, + additionalProperties: false, +} as const; + +const intakeJournalHistoryResponseSchema = { + type: "object", + required: ["entries"], + properties: { + entries: { + type: "array", + items: intakeJournalEntrySchema, + }, + }, + additionalProperties: false, +} as const; + +const intakeJournalHistoryQuerySchema = z.object({ + medicationId: z.coerce.number().int().positive().optional(), + from: z.string().trim().min(1).optional(), + to: z.string().trim().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(100), +}); + +const intakeJournalUpsertSchema = z.object({ + note: z.string().max(4000), +}); + +function getValidationErrorMessage(error: z.ZodError): string { + const issue = error.issues[0]; + if (!issue) { + return "Invalid request payload"; + } + + return issue.message; +} + +function parseOptionalDate(value: string | undefined): Date | null { + if (!value) { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function serializeTakenAt(value: Date | null, dismissed: boolean): string | null { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return null; + } + + if (dismissed && value.getTime() <= 0) { + return null; + } + + return value.toISOString(); +} + +function buildJournalEntryDto(input: { + event: Awaited> extends infer T + ? T extends null + ? never + : T + : never; + journalEntry: Awaited>; +}) { + const { event, journalEntry } = input; + + return { + doseTrackingId: event.doseTrackingId, + doseId: event.doseId, + medicationId: event.medicationId, + medicationName: event.medicationName, + scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor), + takenAt: serializeTakenAt(event.takenAt, event.dismissed), + dismissed: event.dismissed, + takenSource: event.takenSource, + markedBy: event.markedBy, + note: journalEntry?.note ?? null, + updatedAt: journalEntry?.updatedAt?.toISOString() ?? null, + createdAt: journalEntry?.createdAt?.toISOString() ?? null, + }; +} + +async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { + if (!env.AUTH_ENABLED) { + return getAnonymousUserId(); + } + + const authUser = request.user as AuthUser | null; + if (!authUser) { + reply.status(401).send({ error: "Not authenticated" }); + throw new Error("AUTH_REQUIRED"); + } + + return authUser.id; +} + +export async function intakeJournalRoutes(app: FastifyInstance) { + app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true }); + + app.get<{ Querystring: z.infer }>( + "/intake-journal", + { + schema: { + tags: ["intake-journal"], + summary: "List intake journal history for the current owner", + security: intakeJournalEndpointSecurity, + querystring: { + type: "object", + properties: { + medicationId: { type: "integer", minimum: 1 }, + from: { type: "string", format: "date-time" }, + to: { type: "string", format: "date-time" }, + limit: { type: "integer", minimum: 1, maximum: 200 }, + }, + }, + response: { + 200: intakeJournalHistoryResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query); + + if (!parsed.success) { + return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) }); + } + + const from = parseOptionalDate(parsed.data.from); + if (parsed.data.from && !from) { + return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" }); + } + + const to = parseOptionalDate(parsed.data.to); + if (parsed.data.to && !to) { + return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" }); + } + + if (from && to && from.getTime() > to.getTime()) { + return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" }); + } + + const entries = await listIntakeJournalEntriesForUser({ + userId, + medicationId: parsed.data.medicationId, + from: from ?? undefined, + to: to ?? undefined, + limit: parsed.data.limit, + }); + + return { + entries: entries.map((entry) => ({ + doseTrackingId: entry.doseTrackingId, + doseId: entry.doseId, + medicationId: entry.medicationId, + medicationName: entry.medicationName, + scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor), + takenAt: serializeTakenAt(entry.takenAt, entry.dismissed), + dismissed: entry.dismissed, + takenSource: entry.takenSource, + markedBy: entry.markedBy, + note: entry.note, + updatedAt: entry.updatedAt.toISOString(), + createdAt: entry.createdAt.toISOString(), + })), + }; + } + ); + + app.get<{ Params: { doseId: string } }>( + "/intake-journal/event/:doseId", + { + schema: { + tags: ["intake-journal"], + summary: "Get intake journal context for a tracked dose event", + security: intakeJournalEndpointSecurity, + params: doseIdParamsSchema, + response: { + 200: intakeJournalEventResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const { doseId } = request.params; + + if (!isTrackedDoseIdFormat(doseId)) { + return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" }); + } + + const event = await resolveTrackedDoseEventForUser({ userId, doseId }); + if (!event) { + return reply + .status(404) + .send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" }); + } + + const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId }); + return { entry: buildJournalEntryDto({ event, journalEntry }) }; + } + ); + + app.put<{ Body: z.infer; Params: { doseId: string } }>( + "/intake-journal/event/:doseId", + { + schema: { + tags: ["intake-journal"], + summary: "Create or update an intake journal note for a tracked dose event", + security: intakeJournalEndpointSecurity, + params: doseIdParamsSchema, + body: { + type: "object", + required: ["note"], + properties: { + note: { type: "string", maxLength: 4000 }, + }, + additionalProperties: false, + }, + response: { + 200: intakeJournalEventResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const { doseId } = request.params; + + if (!isTrackedDoseIdFormat(doseId)) { + return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" }); + } + + const parsed = intakeJournalUpsertSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) }); + } + + const event = await resolveTrackedDoseEventForUser({ userId, doseId }); + if (!event) { + return reply + .status(404) + .send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" }); + } + + const journalEntry = await upsertIntakeJournalForDoseEvent({ + userId, + doseId, + note: parsed.data.note, + }); + + return { entry: buildJournalEntryDto({ event, journalEntry }) }; + } + ); + + app.delete<{ Params: { doseId: string } }>( + "/intake-journal/event/:doseId", + { + schema: { + tags: ["intake-journal"], + summary: "Delete an intake journal note for a tracked dose event", + security: intakeJournalEndpointSecurity, + params: doseIdParamsSchema, + response: { + 200: { + type: "object", + required: ["success"], + properties: { + success: { type: "boolean" }, + }, + additionalProperties: false, + }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const { doseId } = request.params; + + if (!isTrackedDoseIdFormat(doseId)) { + return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" }); + } + + const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId }); + if (!deleted) { + return reply + .status(404) + .send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" }); + } + + return { success: true }; + } + ); +} diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 65d5e1c..f5deca9 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -45,12 +45,24 @@ type PlannerRow = { type SendEmailBody = { email: string; - from: string; - until: string; + from?: string; + until?: string; + startDate?: string; + endDate?: string; rows: PlannerRow[]; language?: Language; // Optional: passed from frontend for unauthenticated requests }; +function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null { + const startDate = body.startDate ?? body.from; + const endDate = body.endDate ?? body.until; + if (!startDate || !endDate) { + return null; + } + + return { startDate, endDate }; +} + type LowStockItem = { name: string; medsLeft: number; @@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) { email: { type: "string" }, from: { type: "string" }, until: { type: "string" }, + startDate: { type: "string", format: "date-time" }, + endDate: { type: "string", format: "date-time" }, language: { type: "string" }, rows: { type: "array", items: plannerRowSchema }, }, example: { email: "daniel@example.com", + startDate: "2026-03-11T00:00:00.000Z", + endDate: "2026-04-11T00:00:00.000Z", from: "2026-03-11", until: "2026-04-11", language: "en", @@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) { }, }, async (request, reply) => { - const { email, from, until, rows, language: bodyLanguage } = request.body; + const { email, rows, language: bodyLanguage } = request.body; + const resolvedDateRange = resolvePlannerDateRange(request.body); request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received"); if (!rows || rows.length === 0) { return reply.status(400).send({ error: "Missing planner data" }); } + if (!resolvedDateRange) { + return reply.status(400).send({ error: "Missing planner date range" }); + } + + const { startDate, endDate } = resolvedDateRange; + // Load user settings for notification channels const userId = await getUserId(request); const activeMeds = await db @@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) { // Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe const fromDate = escapeHtml( - new Date(from).toLocaleDateString(locale, { + new Date(startDate).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", }) ); const untilDate = escapeHtml( - new Date(until).toLocaleDateString(locale, { + new Date(endDate).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts index 6b8215e..3f88725 100644 --- a/backend/src/routes/report.ts +++ b/backend/src/routes/report.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, gte, lt } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; @@ -12,10 +12,42 @@ import { validationErrorSchema, } from "../utils/openapi-route-standards.js"; -const reportDataSchema = z.object({ - medicationIds: z.array(z.number().int().positive()).min(1).max(100), - takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(), -}); +const reportDataSchema = z + .object({ + medicationIds: z.array(z.number().int().positive()).min(1).max(100), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(), + }) + .superRefine((value, ctx) => { + const hasStartDate = typeof value.startDate === "string"; + const hasEndDate = typeof value.endDate === "string"; + + if (hasStartDate !== hasEndDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "startDate and endDate must be provided together", + path: hasStartDate ? ["endDate"] : ["startDate"], + }); + return; + } + + if (!hasStartDate || !hasEndDate) { + return; + } + + const startDateValue = value.startDate!; + const endDateValue = value.endDate!; + const startDate = new Date(startDateValue); + const endDate = new Date(endDateValue); + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid date range", + path: ["endDate"], + }); + } + }); const reportDataBodyOpenApiSchema = { type: "object", @@ -27,6 +59,14 @@ const reportDataBodyOpenApiSchema = { maxItems: 100, items: { type: "integer", minimum: 1 }, }, + startDate: { + type: "string", + format: "date-time", + }, + endDate: { + type: "string", + format: "date-time", + }, takenByFilter: { type: "array", maxItems: 50, @@ -35,17 +75,47 @@ const reportDataBodyOpenApiSchema = { }, example: { medicationIds: [1, 3, 5], + startDate: "2026-05-01T00:00:00.000Z", + endDate: "2026-06-01T00:00:00.000Z", takenByFilter: ["Daniel"], }, } as const; +const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; + +function getPersonTagKey(value: string): string { + return value.trim().toLocaleLowerCase(); +} + function matchesTakenByFilter(doseId: string, takenByFilter: Set | null): boolean { if (!takenByFilter) return true; const parts = doseId.split("-"); if (parts.length < 4) return false; const takenBy = parts.at(-1)?.trim(); if (!takenBy) return false; - return takenByFilter.has(takenBy); + return takenByFilter.has(getPersonTagKey(takenBy)); +} + +function getDoseScheduledAtMs(doseId: string): number | null { + const match = trackedDoseIdPattern.exec(doseId); + if (!match) { + return null; + } + + const scheduledAtMs = Number.parseInt(match[3], 10); + return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs; +} + +function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean { + if (!range) { + return true; + } + + if (timestampMs === null) { + return false; + } + + return timestampMs >= range.startMs && timestampMs < range.endMs; } const reportDataResponseSchema = { @@ -110,10 +180,17 @@ export async function reportRoutes(app: FastifyInstance) { if (!parsed.success) return reply.status(400).send(parsed.error.format()); const userId = await getUserId(req, reply); - const { medicationIds, takenByFilter } = parsed.data; + const { medicationIds, startDate, endDate, takenByFilter } = parsed.data; const normalizedTakenByFilter = takenByFilter?.length - ? new Set(takenByFilter.map((value) => value.trim())) + ? new Set(takenByFilter.map((value) => getPersonTagKey(value))) : null; + const dateRange = + startDate && endDate + ? { + startMs: new Date(startDate).getTime(), + endMs: new Date(endDate).getTime(), + } + : null; // Verify all medications belong to this user const userMeds = await db @@ -152,6 +229,7 @@ export async function reportRoutes(app: FastifyInstance) { const medId = Number.parseInt(dose.doseId.split("-")[0], 10); if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue; if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue; + if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue; if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, @@ -191,10 +269,15 @@ export async function reportRoutes(app: FastifyInstance) { const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube"; // Get refills for this medication scoped to the authenticated user. + const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)]; + if (dateRange) { + refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs))); + refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs))); + } const refills = await db .select() .from(refillHistory) - .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))); + .where(and(...refillFilters)); result[medId] = { dosesTaken: takenDoses.length, diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index b034d4b..5f9f67a 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; @@ -15,6 +15,7 @@ import { validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; +import { redactTokenForLog } from "../utils/redaction.js"; import { getAllTakenByForMedication, parseIntakesJson, @@ -28,6 +29,11 @@ import { const createShareSchema = z.object({ takenBy: z.string().min(1, "takenBy is required"), scheduleDays: z.number().int().min(1).max(365).default(30), + expiryDays: z + .union([z.number().int().min(1).max(365), z.null()]) + .optional() + .default(null), + allowJournalNotes: z.boolean().optional().default(false), }); const protectedEndpointSecurity: ReadonlyArray> = [ @@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray const shareTokenPattern = /^[a-f0-9]{16}$/; +function toIsoTimestamp(value: Date | string | number | null | undefined): string | null { + if (value == null) { + return null; + } + + try { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) { + const numericValue = typeof value === "number" ? value : Number(value); + const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue; + const date = new Date(timestampMs); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } catch { + return null; + } +} + +function resolveExpiryDate(expiryDays: number | null | undefined): Date | null { + if (expiryDays == null) { + return null; + } + + return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); +} + +function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean { + const isoValue = toIsoTimestamp(value); + return isoValue != null && new Date(isoValue).getTime() < Date.now(); +} + const createShareBodyOpenApiSchema = { type: "object", properties: { takenBy: { type: "string" }, scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 }, + allowJournalNotes: { type: "boolean", default: false }, + expiryDays: { + anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }], + default: null, + }, }, example: { takenBy: "Daniel", scheduleDays: 14, + allowJournalNotes: true, + expiryDays: 30, }, } as const; @@ -64,6 +114,7 @@ const shareReadResponseSchema = { stockCalculationMode: { type: "string", enum: ["automatic", "manual"] }, upcomingTodayOnly: { type: "boolean" }, shareScheduleTodayOnly: { type: "boolean" }, + allowJournalNotes: { type: "boolean" }, }, } as const; @@ -96,6 +147,37 @@ const shareOverviewResponseSchema = { }, } as const; +const shareListResponseSchema = { + type: "object", + properties: { + shareLinks: { + type: "array", + items: { + type: "object", + properties: { + token: { type: "string" }, + takenBy: { type: "string" }, + scheduleDays: { type: "integer" }, + createdAt: { type: "string", format: "date-time" }, + expiresAt: { type: ["string", "null"], format: "date-time" }, + allowJournalNotes: { type: "boolean" }, + shareUrl: { type: "string" }, + }, + required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"], + }, + }, + }, + required: ["shareLinks"], +} as const; + +const ownerTokenParamsSchema = { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], +} as const; + // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { @@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) { }, async (request, reply) => { const { token } = request.params; + const tokenRef = redactTokenForLog(token); // 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: token=${token}`); + request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`); return reply.status(404).send({ error: "Share link not found", code: "NOT_FOUND", @@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) { // Check if token has expired if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { request.log.warn( - `[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}` + `[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${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)); @@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) { takenBy: share.takenBy, sharedBy: owner?.username ?? null, scheduleDays: share.scheduleDays, + allowJournalNotes: share.allowJournalNotes ?? false, medications: medicationsWithBlisters, shareMedicationOverview, medicationOverview, @@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) { reply.header("Cache-Control", "no-store"); const { token } = request.params; + const tokenRef = redactTokenForLog(token); if (!shareTokenPattern.test(token)) { - request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`); + request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`); 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: token=${token}`); + request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`); return reply.status(404).send({ error: "not_found" }); } if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { request.log.warn( - `[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}` + `[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}` ); return reply.status(410).send({ error: "expired", @@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) { reused: { type: "boolean" }, token: { type: "string" }, shareUrl: { type: "string" }, + allowJournalNotes: { type: "boolean" }, expiresAt: { type: ["string", "null"] }, }, }, @@ -390,7 +476,8 @@ export async function shareRoutes(app: FastifyInstance) { }); } - const { takenBy, scheduleDays } = parsed.data; + const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data; + const expiresAt = resolveExpiryDate(expiryDays); // Check if user has medications for this takenBy (search in both medication-level and intake-level) const allMeds = await db @@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) { .where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy))); if (existingShare) { - await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id)); + const existingTokenRef = redactTokenForLog(existingShare.token); + await db + .update(shareTokens) + .set({ scheduleDays, expiresAt, allowJournalNotes }) + .where(eq(shareTokens.id, existingShare.id)); request.log.info( - `[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}` + `[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}` ); return { reused: true, token: existingShare.token, shareUrl: `/share/${existingShare.token}`, - expiresAt: null, + allowJournalNotes, + expiresAt: toIsoTimestamp(expiresAt), }; } const token = randomBytes(8).toString("hex"); + const tokenRef = redactTokenForLog(token); await db.insert(shareTokens).values({ userId, token, takenBy, scheduleDays, - expiresAt: null, + allowJournalNotes, + expiresAt, }); request.log.info( - `[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}` + `[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}` ); return { reused: false, token, shareUrl: `/share/${token}`, - expiresAt: null, + allowJournalNotes, + expiresAt: toIsoTimestamp(expiresAt), }; } ); + // --------------------------------------------------------------------------- + // GET /share - PROTECTED: List active share links for current owner + // --------------------------------------------------------------------------- + app.get( + "/share", + { + preHandler: requireAuth, + schema: { + tags: ["share"], + security: protectedEndpointSecurity, + response: { + 200: shareListResponseSchema, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const shares = await db + .select() + .from(shareTokens) + .where(eq(shareTokens.userId, userId)) + .orderBy(desc(shareTokens.createdAt)); + + return { + shareLinks: shares + .filter((share) => !isExpiredTimestamp(share.expiresAt)) + .map((share) => ({ + token: share.token, + takenBy: share.takenBy, + scheduleDays: share.scheduleDays, + createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(), + expiresAt: toIsoTimestamp(share.expiresAt), + allowJournalNotes: share.allowJournalNotes ?? false, + shareUrl: `/share/${share.token}`, + })), + }; + } + ); + + // --------------------------------------------------------------------------- + // DELETE /share/:token - PROTECTED: Revoke an existing share link + // --------------------------------------------------------------------------- + app.delete<{ Params: { token: string } }>( + "/share/:token", + { + preHandler: requireAuth, + schema: { + tags: ["share"], + security: protectedEndpointSecurity, + params: ownerTokenParamsSchema, + response: { + 204: { type: "null" }, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const { token } = request.params; + const tokenRef = redactTokenForLog(token); + + const [share] = await db + .select() + .from(shareTokens) + .where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token))); + + if (!share) { + return reply.status(404).send({ + error: "Share link not found", + code: "NOT_FOUND", + }); + } + + await db.delete(shareTokens).where(eq(shareTokens.id, share.id)); + + request.log.info( + `[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}` + ); + + return reply.status(204).send(); + } + ); + // --------------------------------------------------------------------------- // GET /share/people - PROTECTED: Get list of unique takenBy values // --------------------------------------------------------------------------- diff --git a/backend/src/services/intake-journal-export.ts b/backend/src/services/intake-journal-export.ts new file mode 100644 index 0000000..b5a96e4 --- /dev/null +++ b/backend/src/services/intake-journal-export.ts @@ -0,0 +1,90 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { intakeJournal } from "../db/schema.js"; + +type IntakeJournalWriteDatabase = Pick; + +export type IntakeJournalExportPayload = { + journalNote: string; + journalCreatedAt?: string | null; + journalUpdatedAt?: string | null; +}; + +function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null { + if (!value) { + return null; + } + + try { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } catch { + return null; + } +} + +function toDateOrFallback(value: string | null | undefined, fallback: Date): Date { + if (!value) { + return fallback; + } + + try { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? fallback : parsed; + } catch { + return fallback; + } +} + +export async function listIntakeJournalExportPayloadsForUser( + userId: number +): Promise> { + const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId)); + + return new Map( + rows.map((row) => [ + row.doseTrackingId, + { + journalNote: row.note, + journalCreatedAt: toIsoStringOrNull(row.createdAt), + journalUpdatedAt: toIsoStringOrNull(row.updatedAt), + }, + ]) + ); +} + +export async function restoreIntakeJournalForImportedDose(input: { + userId: number; + doseTrackingId: number; + medicationId: number; + scheduledFor: Date; + journalNote?: string | null; + journalCreatedAt?: string | null; + journalUpdatedAt?: string | null; + database?: IntakeJournalWriteDatabase; +}): Promise { + const normalizedNote = input.journalNote?.trim() ?? ""; + if (normalizedNote.length === 0) { + return false; + } + + const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor); + const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt); + const database = input.database ?? db; + + await database.insert(intakeJournal).values({ + userId: input.userId, + doseTrackingId: input.doseTrackingId, + medicationId: input.medicationId, + scheduledFor: input.scheduledFor, + note: normalizedNote, + createdAt, + updatedAt, + }); + + return true; +} diff --git a/backend/src/services/intake-journal-service.ts b/backend/src/services/intake-journal-service.ts new file mode 100644 index 0000000..4bef6ed --- /dev/null +++ b/backend/src/services/intake-journal-service.ts @@ -0,0 +1,332 @@ +import { and, desc, eq, gte, lte } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { doseTracking, intakeJournal, medications } from "../db/schema.js"; +import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js"; +import type { DoseTrackingSource } from "./dose-tracking-service.js"; + +const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; + +type ParsedDoseId = { + medicationId: number; + intakeIndex: number; + timestampMs: number; + personSuffix: string | null; +}; + +type MedicationTimingRow = { + id: number; + name: string | null; + genericName: string | null; + intakesJson: string; + usageJson: string; + everyJson: string; + startJson: string; + intakeRemindersEnabled: boolean; +}; + +export type ResolvedTrackedDoseEvent = { + doseTrackingId: number; + userId: number; + doseId: string; + medicationId: number; + medicationName: string; + scheduledFor: Date; + takenAt: Date; + markedBy: string | null; + takenSource: DoseTrackingSource; + dismissed: boolean; + personSuffix: string | null; +}; + +export type IntakeJournalEntry = typeof intakeJournal.$inferSelect; + +export type IntakeJournalHistoryEntry = { + id: number; + doseTrackingId: number; + doseId: string; + medicationId: number; + medicationName: string; + scheduledFor: Date; + takenAt: Date; + markedBy: string | null; + takenSource: DoseTrackingSource; + dismissed: boolean; + note: string; + createdAt: Date; + updatedAt: Date; +}; + +function parseDoseId(doseId: string): ParsedDoseId | null { + const match = doseIdPattern.exec(doseId); + if (!match) { + return null; + } + + const medicationId = Number.parseInt(match[1], 10); + const intakeIndex = Number.parseInt(match[2], 10); + const timestampMs = Number.parseInt(match[3], 10); + const personSuffix = match[4] ? match[4].trim() : null; + + if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) { + return null; + } + + return { + medicationId, + intakeIndex, + timestampMs, + personSuffix, + }; +} + +export function isTrackedDoseIdFormat(doseId: string): boolean { + return parseDoseId(doseId) !== null; +} + +function getMedicationDisplayName(medication: Pick): string { + const commercialName = medication.name?.trim() ?? ""; + if (commercialName.length > 0) { + return commercialName; + } + + const genericName = medication.genericName?.trim() ?? ""; + if (genericName.length > 0) { + return genericName; + } + + return `Medication #${medication.id}`; +} + +function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date { + const intakes = parseIntakesJson( + medication.intakesJson, + { + usageJson: medication.usageJson, + everyJson: medication.everyJson, + startJson: medication.startJson, + }, + medication.intakeRemindersEnabled + ); + const intake = intakes[parsedDose.intakeIndex]; + if (!intake) { + return new Date(parsedDose.timestampMs); + } + + const doseDate = new Date(parsedDose.timestampMs); + const intakeStart = parseLocalDateTime(intake.start); + + return new Date( + doseDate.getFullYear(), + doseDate.getMonth(), + doseDate.getDate(), + intakeStart.getHours(), + intakeStart.getMinutes(), + intakeStart.getSeconds(), + intakeStart.getMilliseconds() + ); +} + +export async function resolveTrackedDoseEventForUser(input: { + userId: number; + doseId: string; +}): Promise { + const parsedDose = parseDoseId(input.doseId); + if (!parsedDose) { + return null; + } + + const [event] = await db + .select({ + doseTrackingId: doseTracking.id, + userId: doseTracking.userId, + doseId: doseTracking.doseId, + takenAt: doseTracking.takenAt, + markedBy: doseTracking.markedBy, + takenSource: doseTracking.takenSource, + dismissed: doseTracking.dismissed, + medicationId: medications.id, + medicationName: medications.name, + medicationGenericName: medications.genericName, + intakesJson: medications.intakesJson, + usageJson: medications.usageJson, + everyJson: medications.everyJson, + startJson: medications.startJson, + intakeRemindersEnabled: medications.intakeRemindersEnabled, + }) + .from(doseTracking) + .innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId))) + .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId))) + .limit(1); + + if (!event) { + return null; + } + + const scheduledFor = resolveScheduledFor(parsedDose, { + id: event.medicationId, + name: event.medicationName, + genericName: event.medicationGenericName, + intakesJson: event.intakesJson, + usageJson: event.usageJson, + everyJson: event.everyJson, + startJson: event.startJson, + intakeRemindersEnabled: event.intakeRemindersEnabled ?? false, + }); + + return { + doseTrackingId: event.doseTrackingId, + userId: event.userId, + doseId: event.doseId, + medicationId: event.medicationId, + medicationName: getMedicationDisplayName({ + id: event.medicationId, + name: event.medicationName, + genericName: event.medicationGenericName, + }), + scheduledFor, + takenAt: event.takenAt, + markedBy: event.markedBy, + takenSource: event.takenSource as DoseTrackingSource, + dismissed: event.dismissed ?? false, + personSuffix: parsedDose.personSuffix, + }; +} + +export async function getIntakeJournalForDoseEvent(input: { + userId: number; + doseId: string; +}): Promise { + const event = await resolveTrackedDoseEventForUser(input); + if (!event) { + return null; + } + + const [journalEntry] = await db + .select() + .from(intakeJournal) + .where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId))) + .limit(1); + + return journalEntry ?? null; +} + +export async function upsertIntakeJournalForDoseEvent(input: { + userId: number; + doseId: string; + note: string; +}): Promise { + const normalizedNote = input.note.trim(); + if (normalizedNote.length === 0) { + await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId }); + return null; + } + + const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId }); + if (!event) { + return null; + } + + const now = new Date(); + + await db + .insert(intakeJournal) + .values({ + userId: input.userId, + doseTrackingId: event.doseTrackingId, + medicationId: event.medicationId, + scheduledFor: event.scheduledFor, + note: normalizedNote, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: intakeJournal.doseTrackingId, + set: { + userId: input.userId, + medicationId: event.medicationId, + note: normalizedNote, + updatedAt: now, + }, + }); + + return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId }); +} + +export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise { + const event = await resolveTrackedDoseEventForUser(input); + if (!event) { + return false; + } + + await db + .delete(intakeJournal) + .where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId))); + + return true; +} + +export async function listIntakeJournalEntriesForUser(input: { + userId: number; + medicationId?: number; + from?: Date; + to?: Date; + limit?: number; +}): Promise { + const filters = [eq(intakeJournal.userId, input.userId)]; + + if (typeof input.medicationId === "number") { + filters.push(eq(intakeJournal.medicationId, input.medicationId)); + } + + if (input.from) { + filters.push(gte(intakeJournal.scheduledFor, input.from)); + } + + if (input.to) { + filters.push(lte(intakeJournal.scheduledFor, input.to)); + } + + const rows = await db + .select({ + id: intakeJournal.id, + doseTrackingId: intakeJournal.doseTrackingId, + doseId: doseTracking.doseId, + medicationId: intakeJournal.medicationId, + medicationName: medications.name, + medicationGenericName: medications.genericName, + scheduledFor: intakeJournal.scheduledFor, + takenAt: doseTracking.takenAt, + markedBy: doseTracking.markedBy, + takenSource: doseTracking.takenSource, + dismissed: doseTracking.dismissed, + note: intakeJournal.note, + createdAt: intakeJournal.createdAt, + updatedAt: intakeJournal.updatedAt, + }) + .from(intakeJournal) + .innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId)) + .innerJoin(medications, eq(medications.id, intakeJournal.medicationId)) + .where(and(...filters)) + .orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt)) + .limit(input.limit ?? 100); + + return rows.map((row) => ({ + id: row.id, + doseTrackingId: row.doseTrackingId, + doseId: row.doseId, + medicationId: row.medicationId, + medicationName: getMedicationDisplayName({ + id: row.medicationId, + name: row.medicationName, + genericName: row.medicationGenericName, + }), + scheduledFor: row.scheduledFor, + takenAt: row.takenAt, + markedBy: row.markedBy, + takenSource: row.takenSource as DoseTrackingSource, + dismissed: row.dismissed ?? false, + note: row.note, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); +} diff --git a/backend/src/test/doses.test.ts b/backend/src/test/doses.test.ts index 9bb9d34..d3de992 100644 --- a/backend/src/test/doses.test.ts +++ b/backend/src/test/doses.test.ts @@ -51,6 +51,7 @@ const __dirname = dirname(__filename); const migrationsFolder = resolve(__dirname, "../../drizzle"); async function clearTables() { + await testClient.execute("DELETE FROM intake_journal"); await testClient.execute("DELETE FROM dose_tracking"); await testClient.execute("DELETE FROM share_tokens"); await testClient.execute("DELETE FROM api_keys"); @@ -78,20 +79,30 @@ async function insertMedication(options: { start?: string; }) { const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z"; + const takenBy = options.takenBy ?? []; + const intakeTakenBy = takenBy[0] ?? null; await testClient.execute({ sql: `INSERT INTO medications ( id, user_id, name, taken_by_json, medication_form, package_type, pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment, usage_json, every_json, start_json, intakes_json, intake_reminders_enabled - ) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`, + ) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`, args: [ options.id, options.userId, - JSON.stringify(options.takenBy ?? []), + JSON.stringify(takenBy), options.packCount ?? 1, options.looseTablets ?? 0, intakeStart, - "[]", + JSON.stringify([ + { + usage: 1, + every: 1, + start: intakeStart, + takenBy: intakeTakenBy, + intakeRemindersEnabled: false, + }, + ]), ], }); } @@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat }); } -async function _insertShareToken(userId: number, token: string, takenBy: string) { +async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) { await testClient.execute({ - sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)", - args: [userId, token, takenBy], + sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)", + args: [userId, token, takenBy, allowJournalNotes ? 1 : 0], }); } +function buildLocalDoseStart(hours = 8): string { + const start = new Date(); + start.setHours(hours, 0, 0, 0); + const year = start.getFullYear(); + const month = String(start.getMonth() + 1).padStart(2, "0"); + const day = String(start.getDate()).padStart(2, "0"); + const hour = String(start.getHours()).padStart(2, "0"); + + return `${year}-${month}-${day}T${hour}:00:00.000`; +} + async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) { const token = await app.jwt.sign({ sub: userId, username }); return `access_token=${token}`; @@ -458,6 +480,48 @@ describe("Dose Tracking API", () => { }); }); + describe("single-dose skip routes", () => { + it("marks a single owner dose as skipped through the frontend route", async () => { + const doseId = "1-0-1735344000000"; + + const response = await app.inject({ + method: "POST", + url: "/doses/skip", + headers: { cookie: cookieHeader }, + payload: { doseId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + const result = await testClient.execute({ + sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, doseId], + }); + expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]); + }); + + it("undoes a skipped-only owner dose through the frontend route", async () => { + const doseId = "1-0-1735344000000"; + await insertDose({ userId, doseId, dismissed: true, takenAt: null }); + + const response = await app.inject({ + method: "DELETE", + url: `/doses/skip/${encodeURIComponent(doseId)}`, + headers: { cookie: cookieHeader }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + const result = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, doseId], + }); + expect(Number(result.rows[0].count)).toBe(0); + }); + }); + 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 }); @@ -481,4 +545,174 @@ describe("Dose Tracking API", () => { ]); }); }); + + describe("shared single-dose skip routes", () => { + it("marks and undoes a visible shared dose as skipped", async () => { + const start = buildLocalDoseStart(); + await insertMedication({ + id: 6, + userId, + takenBy: ["Max"], + start, + }); + await _insertShareToken(userId, "share-skip-token", "Max", false); + + const doseId = `6-0-${new Date(start).getTime()}-Max`; + + const skipResponse = await app.inject({ + method: "POST", + url: "/share/share-skip-token/doses/skip", + payload: { doseId }, + }); + + expect(skipResponse.statusCode).toBe(200); + expect(skipResponse.json()).toEqual({ success: true }); + + const skippedRows = await testClient.execute({ + sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, doseId], + }); + expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]); + + const undoResponse = await app.inject({ + method: "DELETE", + url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`, + }); + + expect(undoResponse.statusCode).toBe(200); + expect(undoResponse.json()).toEqual({ success: true }); + + const remainingRows = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, doseId], + }); + expect(Number(remainingRows.rows[0].count)).toBe(0); + }); + }); + + describe("Shared journal notes", () => { + it("rejects shared journal access when the share link does not allow notes", async () => { + const start = buildLocalDoseStart(); + await insertMedication({ + id: 7, + userId, + takenBy: ["Max"], + start, + }); + await _insertShareToken(userId, "token-no-notes", "Max", false); + + const doseId = `7-0-${new Date(start).getTime()}-Max`; + await insertDose({ userId, doseId, markedBy: "Max" }); + + const response = await app.inject({ + method: "GET", + url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`, + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ + error: "Journal notes are not enabled for this share link", + code: "NOT_ENABLED", + }); + }); + + it("supports shared journal note read and save, but not implicit or explicit delete", async () => { + const start = buildLocalDoseStart(); + await insertMedication({ + id: 8, + userId, + takenBy: ["Max"], + start, + }); + await _insertShareToken(userId, "token-with-notes", "Max", true); + + const doseId = `8-0-${new Date(start).getTime()}-Max`; + await insertDose({ userId, doseId, markedBy: "Max" }); + + const initialResponse = await app.inject({ + method: "GET", + url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`, + }); + + expect(initialResponse.statusCode).toBe(200); + expect(initialResponse.json().entry).toEqual( + expect.objectContaining({ + doseId, + markedBy: "Max", + note: null, + }) + ); + + const initialDosesResponse = await app.inject({ + method: "GET", + url: "/share/token-with-notes/doses", + }); + + expect(initialDosesResponse.statusCode).toBe(200); + expect(initialDosesResponse.json().doses).toEqual([ + expect.objectContaining({ + doseId, + hasJournalNote: false, + }), + ]); + + const saveResponse = await app.inject({ + method: "PUT", + url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`, + payload: { note: "Shared note from Max" }, + }); + + expect(saveResponse.statusCode).toBe(200); + expect(saveResponse.json().entry).toEqual( + expect.objectContaining({ + doseId, + note: "Shared note from Max", + }) + ); + + const savedDosesResponse = await app.inject({ + method: "GET", + url: "/share/token-with-notes/doses", + }); + + expect(savedDosesResponse.statusCode).toBe(200); + expect(savedDosesResponse.json().doses).toEqual([ + expect.objectContaining({ + doseId, + hasJournalNote: true, + }), + ]); + + const blankSaveResponse = await app.inject({ + method: "PUT", + url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`, + payload: { note: " " }, + }); + + expect(blankSaveResponse.statusCode).toBe(400); + expect(blankSaveResponse.json()).toEqual({ + error: "Journal note cannot be empty", + code: "EMPTY_NOTE", + }); + + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`, + }); + + expect(deleteResponse.statusCode).toBe(403); + expect(deleteResponse.json()).toEqual({ + error: "Shared links cannot delete journal notes", + code: "DELETE_NOT_ALLOWED", + }); + + const journalRows = await testClient.execute({ + sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?", + args: [userId, 8], + }); + + expect(journalRows.rows).toHaveLength(1); + expect(journalRows.rows[0].note).toBe("Shared note from Max"); + }); + }); }); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 389af5a..bf3533e 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -3,6 +3,7 @@ * These tests import the actual route handlers for real coverage. */ +import { existsSync, unlinkSync } from "node:fs"; import cookie from "@fastify/cookie"; import fastifyMultipart from "@fastify/multipart"; import sensible from "@fastify/sensible"; @@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js"; import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Use vi.hoisted to create the db BEFORE mocks are set up -const { testClient, testDb } = vi.hoisted(() => { +const { testClient, testDb, testDbPath } = vi.hoisted(() => { // Dynamic import inside hoisted block const { createClient } = require("@libsql/client"); const { drizzle } = require("drizzle-orm/libsql"); - const client = createClient({ url: ":memory:" }); + const { tmpdir } = require("node:os"); + const { join } = require("node:path"); + const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`); + const client = createClient({ url: `file:${dbPath}` }); const db = drizzle(client); - return { testClient: client, testDb: db }; + return { testClient: client, testDb: db, testDbPath: dbPath }; }); // Mock modules using the hoisted db @@ -171,6 +175,7 @@ async function createSchema(client: Client) { token text NOT NULL UNIQUE, taken_by text NOT NULL, schedule_days integer NOT NULL DEFAULT 30, + allow_journal_notes integer NOT NULL DEFAULT 0, created_at integer NOT NULL DEFAULT (strftime('%s','now')), expires_at integer, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -184,6 +189,19 @@ async function createSchema(client: Client) { taken_source text NOT NULL DEFAULT 'manual', dismissed integer NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS intake_journal ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + dose_tracking_id integer NOT NULL UNIQUE, + medication_id integer NOT NULL, + scheduled_for integer NOT NULL, + note text NOT NULL, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE, + FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS refill_history ( id integer PRIMARY KEY AUTOINCREMENT, @@ -204,6 +222,7 @@ async function createSchema(client: Client) { } async function clearData(client: Client) { + await client.execute("DELETE FROM intake_journal"); await client.execute("DELETE FROM refill_history"); await client.execute("DELETE FROM dose_tracking"); await client.execute("DELETE FROM share_tokens"); @@ -222,10 +241,11 @@ async function _createUser(client: Client, username: string): Promise { } async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise { + const start = new Date(visibleDoseTimestampMs()).toISOString(); const result = await client.execute({ sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json) - VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`, - args: [userId, name, JSON.stringify(takenBy)], + VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`, + args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])], }); return result.rows[0].id as number; } @@ -237,6 +257,12 @@ async function createShareToken(client: Client, userId: number, takenBy: string, }); } +function visibleDoseTimestampMs(): number { + const doseDate = new Date(); + doseDate.setHours(8, 0, 0, 0); + return doseDate.getTime(); +} + // ============================================================================= // E2E Tests with Real Routes // ============================================================================= @@ -386,6 +412,11 @@ describe("E2E Tests with Real Routes", () => { afterAll(async () => { await app.close(); testClient.close(); + for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) { + if (existsSync(path)) { + unlinkSync(path); + } + } }); beforeEach(async () => { @@ -508,12 +539,12 @@ describe("E2E Tests with Real Routes", () => { }); it("should mark dose via share link using real route", async () => { - await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "test_share_token_456"; await createShareToken(testClient, userId, "Daniel", token); - const doseId = "1-0-1735344000000"; + const doseId = `${medId}-0-${visibleDoseTimestampMs()}`; const response = await app.inject({ method: "POST", url: `/share/${token}/doses`, @@ -1039,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => { }); it("should unmark dose via share link", async () => { - await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "test_delete_dose_token"; await createShareToken(testClient, userId, "Daniel", token); // First mark the dose - const doseId = "1-0-1735344000000"; + const doseId = `${medId}-0-${visibleDoseTimestampMs()}`; await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, args: [userId, doseId, "Daniel"], @@ -1089,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => { }); it("should return already marked message for duplicate dose", async () => { - await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "test_duplicate_token"; await createShareToken(testClient, userId, "Daniel", token); - const doseId = "1-0-1735344000000"; + const doseId = `${medId}-0-${visibleDoseTimestampMs()}`; // Mark the dose first time await app.inject({ @@ -1530,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => { // --------------------------------------------------------------------------- describe("Share token management", () => { + it("should list active share links for the owner", async () => { + await createMedication(testClient, userId, "Med1", ["Daniel"]); + + const createResponse = await app.inject({ + method: "POST", + url: "/share", + payload: { + takenBy: "Daniel", + scheduleDays: 90, + }, + }); + + expect(createResponse.statusCode).toBe(200); + + const listResponse = await app.inject({ + method: "GET", + url: "/share", + }); + + expect(listResponse.statusCode).toBe(200); + const data = listResponse.json(); + expect(data.shareLinks).toHaveLength(1); + expect(data.shareLinks[0].takenBy).toBe("Daniel"); + }); + + it("should revoke an active share link", async () => { + await createMedication(testClient, userId, "Med1", ["Daniel"]); + + const createResponse = await app.inject({ + method: "POST", + url: "/share", + payload: { + takenBy: "Daniel", + scheduleDays: 30, + }, + }); + + const { token } = createResponse.json(); + const revokeResponse = await app.inject({ + method: "DELETE", + url: `/share/${token}`, + }); + + expect(revokeResponse.statusCode).toBe(204); + + const publicResponse = await app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(publicResponse.statusCode).toBe(404); + }); + it("should create share token with custom scheduleDays", async () => { await createMedication(testClient, userId, "Med1", ["Daniel"]); @@ -1548,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => { expect(data.expiresAt).toBeDefined(); }); + it("should create a share token with an expiry and keep it in the active owner list", async () => { + await createMedication(testClient, userId, "Med1", ["Daniel"]); + + const createResponse = await app.inject({ + method: "POST", + url: "/share", + payload: { + takenBy: "Daniel", + scheduleDays: 30, + expiryDays: 7, + }, + }); + + expect(createResponse.statusCode).toBe(200); + const created = createResponse.json(); + expect(created.expiresAt).toBeTruthy(); + + const listResponse = await app.inject({ + method: "GET", + url: "/share", + }); + + expect(listResponse.statusCode).toBe(200); + const listData = listResponse.json(); + expect(listData.shareLinks).toHaveLength(1); + expect(listData.shareLinks[0].expiresAt).toBeTruthy(); + }); + it("should return validation error for invalid scheduleDays", async () => { await createMedication(testClient, userId, "Med1", ["Daniel"]); @@ -1685,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => { describe("Share token dose routes", () => { it("should get taken doses via share link", async () => { - await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "get-doses-token"; await createShareToken(testClient, userId, "Daniel", token); // Insert a dose directly + const doseId = `${medId}-0-${visibleDoseTimestampMs()}`; await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, - args: [userId, "1-0-1735344000000", "Daniel"], + args: [userId, doseId, "Daniel"], }); const response = await app.inject({ @@ -1703,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => { expect(response.statusCode).toBe(200); const data = response.json(); expect(data.doses).toHaveLength(1); - expect(data.doses[0].doseId).toBe("1-0-1735344000000"); + expect(data.doses[0].doseId).toBe(doseId); expect(data.doses[0].markedBy).toBe("Daniel"); }); @@ -3000,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => { }); describe("Real /import routes", () => { + it("should preview import data without mutating existing user data", async () => { + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Existing Med", + packCount: 2, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + const previewPayload = { + version: "1.6", + exportedAt: new Date().toISOString(), + includeSensitiveData: true, + medications: [ + { + _exportId: "med-1", + name: "Imported Med", + inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 }, + schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + ], + settings: { language: "en", stockCalculationMode: "automatic" }, + shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }], + doseHistory: [ + { + medicationRef: "med-1", + scheduleIndex: 0, + scheduledTime: "2025-01-01T08:00:00.000Z", + takenAt: "2025-01-01T08:03:00.000Z", + journalNote: "after breakfast", + }, + ], + }; + + const previewResponse = await app.inject({ + method: "POST", + url: "/import/preview", + payload: previewPayload, + }); + + expect(previewResponse.statusCode).toBe(200); + expect(previewResponse.json()).toMatchObject({ + success: true, + preview: { + version: "1.6", + includeSensitiveData: true, + incoming: { + medications: 1, + doseHistory: 1, + shareLinks: 1, + journalEntries: 1, + hasSettings: true, + }, + current: { + medications: 1, + hasSettings: false, + }, + warnings: { + replacesExistingData: true, + regeneratesShareLinks: true, + containsSensitiveData: true, + }, + }, + }); + + const medsResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(medsResponse.json()).toHaveLength(1); + expect(medsResponse.json()[0].name).toBe("Existing Med"); + }); + it("should import medications from export format", async () => { const importData = { version: "1.0", diff --git a/backend/src/test/intake-journal-routes.test.ts b/backend/src/test/intake-journal-routes.test.ts new file mode 100644 index 0000000..78143a8 --- /dev/null +++ b/backend/src/test/intake-journal-routes.test.ts @@ -0,0 +1,453 @@ +import { existsSync, unlinkSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import cookie from "@fastify/cookie"; +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"; +import { jwtPlugin } from "../plugins/jwt.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; + +const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const { tmpdir } = require("node:os"); + const { join } = require("node:path"); + const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`); + const client = createClient({ url: `file:${dbPath}` }); + const db = drizzle(client); + + return { + testClient: client, + testDb: db, + testDbPath: dbPath, + 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, + PUBLIC_APP_URL: "https://app.example.com", + }, + }; +}); + +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +const { exportRoutes } = await import("../routes/export.js"); +const { intakeJournalRoutes } = await import("../routes/intake-journal.js"); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +async function clearTables() { + await testClient.execute("DELETE FROM intake_journal"); + 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); +} + +async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) { + const token = await app.jwt.sign({ sub: userId, username }); + return `access_token=${token}`; +} + +async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) { + const start = options.start ?? "2026-02-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", + 1, + 1, + 10, + 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 seedTrackedDose(options: { + userId: number; + doseId: string; + takenAt: Date; + markedBy?: string | null; + dismissed?: boolean; +}) { + const result = await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + options.userId, + options.doseId, + Math.floor(options.takenAt.getTime() / 1000), + options.markedBy ?? null, + "manual", + options.dismissed ? 1 : 0, + ], + }); + + return Number(result.rows[0].id); +} + +describe("Intake journal routes", () => { + let app: FastifyInstance; + + beforeAll(async () => { + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); + await app.register(sensible); + await app.register(cookie, { secret: "test-cookie-secret" }); + await app.register(jwtPlugin, { + secret: "test-jwt-secret", + cookie: { cookieName: "access_token", signed: false }, + }); + await app.register(intakeJournalRoutes); + await app.register(exportRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) { + if (existsSync(path)) { + unlinkSync(path); + } + } + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await clearTables(); + }); + + it("keeps journal CRUD/history owner-scoped across route access", async () => { + const ownerId = await createUser("journal-owner"); + const otherId = await createUser("journal-other"); + const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner"); + const otherCookie = await buildSessionCookie(app, otherId, "journal-other"); + + const ownerStart = "2026-02-01T08:00:00.000Z"; + const otherStart = "2026-02-02T09:00:00.000Z"; + const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart }); + const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart }); + + const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`; + const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`; + await seedTrackedDose({ + userId: ownerId, + doseId: ownerDoseId, + takenAt: new Date("2026-02-01T08:05:00.000Z"), + markedBy: "Daniel", + }); + await seedTrackedDose({ + userId: otherId, + doseId: otherDoseId, + takenAt: new Date("2026-02-02T09:05:00.000Z"), + markedBy: "Maria", + }); + + const ownerPutResponse = await app.inject({ + method: "PUT", + url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`, + headers: { cookie: ownerCookie }, + payload: { note: "Took after breakfast." }, + }); + + expect(ownerPutResponse.statusCode).toBe(200); + expect(ownerPutResponse.json().entry).toEqual( + expect.objectContaining({ + doseId: ownerDoseId, + medicationId: ownerMedicationId, + scheduledFor: expect.stringContaining("T08:00:00"), + note: "Took after breakfast.", + }) + ); + + const otherPutResponse = await app.inject({ + method: "PUT", + url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`, + headers: { cookie: otherCookie }, + payload: { note: "Different owner note." }, + }); + + expect(otherPutResponse.statusCode).toBe(200); + + const ownerHistoryResponse = await app.inject({ + method: "GET", + url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`, + headers: { cookie: ownerCookie }, + }); + + expect(ownerHistoryResponse.statusCode).toBe(200); + expect(ownerHistoryResponse.json().entries).toEqual([ + expect.objectContaining({ + doseId: ownerDoseId, + medicationId: ownerMedicationId, + note: "Took after breakfast.", + markedBy: "Daniel", + }), + ]); + + const otherEventResponse = await app.inject({ + method: "GET", + url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`, + headers: { cookie: ownerCookie }, + }); + + expect(otherEventResponse.statusCode).toBe(404); + expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" }); + + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`, + headers: { cookie: ownerCookie }, + }); + + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toEqual({ success: true }); + + const emptyHistoryResponse = await app.inject({ + method: "GET", + url: "/intake-journal", + headers: { cookie: ownerCookie }, + }); + + expect(emptyHistoryResponse.statusCode).toBe(200); + expect(emptyHistoryResponse.json().entries).toEqual([]); + }); + + it("preserves journal metadata through authenticated export and import", async () => { + const userId = await createUser("journal-roundtrip"); + const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip"); + const start = "2026-02-03T07:30:00.000Z"; + const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start }); + const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`; + const doseTrackingId = await seedTrackedDose({ + userId, + doseId, + takenAt: new Date("2026-02-03T07:33:00.000Z"), + markedBy: "Daniel", + }); + + const createdAt = new Date("2026-02-03T07:40:00.000Z"); + const updatedAt = new Date("2026-02-03T07:50:00.000Z"); + await testClient.execute({ + sql: `INSERT INTO intake_journal ( + user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [ + userId, + doseTrackingId, + medicationId, + Math.floor(new Date(start).getTime() / 1000), + "Roundtrip journal note", + Math.floor(createdAt.getTime() / 1000), + Math.floor(updatedAt.getTime() / 1000), + ], + }); + + const exportResponse = await app.inject({ + method: "GET", + url: "/export", + headers: { cookie: sessionCookie }, + }); + + expect(exportResponse.statusCode).toBe(200); + const exportBody = exportResponse.json(); + expect(exportBody.doseHistory).toHaveLength(1); + expect(exportBody.doseHistory[0]).toEqual( + expect.objectContaining({ + journalNote: "Roundtrip journal note", + journalCreatedAt: createdAt.toISOString(), + journalUpdatedAt: updatedAt.toISOString(), + }) + ); + + const importResponse = await app.inject({ + method: "POST", + url: "/import", + headers: { cookie: sessionCookie }, + payload: exportBody, + }); + + expect(importResponse.statusCode).toBe(200); + + const reExportResponse = await app.inject({ + method: "GET", + url: "/export", + headers: { cookie: sessionCookie }, + }); + + expect(reExportResponse.statusCode).toBe(200); + expect(reExportResponse.json().doseHistory).toEqual([ + expect.objectContaining({ + journalNote: "Roundtrip journal note", + journalCreatedAt: createdAt.toISOString(), + journalUpdatedAt: updatedAt.toISOString(), + }), + ]); + + const restoredJournalRows = await testClient.execute({ + sql: "SELECT note FROM intake_journal WHERE user_id = ?", + args: [userId], + }); + + expect(restoredJournalRows.rows).toHaveLength(1); + expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note"); + }); + + it("preserves the shared journal-note permission through authenticated export and import", async () => { + const userId = await createUser("share-journal-roundtrip"); + const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip"); + + await testClient.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [userId, "share-journal-token", "Daniel", 14, 1, null], + }); + + const exportResponse = await app.inject({ + method: "GET", + url: "/export", + headers: { cookie: sessionCookie }, + }); + + expect(exportResponse.statusCode).toBe(200); + const exportBody = exportResponse.json(); + expect(exportBody.shareLinks).toEqual([ + expect.objectContaining({ + takenBy: "Daniel", + scheduleDays: 14, + allowJournalNotes: true, + regenerateToken: true, + }), + ]); + + const importResponse = await app.inject({ + method: "POST", + url: "/import", + headers: { cookie: sessionCookie }, + payload: exportBody, + }); + + expect(importResponse.statusCode).toBe(200); + + const shareRows = await testClient.execute({ + sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?", + args: [userId], + }); + + expect(shareRows.rows).toHaveLength(1); + expect(shareRows.rows[0]).toEqual( + expect.objectContaining({ + taken_by: "Daniel", + schedule_days: 14, + allow_journal_notes: 1, + }) + ); + expect(shareRows.rows[0].token).not.toBe("share-journal-token"); + }); + + it("keeps existing data when import fails inside the replacement transaction", async () => { + const userId = await createUser("import-rollback"); + const sessionCookie = await buildSessionCookie(app, userId, "import-rollback"); + await seedMedication({ userId, name: "Existing Rollback Med" }); + + const importResponse = await app.inject({ + method: "POST", + url: "/import", + headers: { cookie: sessionCookie }, + payload: { + version: "1.6", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "Imported Rollback Med", + inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 }, + schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }], + }, + ], + doseHistory: [ + { + medicationRef: "med-1", + scheduleIndex: 0, + scheduledTime: "2026-02-04T08:00:00.000Z", + takenAt: "not-a-date", + }, + ], + }, + }); + + expect(importResponse.statusCode).toBe(500); + + const medicationRows = await testClient.execute({ + sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name", + args: [userId], + }); + + expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]); + }); +}); diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 13172c2..0735db7 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -165,6 +165,7 @@ async function createSchema(client: Client) { token text NOT NULL UNIQUE, taken_by text NOT NULL, schedule_days integer NOT NULL DEFAULT 30, + allow_journal_notes integer NOT NULL DEFAULT 0, created_at integer NOT NULL DEFAULT (strftime('%s','now')), expires_at integer, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -195,6 +196,16 @@ async function clearData(client: Client) { await client.execute("DELETE FROM sqlite_sequence"); } +function visibleDoseTimestampMs(): number { + const doseDate = new Date(); + doseDate.setHours(8, 0, 0, 0); + return doseDate.getTime(); +} + +function visibleDoseStartIso(): string { + return new Date(visibleDoseTimestampMs()).toISOString(); +} + // ============================================================================= // Tests // ============================================================================= @@ -259,9 +270,11 @@ describe("Integration Tests", () => { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, - blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + looseTablets: 10, + blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }], }, }); + expect(createRes.statusCode, createRes.body).toBe(200); const medId = createRes.json().id; // Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10) @@ -617,9 +630,10 @@ describe("Integration Tests", () => { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, - blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }], }, }); + expect(createRes.statusCode, createRes.body).toBe(200); const medId = createRes.json().id; // Create share token for Daniel @@ -628,15 +642,17 @@ describe("Integration Tests", () => { url: "/share", payload: { takenBy: "Daniel", scheduleDays: 30 }, }); + expect(shareRes.statusCode, shareRes.body).toBe(200); const token = shareRes.json().token; // Mark dose via share link - const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`; - await app.inject({ + const doseId = `${medId}-0-${visibleDoseTimestampMs()}`; + const markRes = await app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); + expect(markRes.statusCode, markRes.body).toBe(200); // Verify markedBy is "Daniel" const result = await testClient.execute({ @@ -667,9 +683,10 @@ describe("Integration Tests", () => { payload: { name: "Vitamin D", takenBy: ["Anna"], - blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }], }, }); + expect(createRes.statusCode, createRes.body).toBe(200); const medId = createRes.json().id; // Create share token @@ -678,21 +695,24 @@ describe("Integration Tests", () => { url: "/share", payload: { takenBy: "Anna", scheduleDays: 30 }, }); + expect(shareRes.statusCode, shareRes.body).toBe(200); const token = shareRes.json().token; // Mark a dose - const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`; - await app.inject({ + const doseId = `${medId}-0-${visibleDoseTimestampMs()}`; + const markRes = await app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); + expect(markRes.statusCode, markRes.body).toBe(200); // Get shared schedule const scheduleRes = await app.inject({ method: "GET", url: `/share/${token}`, }); + expect(scheduleRes.statusCode, scheduleRes.body).toBe(200); const data = scheduleRes.json(); expect(data.takenBy).toBe("Anna"); @@ -781,7 +801,7 @@ describe("Integration Tests", () => { payload: { name: "Family Vitamins", takenBy: ["Daniel", "Anna", "Max"], - blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }], }, }); @@ -799,8 +819,8 @@ describe("Integration Tests", () => { }); // Both should succeed with different tokens - expect(danielShare.statusCode).toBe(200); - expect(annaShare.statusCode).toBe(200); + expect(danielShare.statusCode, danielShare.body).toBe(200); + expect(annaShare.statusCode, annaShare.body).toBe(200); expect(danielShare.json().token).not.toBe(annaShare.json().token); // Each share link should show correct person diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index e02e690..da54532 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -248,6 +248,32 @@ describe("Planner Routes", () => { expect(response.json()).toEqual({ error: "Missing planner data" }); }); + it("should reject request when no planner date range can be resolved", async () => { + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Missing planner date range" }); + }); + it("should return error when no notification channels configured", async () => { // User settings exist but email/shoutrrr disabled await testClient.execute({ @@ -282,6 +308,51 @@ describe("Planner Routes", () => { expect(response.json()).toEqual({ error: "No notification channels configured" }); }); + it("should accept startDate and endDate aliases for planner range", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-01-31T00:00:00.000Z", + language: "en", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Notification sent via email" }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + it("should send email successfully when SMTP is configured", async () => { // Set SMTP env vars process.env.SMTP_HOST = "smtp.test.com"; diff --git a/backend/src/test/redaction.test.ts b/backend/src/test/redaction.test.ts new file mode 100644 index 0000000..7148e41 --- /dev/null +++ b/backend/src/test/redaction.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { redactTokenForLog } from "../utils/redaction.js"; + +describe("redactTokenForLog", () => { + it("returns a stable short hash reference without exposing the raw token", () => { + const rawToken = "share-token-secret-value"; + const tokenRef = redactTokenForLog(rawToken); + + expect(tokenRef).toMatch(/^sha256:[a-f0-9]{12}$/); + expect(tokenRef).toBe(redactTokenForLog(rawToken)); + expect(tokenRef).not.toContain(rawToken); + }); + + it("normalizes empty tokens to a non-sensitive placeholder", () => { + expect(redactTokenForLog("")).toBe("missing"); + expect(redactTokenForLog(" ")).toBe("missing"); + expect(redactTokenForLog(null)).toBe("missing"); + expect(redactTokenForLog(undefined)).toBe("missing"); + }); +}); diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts index 0249a20..329a41b 100644 --- a/backend/src/test/routes-real.test.ts +++ b/backend/src/test/routes-real.test.ts @@ -1,3 +1,4 @@ +import { existsSync, unlinkSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { migrate } from "drizzle-orm/libsql/migrator"; @@ -6,10 +7,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { runAlterMigrations } from "../db/db-utils.js"; import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; -const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => { +const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); const { drizzle } = require("drizzle-orm/libsql"); - const client = createClient({ url: ":memory:" }); + const { tmpdir } = require("node:os"); + const { join } = require("node:path"); + const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`); + const client = createClient({ url: `file:${dbPath}` }); const db = drizzle(client); const env = { AUTH_ENABLED: false, @@ -22,6 +26,7 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois return { testClient: client, testDb: db, + testDbPath: dbPath, mockedEnv: env, nodemailerSendMail: vi.fn(), fetchMock: vi.fn(), @@ -121,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => { afterAll(async () => { await app.close(); testClient.close(); + if (existsSync(testDbPath)) { + unlinkSync(testDbPath); + } }); beforeEach(async () => { @@ -647,7 +655,7 @@ describe("Real route coverage: settings/export/report", () => { }); await testClient.execute({ sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", - args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1], + args: [1, `${medId}-0-1700000600000-alice`, 1700000600, 1], }); await testClient.execute({ sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", @@ -665,6 +673,66 @@ describe("Real route coverage: settings/export/report", () => { expect(body[medId].dosesSkipped).toBe(1); }); + it("POST /medications/report-data filters doses by scheduled doseId timestamp and refills by the same date window", async () => { + const medId = await seedMedication("Report Date Range Med"); + const windowStart = "2026-01-10T00:00:00.000Z"; + const windowEnd = "2026-01-20T00:00:00.000Z"; + + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [ + 1, + `${medId}-0-${Date.parse("2026-01-05T09:00:00.000Z")}-Daniel`, + Math.floor(Date.parse("2026-01-12T09:00:00.000Z") / 1000), + 0, + ], + }); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [ + 1, + `${medId}-0-${Date.parse("2026-01-15T09:00:00.000Z")}-Daniel`, + Math.floor(Date.parse("2026-01-25T09:00:00.000Z") / 1000), + 0, + ], + }); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [ + 1, + `${medId}-0-${Date.parse("2026-01-18T09:00:00.000Z")}-Daniel`, + Math.floor(Date.parse("2026-01-18T09:30:00.000Z") / 1000), + 1, + ], + }); + + await testClient.execute({ + sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)", + args: [medId, 1, 1, 0, 0, Math.floor(Date.parse("2026-01-12T08:00:00.000Z") / 1000)], + }); + await testClient.execute({ + sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)", + args: [medId, 1, 9, 0, 1, Math.floor(Date.parse("2026-01-22T08:00:00.000Z") / 1000)], + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [medId], startDate: windowStart, endDate: windowEnd }, + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body[medId]).toMatchObject({ + dosesTaken: 1, + dosesSkipped: 1, + }); + expect(body[medId].refills).toHaveLength(1); + expect(body[medId].refills[0]).toMatchObject({ + packsAdded: 1, + usedPrescription: false, + }); + }); + it("GET /export includes medications, settings, doseHistory and refillHistory", async () => { const medId = await seedMedication("Export Med"); await testClient.execute({ diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index ca5e290..70fc4a1 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -177,18 +177,26 @@ export interface CreateShareTokenOptions { token?: string; scheduleDays?: number; expiresAt?: number | null; + allowJournalNotes?: boolean; } /** * Create a test share token and return the token string */ export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise { - const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options; + const { + userId, + takenBy, + token = `test_token_${Date.now()}`, + scheduleDays = 30, + expiresAt = null, + allowJournalNotes = false, + } = options; await client.execute({ - sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) - VALUES (?, ?, ?, ?, ?)`, - args: [userId, token, takenBy, scheduleDays, expiresAt], + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0], }); return token; diff --git a/backend/src/utils/local-date-time.ts b/backend/src/utils/local-date-time.ts new file mode 100644 index 0000000..a58b1a9 --- /dev/null +++ b/backend/src/utils/local-date-time.ts @@ -0,0 +1,17 @@ +function pad(value: number, size = 2): string { + return String(value).padStart(size, "0"); +} + +export function toLocalDateTimeOffsetString(value: Date): string { + const offsetMinutes = -value.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffsetMinutes = Math.abs(offsetMinutes); + const offsetHours = Math.floor(absoluteOffsetMinutes / 60); + const offsetRemainderMinutes = absoluteOffsetMinutes % 60; + + return [ + `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`, + `T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`, + `${sign}${pad(offsetHours)}:${pad(offsetRemainderMinutes)}`, + ].join(""); +} diff --git a/backend/src/utils/redaction.ts b/backend/src/utils/redaction.ts new file mode 100644 index 0000000..fdae0bf --- /dev/null +++ b/backend/src/utils/redaction.ts @@ -0,0 +1,10 @@ +import { createHash } from "node:crypto"; + +export function redactTokenForLog(token: string | null | undefined): string { + const normalizedToken = token?.trim(); + if (!normalizedToken) { + return "missing"; + } + + return `sha256:${createHash("sha256").update(normalizedToken, "utf8").digest("hex").slice(0, 12)}`; +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index bd731c6..b6df5a6 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -7,11 +7,8 @@ export default defineConfig({ include: ["src/**/*.test.ts"], setupFiles: ["src/test/setup.ts"], // Run tests sequentially to avoid DB conflicts - poolOptions: { - threads: { - singleThread: true, - }, - }, + fileParallelism: false, + maxWorkers: 1, // Timeout for longer integration tests testTimeout: 10000, coverage: {