diff --git a/.env.example b/.env.example index 2c06b1f..95f1aa6 100644 --- a/.env.example +++ b/.env.example @@ -118,4 +118,5 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning # UI defaults # DEFAULT_LANGUAGE=en # en or de -# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual \ No newline at end of file +# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual +# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links \ No newline at end of file diff --git a/backend/drizzle/0006_add_stock_reminder_tracking.sql b/backend/drizzle/0006_add_stock_reminder_tracking.sql new file mode 100644 index 0000000..3fbad66 --- /dev/null +++ b/backend/drizzle/0006_add_stock_reminder_tracking.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user_settings` ADD `last_stock_reminder_sent` text;--> statement-breakpoint +ALTER TABLE `user_settings` ADD `last_stock_reminder_channel` text;--> statement-breakpoint +ALTER TABLE `user_settings` ADD `last_stock_reminder_med_names` text; \ No newline at end of file diff --git a/backend/drizzle/0007_add_share_stock_status.sql b/backend/drizzle/0007_add_share_stock_status.sql new file mode 100644 index 0000000..b18941b --- /dev/null +++ b/backend/drizzle/0007_add_share_stock_status.sql @@ -0,0 +1 @@ +ALTER TABLE `user_settings` ADD `share_stock_status` integer DEFAULT true NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0006_snapshot.json b/backend/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..7c2e6b8 --- /dev/null +++ b/backend/drizzle/meta/0006_snapshot.json @@ -0,0 +1,907 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7cd75e33-b3d8-4930-a60b-2a0a9f644c6d", + "prevId": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0", + "tables": { + "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 + }, + "dismissed": { + "name": "dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "dose_tracking_user_id_users_id_fk": { + "name": "dose_tracking_user_id_users_id_fk", + "tableFrom": "dose_tracking", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "medications": { + "name": "medications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generic_name": { + "name": "generic_name", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "taken_by_json": { + "name": "taken_by_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "package_type": { + "name": "package_type", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blister'" + }, + "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 + }, + "dismissed_until": { + "name": "dismissed_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "medications_user_id_users_id_fk": { + "name": "medications_user_id_users_id_fk", + "tableFrom": "medications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refill_history": { + "name": "refill_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "medication_id": { + "name": "medication_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "packs_added": { + "name": "packs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "loose_pills_added": { + "name": "loose_pills_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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 + }, + "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 + }, + "reminder_days_before": { + "name": "reminder_days_before", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "repeat_daily_reminders": { + "name": "repeat_daily_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "skip_reminders_for_taken_doses": { + "name": "skip_reminders_for_taken_doses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "repeat_reminders_enabled": { + "name": "repeat_reminders_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reminder_repeat_interval_minutes": { + "name": "reminder_repeat_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "max_nagging_reminders": { + "name": "max_nagging_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "low_stock_days": { + "name": "low_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "normal_stock_days": { + "name": "normal_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "high_stock_days": { + "name": "high_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 180 + }, + "expiry_warning_days": { + "name": "expiry_warning_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "language": { + "name": "language", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "stock_calculation_mode": { + "name": "stock_calculation_mode", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'automatic'" + }, + "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 + }, + "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/0007_snapshot.json b/backend/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..06f403b --- /dev/null +++ b/backend/drizzle/meta/0007_snapshot.json @@ -0,0 +1,915 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b6f1ee4b-cc31-4060-a4d4-bcd4fdc5bd87", + "prevId": "7cd75e33-b3d8-4930-a60b-2a0a9f644c6d", + "tables": { + "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 + }, + "dismissed": { + "name": "dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "dose_tracking_user_id_users_id_fk": { + "name": "dose_tracking_user_id_users_id_fk", + "tableFrom": "dose_tracking", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "medications": { + "name": "medications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generic_name": { + "name": "generic_name", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "taken_by_json": { + "name": "taken_by_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "package_type": { + "name": "package_type", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blister'" + }, + "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 + }, + "dismissed_until": { + "name": "dismissed_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "medications_user_id_users_id_fk": { + "name": "medications_user_id_users_id_fk", + "tableFrom": "medications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refill_history": { + "name": "refill_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "medication_id": { + "name": "medication_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "packs_added": { + "name": "packs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "loose_pills_added": { + "name": "loose_pills_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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 + }, + "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 + }, + "reminder_days_before": { + "name": "reminder_days_before", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "repeat_daily_reminders": { + "name": "repeat_daily_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "skip_reminders_for_taken_doses": { + "name": "skip_reminders_for_taken_doses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "repeat_reminders_enabled": { + "name": "repeat_reminders_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reminder_repeat_interval_minutes": { + "name": "reminder_repeat_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "max_nagging_reminders": { + "name": "max_nagging_reminders", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "low_stock_days": { + "name": "low_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "normal_stock_days": { + "name": "normal_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "high_stock_days": { + "name": "high_stock_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 180 + }, + "expiry_warning_days": { + "name": "expiry_warning_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "language": { + "name": "language", + "type": "text(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "stock_calculation_mode": { + "name": "stock_calculation_mode", + "type": "text(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'automatic'" + }, + "share_stock_status": { + "name": "share_stock_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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 + }, + "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 eafffa4..6bf6897 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1769893708813, "tag": "0005_add_intakes_json", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1770626907896, + "tag": "0006_add_stock_reminder_tracking", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1770659669121, + "tag": "0007_add_share_stock_status", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 3f348fb..e80adca 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-backend", - "version": "1.8.8", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-backend", - "version": "1.8.8", + "version": "1.9.0", "dependencies": { "@fastify/cookie": "^10.0.1", "@fastify/cors": "^10.0.1", diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index 29bc1fd..429de47 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -88,10 +88,10 @@ export async function runDrizzleMigrations( await migrate(database, { migrationsFolder }); return { success: true }; } catch (err: any) { - // If the error is "duplicate column", it means the schema is already up-to-date - // This happens when ALTER migrations in client.ts have already added the columns - // We consider this a success with a warning, not a failure - if (err.message?.includes("duplicate column")) { + // If the error is about existing schema objects, the DB is already up-to-date + // This happens when ALTER migrations in client.ts have already added the columns, + // or when tables were created before drizzle migrations were introduced + if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) { return { success: true, warning: `Schema already up-to-date: ${err.message}` }; } return { success: false, error: err.message }; @@ -129,6 +129,12 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`, // Added for intake-level takenBy: unified intakes structure `ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`, + // Added for separate stock reminder tracking + `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`, + `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`, + `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`, + // Added for share stock visibility toggle + `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, ]; for (const sql of alterMigrations) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 285cc92..2882721 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -86,12 +86,18 @@ export const userSettings = sqliteTable("user_settings", { language: text("language", { length: 10 }).notNull().default("en"), // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), - // Last notification tracking + // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users + shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true), + // Last notification tracking (intake reminders) lastAutoEmailSent: text("last_auto_email_sent"), lastNotificationType: text("last_notification_type"), lastNotificationChannel: text("last_notification_channel"), lastReminderMedName: text("last_reminder_med_name"), lastReminderTakenBy: text("last_reminder_taken_by"), + // Last stock reminder tracking (separate from intake) + lastStockReminderSent: text("last_stock_reminder_sent"), + lastStockReminderChannel: text("last_stock_reminder_channel"), + lastStockReminderMedNames: text("last_stock_reminder_med_names"), // Timestamps updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts index de885c1..9f862aa 100644 --- a/backend/src/i18n/translations.ts +++ b/backend/src/i18n/translations.ts @@ -64,20 +64,29 @@ function getRegionFromTimezone(): string | undefined { } type TranslationKeys = { - // Stock reminder email + // Stock reminder (shared across email + push) stockReminder: { subject: string; title: string; description: string; + descriptionEmpty: string; + descriptionMixed: string; alertSingle: string; alertMultiple: string; + alertEmptySingle: string; + alertEmptyMultiple: string; + alertLowSingle: string; + alertLowMultiple: string; + alertLowStockSingle: string; + alertLowStockMultiple: string; + descriptionLow: string; tableHeaders: { medication: string; pills: string; days: string; runsOut: string; }; - footer: string; + now: string; repeatDailyNote: string; }; // Intake reminder email @@ -94,7 +103,6 @@ type TranslationKeys = { }; pills: string; takenBy: string; - footer: string; }; // Push notifications push: { @@ -107,35 +115,68 @@ type TranslationKeys = { repeatDailyNote: string; empty: string; low: string; + critical: string; + lowStock: string; reorderNow: string; emptySection: string; lowSection: string; + criticalSection: string; + lowStockSection: string; + }; + // Demand calculator email + demandCalculator: { + subject: string; + title: string; + description: string; + summaryOutOfStock: string; + summaryAllOk: string; + tableHeaders: { + medication: string; + usage: string; + needed: string; + available: string; + status: string; + }; + statusEnough: string; + statusEmpty: string; }; // Common common: { pill: string; pills: string; + blister: string; + blisters: string; day: string; days: string; soon: string; + footer: string; }; }; const translations: Record = { en: { stockReminder: { - subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Low", + subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Critically Low", title: "⚠️ MedAssist-ng - Automatic Reorder Reminder", - description: "The following medications are running low and need to be reordered:", - alertSingle: "⚠️ 1 medication running low!", - alertMultiple: "⚠️ {count} medications running low!", + description: "The following medications are running critically low and need to be reordered:", + descriptionEmpty: "The following medications are empty and need to be reordered immediately:", + descriptionMixed: "The following medications need to be reordered:", + alertSingle: "⚠️ 1 medication running critically low!", + alertMultiple: "⚠️ {count} medications running critically low!", + alertEmptySingle: "🚨 1 medication empty - reorder immediately!", + alertEmptyMultiple: "🚨 {count} medications empty - reorder immediately!", + alertLowSingle: "⚠️ 1 medication running critically low", + alertLowMultiple: "⚠️ {count} medications running critically low", + alertLowStockSingle: "⚠️ 1 medication running low", + alertLowStockMultiple: "⚠️ {count} medications running low", + descriptionLow: "The following medications are running low and should be reordered soon:", tableHeaders: { medication: "Medication", pills: "Pills", days: "Days", runsOut: "Runs Out", }, - footer: "🤖 Automatic reminder from MedAssist-ng", + now: "NOW", repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.", }, intakeReminder: { @@ -151,44 +192,75 @@ const translations: Record = { }, pills: "pills", takenBy: "for {name}", - footer: "🤖 Automatic reminder from MedAssist-ng", }, push: { - stockTitle: "MedAssist-ng: 1 Medication Running Low", - stockTitleMultiple: "MedAssist-ng: {count} Medications Running Low", + stockTitle: "MedAssist-ng: 1 Medication Running Critically Low", + stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low", intakeTitle: "💊 Reminder: Medication intake in {minutes} min", pillsLeft: "{count} pills", daysLeft: "{count} days left", pillsAt: "{count} pills at {time}", repeatDailyNote: "(Daily reminder enabled)", empty: "Empty", - low: "Low", + low: "Critical", + critical: "Critical", + lowStock: "Low", reorderNow: "Reorder Now!", - emptySection: "EMPTY (reorder immediately)", - lowSection: "RUNNING LOW (reorder soon)", + emptySection: "Empty (reorder immediately)", + lowSection: "Running critically low", + criticalSection: "Running critically low", + lowStockSection: "Running low", + }, + demandCalculator: { + subject: "MedAssist-ng - Supply Overview ({from} - {until})", + title: "MedAssist-ng - Demand Calculator", + description: "Supply overview from {from} to {until}", + summaryOutOfStock: "⚠️ {count} medication{s} will be out of stock during this period.", + summaryAllOk: "✓ All medications have sufficient supply for this period.", + tableHeaders: { + medication: "Medication", + usage: "Usage", + needed: "Blisters needed", + available: "Available", + status: "Status", + }, + statusEnough: "✓ Enough", + statusEmpty: "✗ Empty", }, common: { pill: "pill", pills: "pills", + blister: "blister", + blisters: "blisters", day: "day", days: "days", soon: "soon", + footer: "🤖 Sent from MedAssist-ng", }, }, de: { stockReminder: { - subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} wird knapp", + subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} kritisch niedrig", title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung", - description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:", - alertSingle: "⚠️ 1 Medikament wird knapp!", - alertMultiple: "⚠️ {count} Medikamente werden knapp!", + description: "Die folgenden Medikamente sind kritisch niedrig und sollten nachbestellt werden:", + descriptionEmpty: "Die folgenden Medikamente sind leer und müssen sofort nachbestellt werden:", + descriptionMixed: "Die folgenden Medikamente müssen nachbestellt werden:", + alertSingle: "⚠️ 1 Medikament kritisch niedrig!", + alertMultiple: "⚠️ {count} Medikamente kritisch niedrig!", + alertEmptySingle: "🚨 1 Medikament leer - sofort nachbestellen!", + alertEmptyMultiple: "🚨 {count} Medikamente leer - sofort nachbestellen!", + alertLowSingle: "⚠️ 1 Medikament kritisch niedrig", + alertLowMultiple: "⚠️ {count} Medikamente kritisch niedrig", + alertLowStockSingle: "⚠️ 1 Medikament niedrig", + alertLowStockMultiple: "⚠️ {count} Medikamente niedrig", + descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:", tableHeaders: { medication: "Medikament", pills: "Tabletten", days: "Tage", runsOut: "Aufgebraucht", }, - footer: "🤖 Automatische Erinnerung von MedAssist-ng", + now: "JETZT", repeatDailyNote: "Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.", }, @@ -205,28 +277,50 @@ const translations: Record = { }, pills: "Tabletten", takenBy: "für {name}", - footer: "🤖 Automatische Erinnerung von MedAssist-ng", }, push: { - stockTitle: "MedAssist-ng: 1 Medikament wird knapp", - stockTitleMultiple: "MedAssist-ng: {count} Medikamente werden knapp", + stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig", + stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig", intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.", pillsLeft: "{count} Tabletten", daysLeft: "{count} Tage übrig", pillsAt: "{count} Tabletten um {time}", repeatDailyNote: "(Tägliche Erinnerung aktiviert)", empty: "Leer", - low: "Knapp", + low: "Kritisch", + critical: "Kritisch", + lowStock: "Niedrig", reorderNow: "Jetzt nachbestellen!", - emptySection: "LEER (sofort nachbestellen)", - lowSection: "WIRD KNAPP (bald nachbestellen)", + emptySection: "Leer (sofort nachbestellen)", + lowSection: "Kritisch niedrig", + criticalSection: "Kritisch niedrig", + lowStockSection: "Niedrig", + }, + demandCalculator: { + subject: "MedAssist-ng - Bestandsübersicht ({from} - {until})", + title: "MedAssist-ng - Bedarfsrechner", + description: "Bestandsübersicht von {from} bis {until}", + summaryOutOfStock: "⚠️ {count} Medikament{e} wird im Zeitraum nicht ausreichen.", + summaryAllOk: "✓ Alle Medikamente reichen für diesen Zeitraum.", + tableHeaders: { + medication: "Medikament", + usage: "Verbrauch", + needed: "Blister benötigt", + available: "Verfügbar", + status: "Status", + }, + statusEnough: "✓ Ausreichend", + statusEmpty: "✗ Leer", }, common: { pill: "Tablette", pills: "Tabletten", + blister: "Blister", + blisters: "Blister", day: "Tag", days: "Tage", soon: "bald", + footer: "🤖 Gesendet von MedAssist-ng", }, }, }; @@ -264,3 +358,38 @@ export function getDateLocale(language: Language): string { return "en-US"; } } + +/** + * Get the app URL from the first CORS_ORIGINS entry. + * Falls back to empty string if not set. + */ +export function getAppUrl(): string { + const origins = process.env.CORS_ORIGINS || ""; + return origins.split(",")[0]?.trim() || ""; +} + +/** + * Get the unified footer as HTML with MedAssist-ng as a link to the instance. + * @param variant - 'planner' uses the Medication Planner footer text + */ +export function getFooterHtml(language: Language): string { + const tr = getTranslations(language); + const appUrl = getAppUrl(); + const appName = appUrl + ? `MedAssist-ng` + : "MedAssist-ng"; + return tr.common.footer.replace("MedAssist-ng", appName); +} + +/** + * Get the unified footer as plain text. + * @param variant - 'planner' uses the Medication Planner footer text + */ +export function getFooterPlain(language: Language): string { + const tr = getTranslations(language); + const appUrl = getAppUrl(); + if (appUrl) { + return `${tr.common.footer} (${appUrl})`; + } + return tr.common.footer; +} diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 9e81bcf..8330d76 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -1,6 +1,13 @@ import type { FastifyInstance, FastifyRequest } from "fastify"; import nodemailer from "nodemailer"; -import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js"; +import { + getDateLocale, + getFooterHtml, + getFooterPlain, + getTranslations, + type Language, + t, +} from "../i18n/translations.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; @@ -29,6 +36,7 @@ type PlannerRow = { fullBlisters: number; loosePills: number; enough: boolean; + packageType?: string; }; type SendEmailBody = { @@ -44,6 +52,7 @@ type LowStockItem = { medsLeft: number; daysLeft: number | null; depletionDate: string | null; + isCritical?: boolean; }; type ReminderEmailBody = { @@ -68,32 +77,28 @@ export async function plannerRoutes(app: FastifyInstance) { return authUser.id; } + // Demand calculator notification (supports email and push) app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => { const { email, from, until, rows, language: bodyLanguage } = request.body; - if (!email || !rows || rows.length === 0) { - return reply.status(400).send({ error: "Missing email or planner data" }); + if (!rows || rows.length === 0) { + return reply.status(400).send({ error: "Missing planner data" }); } - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - - if (!smtpHost || !smtpUser) { - return reply.status(400).send({ error: "SMTP not configured" }); - } + // Load user settings for notification channels + const userId = await getUserId(request); + const userSettings = await loadUserSettings(userId); + const notificationSettings = { + emailEnabled: userSettings.emailEnabled, + shoutrrrEnabled: userSettings.shoutrrrEnabled, + shoutrrrUrl: userSettings.shoutrrrUrl || "", + }; // Get locale from user settings or use the language passed in the body - let language: Language = bodyLanguage || "en"; - const authUser = request.user as unknown as AuthUser | null; - if (authUser?.id) { - const userSettings = await loadUserSettings(authUser.id); - language = userSettings.language; - } + const language: Language = (userSettings.language as Language) || bodyLanguage || "en"; const locale = getDateLocale(language); + const tr = getTranslations(language); + const dc = tr.demandCalculator; // Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe const fromDate = escapeHtml( @@ -111,47 +116,93 @@ export async function plannerRoutes(app: FastifyInstance) { }) ); - // Build HTML table with horizontal scroll for mobile - // Escape/coerce all user-provided values to prevent XSS - const tableRows = rows - .map((row) => { - const safeName = escapeHtml(row.medicationName); - const safeTotalPills = Number(row.totalPills) || 0; - const safePlannerUsage = Number(row.plannerUsage) || 0; - const safeBlistersNeeded = Number(row.blistersNeeded) || 0; - const safeBlisterSize = Number(row.blisterSize) || 0; - const safeFullBlisters = Number(row.fullBlisters) || 0; - const safeLoosePills = Number(row.loosePills) || 0; - return ` + const outOfStockCount = rows.filter((r) => !r.enough).length; + const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk; + + // Build plain text (shared between email and push) + const plainText = `${dc.title} +${t(dc.description, { from: fromDate, until: untilDate })} + +${summaryText} + +${rows + .map((r) => { + const isBottle = r.packageType === "bottle"; + const usage = `${r.plannerUsage} ${tr.common.pills}`; + const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`; + const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10; + const available = isBottle + ? `${loosePills} ${tr.common.pills}` + : `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`; + const status = r.enough ? dc.statusEnough : dc.statusEmpty; + return `${r.medicationName}: ${usage}, ${needed}, ${available} - ${status}`; + }) + .join("\n")} + +--- +${getFooterPlain(language)}`; + + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; + + // Send email if enabled + if (notificationSettings.emailEnabled && email) { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + if (smtpHost && smtpUser) { + // Build HTML table with horizontal scroll for mobile + // Escape/coerce all user-provided values to prevent XSS + const tableRows = rows + .map((row) => { + const safeName = escapeHtml(row.medicationName); + const safePlannerUsage = Number(row.plannerUsage) || 0; + const safeBlistersNeeded = Number(row.blistersNeeded) || 0; + const safeBlisterSize = Number(row.blisterSize) || 0; + const safeFullBlisters = Number(row.fullBlisters) || 0; + const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10; + const isBottle = row.packageType === "bottle"; + + // "Blisters needed" column: dash for bottles + const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`; + + // "Available" column: match frontend format + let availableCell: string; + if (isBottle) { + availableCell = `${safeLoosePills} ${tr.common.pills}`; + } else { + availableCell = `${safeFullBlisters} ${tr.common.blisters}`; + if (safeLoosePills > 0) { + availableCell += ` + ${safeLoosePills} ${tr.common.pills}`; + } + } + + return ` ${safeName} - ${safeTotalPills} - ${safePlannerUsage} - ${safeBlistersNeeded} × ${safeBlisterSize} - ${safeFullBlisters}${safeLoosePills > 0 ? ` (+${safeLoosePills})` : ""} + ${safePlannerUsage} ${tr.common.pills} + ${neededCell} + ${availableCell} - ${row.enough ? "✓ OK" : "✗ Out of Stock"} + ${row.enough ? dc.statusEnough : dc.statusEmpty} `; - }) - .join(""); + }) + .join(""); - const outOfStockCount = rows.filter((r) => !r.enough).length; - const summaryText = - outOfStockCount > 0 - ? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.` - : "✓ All medications have sufficient supply for this period."; - - const html = ` + const html = `
-

MedAssist-ng - Demand Calculator

-

Supply overview from ${fromDate} to ${untilDate}

+

${dc.title}

+

${t(dc.description, { from: `${fromDate}`, until: `${untilDate}` })}

- Medication - Stock - Usage - Needed - Available - Status + ${dc.tableHeaders.medication} + ${dc.tableHeaders.usage} + ${dc.tableHeaders.needed} + ${dc.tableHeaders.available} + ${dc.tableHeaders.status} @@ -182,44 +232,76 @@ export async function plannerRoutes(app: FastifyInstance) {

-

Sent from MedAssist-ng Medication Planner

+

${getFooterHtml(language)}

`; - const plainText = `MedAssist-ng - Demand Calculator -Supply overview from ${fromDate} to ${untilDate} + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); -${summaryText} + await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: t(dc.subject, { from: fromDate, until: untilDate }), + text: plainText, + html, + }); -${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.fullBlisters} blisters available${r.loosePills > 0 ? ` (+${r.loosePills} loose)` : ""} (${r.blistersNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")} + results.email = true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Email: ${errorMessage}`); + } + } + } ---- -Sent from MedAssist-ng Medication Planner`; + // Send push notification if enabled + if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { + const pushTitle = t(dc.subject, { from: fromDate, until: untilDate }); + const pushMessage = `${summaryText}\n\n${rows + .map((r) => { + const usage = `${r.plannerUsage} ${tr.common.pills}`; + const status = r.enough ? dc.statusEnough : dc.statusEmpty; + return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`; + }) + .join("\n")}\n\n---\n${getFooterPlain(language)}`; - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, + try { + const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, pushTitle, pushMessage); + if (pushResult.success) { + results.push = true; + } else { + results.errors.push(`Push: ${pushResult.error}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Push: ${errorMessage}`); + } + } + + // Build response message + const sentChannels: string[] = []; + if (results.email) sentChannels.push("email"); + if (results.push) sentChannels.push("push"); + + if (sentChannels.length > 0) { + return reply.send({ + success: true, + message: `Notification sent via ${sentChannels.join(" and ")}`, }); - - await transporter.sendMail({ - from: smtpFrom, - to: email, - subject: `MedAssist-ng - Supply Overview (${fromDate} - ${untilDate})`, - text: plainText, - html, - }); - - return reply.send({ success: true, message: "Email sent successfully" }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` }); + } else if (results.errors.length > 0) { + return reply.status(500).send({ error: results.errors.join("; ") }); + } else { + return reply.status(400).send({ error: "No notification channels configured" }); } }); @@ -240,11 +322,66 @@ Sent from MedAssist-ng Medication Planner`; shoutrrrUrl: userSettings.shoutrrrUrl || "", }; + // Get translations based on user language + const language = (userSettings.language as Language) || "en"; + const tr = getTranslations(language); + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; - // Separate empty from low stock medications + // Separate into 3 categories: empty, critical, and low stock const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0); - const lowMeds = lowStock.filter((r) => r.medsLeft > 0); + const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false); + const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false); + + // Build shared notification content (method-agnostic) + const titleParts: string[] = []; + if (emptyMeds.length > 0) { + titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); + } + if (criticalMeds.length > 0) { + titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`); + } + if (lowStockMeds.length > 0) { + titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); + } + const notificationTitle = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; + + // Build description text + let descriptionText: string; + if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) { + descriptionText = tr.stockReminder.descriptionMixed; + } else if (emptyMeds.length > 0) { + descriptionText = tr.stockReminder.descriptionEmpty; + } else if (criticalMeds.length > 0) { + descriptionText = tr.stockReminder.description; + } else { + descriptionText = tr.stockReminder.descriptionLow; + } + + // Build section-based message (shared between email plain text and push) + const messageParts: string[] = []; + if (emptyMeds.length > 0) { + messageParts.push(`🚨 ${tr.push.emptySection}:`); + emptyMeds.forEach((r) => messageParts.push(` • ${r.name}`)); + } + if (criticalMeds.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.push.criticalSection}:`); + criticalMeds.forEach((r) => + messageParts.push( + ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` + ) + ); + } + if (lowStockMeds.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); + lowStockMeds.forEach((r) => + messageParts.push( + ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` + ) + ); + } // Send email if enabled if (notificationSettings.emailEnabled && email) { @@ -256,52 +393,59 @@ Sent from MedAssist-ng Medication Planner`; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; if (smtpHost && smtpUser) { - // Build subject line based on what we have - let subjectText: string; - if (emptyMeds.length > 0 && lowMeds.length > 0) { - subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`; - } else if (emptyMeds.length > 0) { - subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`; - } else { - subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`; - } + // Build subject line from shared title parts + const subjectText = titleParts.join(", "); - // Build alert box based on what we have - let alertHtml: string; - if (emptyMeds.length > 0 && lowMeds.length > 0) { - alertHtml = ` + // Build alert boxes for each category + const alertParts: string[] = []; + + if (emptyMeds.length > 0) { + const emptyAlert = + emptyMeds.length === 1 + ? tr.stockReminder.alertEmptySingle + : t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length }); + alertParts.push(`

- 🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately! + ${emptyAlert}

-
-
-

- ⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon -

-
`; - } else if (emptyMeds.length > 0) { - alertHtml = ` -
-

- 🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately! -

-
`; - } else { - alertHtml = ` -
-

- ⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon -

-
`; + `); } + if (criticalMeds.length > 0) { + const criticalAlert = + criticalMeds.length === 1 + ? tr.stockReminder.alertLowSingle + : t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length }); + alertParts.push(` +
+

+ ${criticalAlert} +

+
`); + } + + if (lowStockMeds.length > 0) { + const lowAlert = + lowStockMeds.length === 1 + ? tr.stockReminder.alertLowStockSingle + : t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length }); + alertParts.push(` +
+

+ ${lowAlert} +

+
`); + } + + const alertHtml = alertParts.join(""); + // Build table rows with status indicator const buildTableRow = (row: LowStockItem) => { const isEmpty = row.medsLeft <= 0; - const statusIcon = isEmpty ? "🚨" : "⚠️"; - const rowBg = isEmpty ? "#fef2f2" : "white"; - // Escape user-provided strings and coerce numbers to prevent XSS + const isCritical = row.isCritical !== false; + const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️"; + const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white"; const safeName = escapeHtml(row.name); const safeMedsLeft = Number(row.medsLeft) || 0; const safeDaysLeft = Number(row.daysLeft) || 0; @@ -311,26 +455,16 @@ Sent from MedAssist-ng Medication Planner`; ${statusIcon} ${safeName} ${safeMedsLeft} ${safeDaysLeft} - ${isEmpty ? "NOW" : safeDepletionDate} + ${isEmpty ? `${tr.stockReminder.now}` : safeDepletionDate} `; }; const tableRows = lowStock.map(buildTableRow).join(""); - // Build description text - let descriptionText: string; - if (emptyMeds.length > 0 && lowMeds.length > 0) { - descriptionText = "The following medications need to be reordered:"; - } else if (emptyMeds.length > 0) { - descriptionText = "The following medications are EMPTY and need to be reordered immediately:"; - } else { - descriptionText = "The following medications are running low and need to be reordered:"; - } - const html = `
-

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder

+

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - ${tr.push.reorderNow}

${descriptionText}

${alertHtml} @@ -339,10 +473,10 @@ Sent from MedAssist-ng Medication Planner`; - - - - + + + + @@ -352,33 +486,12 @@ Sent from MedAssist-ng Medication Planner`;
-

Sent from MedAssist-ng Medication Planner

+

${getFooterHtml(language)}

`; - // Build plain text with sections - let plainTextContent: string; - if (emptyMeds.length > 0 && lowMeds.length > 0) { - plainTextContent = `🚨 EMPTY (reorder immediately): -${emptyMeds.map((r) => ` • ${r.name}`).join("\n")} - -⚠️ RUNNING LOW (reorder soon): -${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`; - } else if (emptyMeds.length > 0) { - plainTextContent = `🚨 EMPTY (reorder immediately): -${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}`; - } else { - plainTextContent = `⚠️ Running low: -${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`; - } - - const plainText = `MedAssist-ng - Reorder Reminder - -${plainTextContent} - ---- -Sent from MedAssist-ng Medication Planner`; + const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; try { const transporter = nodemailer.createTransport({ @@ -409,38 +522,10 @@ Sent from MedAssist-ng Medication Planner`; // Send push notification if enabled if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { - // Get translations based on user language (default to 'en') - const tr = getTranslations((userSettings.language as Language) || "en"); - - // Build clear title - const titleParts: string[] = []; - if (emptyMeds.length > 0) { - titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); - } - if (lowMeds.length > 0) { - titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`); - } - const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; - - // Build clear message with sections - const messageParts: string[] = []; - if (emptyMeds.length > 0) { - messageParts.push(`🚨 ${tr.push.emptySection}:`); - emptyMeds.forEach((r) => messageParts.push(` • ${r.name}`)); - } - if (lowMeds.length > 0) { - if (emptyMeds.length > 0) messageParts.push(""); - messageParts.push(`⚠️ ${tr.push.lowSection}:`); - lowMeds.forEach((r) => - messageParts.push( - ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` - ) - ); - } - const message = messageParts.join("\n"); + const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; try { - const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message); + const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message); if (pushResult.success) { results.push = true; } else { @@ -458,7 +543,9 @@ Sent from MedAssist-ng Medication Planner`; updateReminderSentTime("stock", channel); // Also update user settings in database so frontend can display the info - await updateUserReminderSentTime(userId, "stock", channel); + const firstMed = lowStock[0]; + const medNames = lowStock.length > 1 ? `${firstMed.name} (+${lowStock.length - 1})` : firstMed?.name; + await updateUserReminderSentTime(userId, "stock", channel, medNames); } // Build response message diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index e76c05e..0ea485f 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -30,11 +30,15 @@ export type UserSettings = { highStockDays: number; language: Language; stockCalculationMode: "automatic" | "manual"; + shareStockStatus: boolean; lastAutoEmailSent: string | null; lastNotificationType: string | null; lastNotificationChannel: string | null; lastReminderMedName: string | null; lastReminderTakenBy: string | null; + lastStockReminderSent: string | null; + lastStockReminderChannel: string | null; + lastStockReminderMedNames: string | null; }; type SettingsBody = { @@ -57,6 +61,7 @@ type SettingsBody = { maxNaggingReminders: number; language: string; stockCalculationMode: "automatic" | "manual"; + shareStockStatus: boolean; }; type TestEmailBody = { @@ -104,11 +109,15 @@ function getDefaultSettings() { highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180), language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en", stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic", + shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true), lastAutoEmailSent: null, lastNotificationType: null, lastNotificationChannel: null, lastReminderMedName: null, lastReminderTakenBy: null, + lastStockReminderSent: null, + lastStockReminderChannel: null, + lastStockReminderMedNames: null, }; } @@ -154,11 +163,15 @@ export async function loadUserSettings(userId: number): Promise { highStockDays: settings.highStockDays, language: settings.language as Language, stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", + shareStockStatus: settings.shareStockStatus ?? true, lastAutoEmailSent: settings.lastAutoEmailSent, lastNotificationType: settings.lastNotificationType, lastNotificationChannel: settings.lastNotificationChannel, lastReminderMedName: settings.lastReminderMedName ?? null, lastReminderTakenBy: settings.lastReminderTakenBy ?? null, + lastStockReminderSent: settings.lastStockReminderSent ?? null, + lastStockReminderChannel: settings.lastStockReminderChannel ?? null, + lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, }; } @@ -186,11 +199,15 @@ export async function getAllUserSettings(): Promise { highStockDays: settings.highStockDays, language: settings.language as Language, stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", + shareStockStatus: settings.shareStockStatus ?? true, lastAutoEmailSent: settings.lastAutoEmailSent, lastNotificationType: settings.lastNotificationType, lastNotificationChannel: settings.lastNotificationChannel, lastReminderMedName: settings.lastReminderMedName ?? null, lastReminderTakenBy: settings.lastReminderTakenBy ?? null, + lastStockReminderSent: settings.lastStockReminderSent ?? null, + lastStockReminderChannel: settings.lastStockReminderChannel ?? null, + lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, })); } @@ -241,6 +258,7 @@ export async function settingsRoutes(app: FastifyInstance) { maxNaggingReminders: settings.maxNaggingReminders ?? 5, language: settings.language, stockCalculationMode: settings.stockCalculationMode ?? "automatic", + shareStockStatus: settings.shareStockStatus ?? true, // SMTP settings (from .env - shared/server-configured) smtpHost: process.env.SMTP_HOST ?? "", smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10), @@ -254,6 +272,10 @@ export async function settingsRoutes(app: FastifyInstance) { lastNotificationChannel: settings.lastNotificationChannel, lastReminderMedName: settings.lastReminderMedName ?? null, lastReminderTakenBy: settings.lastReminderTakenBy ?? null, + // Stock reminder tracking (separate from intake) + lastStockReminderSent: settings.lastStockReminderSent ?? null, + lastStockReminderChannel: settings.lastStockReminderChannel ?? null, + lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, // Server settings (from .env, read-only) expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10), }); @@ -296,6 +318,7 @@ export async function settingsRoutes(app: FastifyInstance) { highStockDays: body.highStockDays ?? 180, language: body.language ?? "en", stockCalculationMode: body.stockCalculationMode ?? "automatic", + shareStockStatus: body.shareStockStatus ?? true, updatedAt: new Date(), }; diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 10d2dc1..954fd3b 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -135,6 +135,8 @@ export async function shareRoutes(app: FastifyInstance) { blisters, // Legacy format for backward compat dismissedUntil: med.dismissedUntil, updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations + lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null, + stockAdjustment: med.stockAdjustment ?? 0, }; }); @@ -145,7 +147,13 @@ export async function shareRoutes(app: FastifyInstance) { medications: medicationsWithBlisters, stockThresholds: { lowStockDays: settings?.lowStockDays ?? 30, + normalStockDays: settings?.normalStockDays ?? 60, + highStockDays: settings?.highStockDays ?? 90, + reminderDaysBefore: settings?.reminderDaysBefore ?? 7, + expiryWarningDays: settings?.expiryWarningDays ?? 90, }, + stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", + shareStockStatus: settings?.shareStockStatus ?? true, }; }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index b0f6775..d61d848 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -5,7 +5,14 @@ import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { getDataDir } from "../db/db-utils.js"; import { doseTracking, medications } from "../db/schema.js"; -import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js"; +import { + getDateLocale, + getFooterHtml, + getFooterPlain, + getTranslations, + type Language, + t, +} from "../i18n/translations.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; // Import shared utilities @@ -150,7 +157,7 @@ async function sendIntakeReminderEmail(

- ${tr.intakeReminder.footer} + ${getFooterHtml(language)}

@@ -179,7 +186,7 @@ ${intakes .join("\n")} --- -${tr.intakeReminder.footer}`; +${getFooterPlain(language)}`; const subject = isRepeat ? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}` @@ -601,7 +608,9 @@ async function checkAndSendIntakeRemindersForUser( } return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`; }) - .join("\n") + repeatNote; + .join("\n") + + repeatNote + + `\n\n---\n${getFooterPlain(language)}`; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 879eeec..fd071d4 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -5,7 +5,7 @@ import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { getDataDir } from "../db/db-utils.js"; import { medications, userSettings } from "../db/schema.js"; -import { getTranslations, type Language, t } from "../i18n/translations.js"; +import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; // Import shared utilities @@ -63,6 +63,7 @@ export function updateReminderSentTime( } // Update user settings in database when reminder is sent +// Stock and intake reminders are tracked separately so neither overwrites the other export async function updateUserReminderSentTime( userId: number, type: "stock" | "intake" = "stock", @@ -71,16 +72,30 @@ export async function updateUserReminderSentTime( takenBy?: string ): Promise { const now = new Date().toISOString(); - await db - .update(userSettings) - .set({ - lastAutoEmailSent: now, - lastNotificationType: type, - lastNotificationChannel: channel, - lastReminderMedName: medName ?? null, - lastReminderTakenBy: takenBy ?? null, - }) - .where(eq(userSettings.userId, userId)); + if (type === "stock") { + // Write to dedicated stock reminder columns only — do NOT touch the shared + // lastNotificationType column, as that would block intake reminder display + await db + .update(userSettings) + .set({ + lastStockReminderSent: now, + lastStockReminderChannel: channel, + lastStockReminderMedNames: medName ?? null, + }) + .where(eq(userSettings.userId, userId)); + } else { + // Write to intake reminder columns + await db + .update(userSettings) + .set({ + lastAutoEmailSent: now, + lastNotificationType: type, + lastNotificationChannel: channel, + lastReminderMedName: medName ?? null, + lastReminderTakenBy: takenBy ?? null, + }) + .where(eq(userSettings.userId, userId)); + } } function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { @@ -191,7 +206,7 @@ async function sendReminderEmail(

- ${tr.stockReminder.footer} + ${getFooterHtml(language)}

${isRepeatDaily ? `

${tr.stockReminder.repeatDailyNote}

` : ""} @@ -205,7 +220,7 @@ ${tr.stockReminder.description} ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")} --- -${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; +${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s"; const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural }); @@ -305,30 +320,30 @@ async function checkAndSendReminderForUser( // Send Shoutrrr notification if enabled if (shoutrrrEnabled) { - // Separate empty from low stock medications + // Separate empty from critical stock medications (all auto-reminder meds are critical by definition) const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0); - const lowMeds = allLowStock.filter((m) => m.medsLeft > 0); + const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0); // Build clear title const titleParts: string[] = []; if (emptyMeds.length > 0) { titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`); } - if (lowMeds.length > 0) { - titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`); + if (criticalMeds.length > 0) { + titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical || "Critical"}`); } const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`; // Build clear message with sections const messageParts: string[] = []; if (emptyMeds.length > 0) { - messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`); + messageParts.push(`🚨 ${tr.push.emptySection || "Empty (reorder immediately)"}:`); emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`)); } - if (lowMeds.length > 0) { + if (criticalMeds.length > 0) { if (emptyMeds.length > 0) messageParts.push(""); - messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`); - lowMeds.forEach((m) => + messageParts.push(`🚨 ${tr.push.criticalSection || "Running critically low"}:`); + criticalMeds.forEach((m) => messageParts.push( ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` ) @@ -340,7 +355,7 @@ async function checkAndSendReminderForUser( messageParts.push(tr.push.repeatDailyNote); } - const message = messageParts.join("\n"); + const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index eb69470..3d830b7 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -126,11 +126,15 @@ async function createSchema(client: Client) { expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', + share_stock_status integer NOT NULL DEFAULT 1, last_auto_email_sent text, last_notification_type text, last_notification_channel text, last_reminder_med_name text, last_reminder_taken_by text, + last_stock_reminder_sent text, + last_stock_reminder_channel text, + last_stock_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index cf9a81d..c1ead0f 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -121,11 +121,15 @@ async function createSchema(client: Client) { expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', + share_stock_status integer NOT NULL DEFAULT 1, last_auto_email_sent text, last_notification_type text, last_notification_channel text, last_reminder_med_name text, last_reminder_taken_by text, + last_stock_reminder_sent text, + last_stock_reminder_channel text, + last_stock_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index 9925e04..e2c56d6 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -110,11 +110,15 @@ async function createSchema(client: Client) { expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', + share_stock_status integer NOT NULL DEFAULT 1, last_auto_email_sent text, last_notification_type text, last_notification_channel text, last_reminder_med_name text, last_reminder_taken_by text, + last_stock_reminder_sent text, + last_stock_reminder_channel text, + last_stock_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, @@ -161,21 +165,6 @@ describe("Planner Routes", () => { }); describe("POST /planner/send-email", () => { - it("should reject request with missing email", async () => { - const response = await app.inject({ - method: "POST", - url: "/planner/send-email", - payload: { - from: "2025-01-01", - until: "2025-01-31", - rows: [{ medicationName: "Test", totalPills: 10, plannerUsage: 5, enough: true }], - }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "Missing email or planner data" }); - }); - it("should reject request with missing rows", async () => { const response = await app.inject({ method: "POST", @@ -189,10 +178,16 @@ describe("Planner Routes", () => { }); expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "Missing email or planner data" }); + expect(response.json()).toEqual({ error: "Missing planner data" }); }); - it("should reject when SMTP is not configured", async () => { + it("should return error when no notification channels configured", async () => { + // User settings exist but email/shoutrrr disabled + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`, + args: [999999999], + }); + const response = await app.inject({ method: "POST", url: "/planner/send-email", @@ -217,7 +212,7 @@ describe("Planner Routes", () => { }); expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "SMTP not configured" }); + expect(response.json()).toEqual({ error: "No notification channels configured" }); }); it("should send email successfully when SMTP is configured", async () => { @@ -226,6 +221,12 @@ describe("Planner Routes", () => { process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; + // Enable email in user settings + 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" }); const response = await app.inject({ @@ -253,7 +254,7 @@ describe("Planner Routes", () => { }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ success: true, message: "Email sent successfully" }); + expect(response.json()).toEqual({ success: true, message: "Notification sent via email" }); expect(mockSendMail).toHaveBeenCalledTimes(1); // Cleanup @@ -267,6 +268,11 @@ describe("Planner Routes", () => { 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" }); const response = await app.inject({ @@ -308,7 +314,7 @@ describe("Planner Routes", () => { // Check that HTML contains out of stock warning const mailCall = mockSendMail.mock.calls[0][0]; - expect(mailCall.html).toContain("Out of Stock"); + expect(mailCall.html).toContain("Empty"); expect(mailCall.html).toContain("1 medication"); delete process.env.SMTP_HOST; @@ -321,6 +327,11 @@ describe("Planner Routes", () => { 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.mockRejectedValueOnce(new Error("Connection refused")); const response = await app.inject({ @@ -347,7 +358,7 @@ describe("Planner Routes", () => { }); expect(response.statusCode).toBe(500); - expect(response.json().error).toContain("Failed to send email"); + expect(response.json().error).toContain("Email:"); expect(response.json().error).toContain("Connection refused"); delete process.env.SMTP_HOST; @@ -360,6 +371,12 @@ describe("Planner Routes", () => { process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; + // User settings with German language + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'de')`, + args: [999999999], + }); + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ @@ -390,12 +407,178 @@ describe("Planner Routes", () => { // German date format should be used const mailCall = mockSendMail.mock.calls[0][0]; - expect(mailCall.subject).toContain("Supply Overview"); + expect(mailCall.subject).toContain("Bestandsübersicht"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); + + it("should send push notification when shoutrrr is enabled", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + 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 push" }); + expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); + + // Verify push message contains medication info + const [_url, title, message] = mockSendShoutrrr.mock.calls[0]; + expect(title).toContain("Supply Overview"); + expect(message).toContain("Aspirin"); + }); + + it("should send both email and push when both enabled", 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, shoutrrr_url, language) VALUES (?, 1, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 5, + plannerUsage: 30, + blisterSize: 10, + blistersNeeded: 3, + fullBlisters: 0, + loosePills: 5, + enough: false, + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Notification sent via email and push" }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); + + // Verify push message contains out of stock info + const [_url, _title, message] = mockSendShoutrrr.mock.calls[0]; + expect(message).toContain("Aspirin"); + expect(message).toContain("Empty"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should send push with German translations", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'de')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 5, + plannerUsage: 30, + blisterSize: 10, + blistersNeeded: 3, + fullBlisters: 0, + loosePills: 5, + enough: false, + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + // Check German translations in push + const [_url, title] = mockSendShoutrrr.mock.calls[0]; + expect(title).toContain("Bestandsübersicht"); + }); + + it("should handle push error gracefully", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: false, error: "Connection failed" }); + + const response = await app.inject({ + method: "POST", + url: "/planner/send-email", + payload: { + email: "test@example.com", + from: "2025-01-01", + until: "2025-01-31", + rows: [ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 30, + plannerUsage: 10, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 3, + loosePills: 0, + enough: true, + }, + ], + }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Push:"); + expect(response.json().error).toContain("Connection failed"); + }); }); describe("POST /reminder/send-email", () => { @@ -503,10 +686,10 @@ describe("Planner Routes", () => { expect(response.statusCode).toBe(200); - // Check email contains EMPTY warning + // Check email contains empty warning const mailCall = mockSendMail.mock.calls[0][0]; expect(mailCall.subject).toContain("Empty"); - expect(mailCall.html).toContain("EMPTY"); + expect(mailCall.html).toContain("empty"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; @@ -541,7 +724,7 @@ describe("Planner Routes", () => { const mailCall = mockSendMail.mock.calls[0][0]; expect(mailCall.subject).toContain("Empty"); - expect(mailCall.subject).toContain("Running Low"); + expect(mailCall.subject).toContain("Critical"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; @@ -698,5 +881,103 @@ describe("Planner Routes", () => { expect(response.json().error).toContain("Push:"); expect(response.json().error).toContain("Network error"); }); + + it("should differentiate critical and low stock in push notification", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true }, + { name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + const [_url, title, message] = mockSendShoutrrr.mock.calls[0]; + // Title should contain both Critical and Low labels + expect(title).toContain("Critical"); + expect(title).toContain("Low"); + // Message should have separate sections + expect(message).toContain("Running critically low"); + expect(message).toContain("Aspirin"); + expect(message).toContain("Running low"); + expect(message).toContain("Ibuprofen"); + }); + + it("should differentiate critical and low stock in email", 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" }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [ + { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true }, + { name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + const mailCall = mockSendMail.mock.calls[0][0]; + // Subject should contain both Critical and Low + expect(mailCall.subject).toContain("Critical"); + expect(mailCall.subject).toContain("Low"); + // HTML should have separate alert boxes + expect(mailCall.html).toContain("critically low"); + expect(mailCall.html).toContain("running low"); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should label all meds as critical when isCritical not provided", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-email", + payload: { + email: "test@example.com", + lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }], + }, + }); + + expect(response.statusCode).toBe(200); + + const [_url, title, message] = mockSendShoutrrr.mock.calls[0]; + // Should be treated as critical (backwards compat) + expect(title).toContain("Critical"); + expect(title).not.toContain("Low"); + expect(message).toContain("Running critically low"); + }); }); }); diff --git a/backend/src/test/settings.test.ts b/backend/src/test/settings.test.ts index f4ef8bd..a8c7525 100644 --- a/backend/src/test/settings.test.ts +++ b/backend/src/test/settings.test.ts @@ -51,6 +51,7 @@ async function registerSettingsRoutes(ctx: TestContext) { expiryWarningDays: 90, language: "en", stockCalculationMode: "automatic", + shareStockStatus: true, }; } @@ -76,6 +77,7 @@ async function registerSettingsRoutes(ctx: TestContext) { expiryWarningDays: s.expiry_warning_days, language: s.language, stockCalculationMode: s.stock_calculation_mode, + shareStockStatus: Boolean(s.share_stock_status ?? 1), }; }); @@ -102,6 +104,7 @@ async function registerSettingsRoutes(ctx: TestContext) { expiryWarningDays?: number; language?: string; stockCalculationMode?: "automatic" | "manual"; + shareStockStatus?: boolean; }; }>("/settings", async (request, reply) => { const userId = 1; @@ -150,8 +153,8 @@ async function registerSettingsRoutes(ctx: TestContext) { reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses, repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders, low_stock_days, normal_stock_days, high_stock_days, - expiry_warning_days, language, stock_calculation_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + expiry_warning_days, language, stock_calculation_mode, share_stock_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args: [ userId, body.emailEnabled ? 1 : 0, @@ -174,6 +177,7 @@ async function registerSettingsRoutes(ctx: TestContext) { body.expiryWarningDays ?? 90, body.language || "en", body.stockCalculationMode || "automatic", + body.shareStockStatus !== false ? 1 : 0, ], }); } else { @@ -200,6 +204,7 @@ async function registerSettingsRoutes(ctx: TestContext) { expiry_warning_days = ?, language = ?, stock_calculation_mode = ?, + share_stock_status = ?, updated_at = strftime('%s','now') WHERE user_id = ?`, args: [ @@ -223,6 +228,7 @@ async function registerSettingsRoutes(ctx: TestContext) { body.expiryWarningDays ?? 90, body.language || "en", body.stockCalculationMode || "automatic", + body.shareStockStatus !== false ? 1 : 0, userId, ], }); @@ -542,6 +548,64 @@ describe("Settings API", () => { }); }); + // --------------------------------------------------------------------------- + // Share Stock Status + // --------------------------------------------------------------------------- + + describe("Share Stock Status", () => { + it("should default to true (show stock on shared links)", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(response.statusCode).toBe(200); + expect(response.json().shareStockStatus).toBe(true); + }); + + it("should disable share stock status", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { shareStockStatus: false }, + }); + + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(getResponse.json().shareStockStatus).toBe(false); + }); + + it("should re-enable share stock status", async () => { + // Disable first + await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { shareStockStatus: false }, + }); + + // Re-enable + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { shareStockStatus: true }, + }); + + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + expect(getResponse.json().shareStockStatus).toBe(true); + }); + }); + // --------------------------------------------------------------------------- // Repeat Reminders & Skip Reminders Settings // --------------------------------------------------------------------------- diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index edf7c03..eb292a1 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -216,13 +216,14 @@ export interface UpdateUserSettingsOptions { userId: number; stockCalculationMode?: "automatic" | "manual"; lowStockDays?: number; + shareStockStatus?: boolean; } /** * Create or update user settings */ export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise { - const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options; + const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options; // Check if settings exist const existing = await client.execute({ @@ -232,13 +233,19 @@ export async function setUserSettings(client: Client, options: UpdateUserSetting if (existing.rows.length > 0) { await client.execute({ - sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`, - args: [stockCalculationMode, lowStockDays, userId], + sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`, + args: + shareStockStatus !== undefined + ? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId] + : [stockCalculationMode, lowStockDays, userId], }); } else { await client.execute({ - sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`, - args: [userId, stockCalculationMode, lowStockDays], + sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`, + args: + shareStockStatus !== undefined + ? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0] + : [userId, stockCalculationMode, lowStockDays], }); } } diff --git a/backend/src/test/share.test.ts b/backend/src/test/share.test.ts index 854b95d..4fa5764 100644 --- a/backend/src/test/share.test.ts +++ b/backend/src/test/share.test.ts @@ -10,6 +10,7 @@ import { createTestMedication, createTestShareToken, createTestUser, + setUserSettings, type TestContext, } from "./setup.js"; @@ -141,6 +142,14 @@ async function registerShareRoutes(ctx: TestContext) { const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30; + // Get shareStockStatus setting + const shareStockResult = await client.execute({ + sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`, + args: [share.user_id], + }); + const shareStockStatus = + shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true; + return { takenBy: share.taken_by, sharedBy: share.owner_username, @@ -149,6 +158,7 @@ async function registerShareRoutes(ctx: TestContext) { stockThresholds: { lowStockDays, }, + shareStockStatus, }; }); @@ -421,6 +431,41 @@ describe("Share Link API", () => { expect(med.blisters).toHaveLength(1); expect(med.blisters[0].usage).toBe(1); expect(med.blisters[0].every).toBe(1); + + // shareStockStatus should default to true + expect(data.shareStockStatus).toBe(true); + }); + + it("should respect shareStockStatus setting when disabled", async () => { + // Create medication + await createTestMedication(ctx.client, { + userId, + name: "TestMed", + takenBy: ["Daniel"], + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }); + + // Set shareStockStatus to false + await setUserSettings(ctx.client, { userId, shareStockStatus: false }); + + // Create share token + const token = await createTestShareToken(ctx.client, { + userId, + takenBy: "Daniel", + scheduleDays: 30, + }); + + const response = await ctx.app.inject({ + method: "GET", + url: `/share/${token}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().shareStockStatus).toBe(false); }); it("should return 404 for invalid token", async () => { diff --git a/backend/src/test/translations.test.ts b/backend/src/test/translations.test.ts index db8cc89..5811a9c 100644 --- a/backend/src/test/translations.test.ts +++ b/backend/src/test/translations.test.ts @@ -69,8 +69,8 @@ describe("Translations Module", () => { }); it("should replace multiple placeholders", () => { - const result = t("{count} {type} running low", { count: 3, type: "medications" }); - expect(result).toBe("3 medications running low"); + const result = t("{count} {type} running critically low", { count: 3, type: "medications" }); + expect(result).toBe("3 medications running critically low"); }); it("should replace same placeholder multiple times", () => { @@ -98,7 +98,7 @@ describe("Translations Module", () => { // Stock reminder subject const subject = t(translations.stockReminder.subject, { count: 3, s: "s" }); - expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low"); + expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Critically Low"); // Intake reminder description const description = t(translations.intakeReminder.description, { minutes: 30 }); @@ -113,7 +113,7 @@ describe("Translations Module", () => { const translations = getTranslations("de"); const subject = t(translations.stockReminder.subject, { count: 2, e: "e" }); - expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp"); + expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente kritisch niedrig"); const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" }); expect(takenBy).toBe("für Daniel");
MedicationPillsDaysRuns Out${tr.stockReminder.tableHeaders.medication}${tr.stockReminder.tableHeaders.pills}${tr.stockReminder.tableHeaders.days}${tr.stockReminder.tableHeaders.runsOut}