-- Migration 096: Reminder System for Sag (Cases) -- Dato: 3. februar 2026 -- -- Features: -- - Time-based reminders (specific datetime or cron-like scheduling) -- - Status-change triggered reminders (via database trigger) -- - Recurring reminders (once, daily, weekly, monthly) -- - Multi-channel notifications (mattermost, email, frontend popup) -- - User preferences with per-case overrides -- - Global rate limiting (max 5 notifications per user per hour) -- ============================================================================ -- User Notification Preferences Table -- ============================================================================ CREATE TABLE IF NOT EXISTS user_notification_preferences ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE, -- Default notification channels (can be overridden per reminder) notify_mattermost BOOLEAN DEFAULT true, notify_email BOOLEAN DEFAULT false, notify_frontend BOOLEAN DEFAULT true, -- Email recipient (if different from user account) email_override VARCHAR(255), -- Quiet hours (no notifications outside these hours) quiet_hours_enabled BOOLEAN DEFAULT false, quiet_hours_start TIME, -- e.g., 18:00 quiet_hours_end TIME, -- e.g., 08:00 (next day) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT email_format CHECK (email_override IS NULL OR email_override ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$') ); -- ============================================================================ -- Reminder Rules Table -- ============================================================================ CREATE TABLE IF NOT EXISTS sag_reminders ( id SERIAL PRIMARY KEY, sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, -- Trigger Configuration trigger_type VARCHAR(50) NOT NULL CHECK (trigger_type IN ('status_change', 'deadline_approaching', 'time_based')), trigger_config JSONB NOT NULL, -- Examples: -- status_change: {"target_status": "i_gang", "when_changed_to": "i_gang"} -- deadline_approaching: {"days_before": 3} -- time_based: {} -- Reminder Details title VARCHAR(255) NOT NULL, message TEXT, priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')), -- Notification Delivery notify_mattermost BOOLEAN, -- NULL = use user default notify_email BOOLEAN, -- NULL = use user default notify_frontend BOOLEAN, -- NULL = use user default override_user_preferences BOOLEAN DEFAULT false, -- Recipient Configuration recipient_user_ids INTEGER[], -- Array of user IDs to notify recipient_emails TEXT[], -- Additional email addresses -- Recurrence Configuration recurrence_type VARCHAR(20) NOT NULL DEFAULT 'once' CHECK (recurrence_type IN ('once', 'daily', 'weekly', 'monthly')), recurrence_day_of_week INTEGER, -- 0-6 (0=Sunday) for weekly recurrence_day_of_month INTEGER, -- 1-31 for monthly -- Scheduling scheduled_at TIMESTAMP, -- When reminder should first trigger next_check_at TIMESTAMP, -- When to check/send next last_sent_at TIMESTAMP, -- State is_active BOOLEAN DEFAULT true, created_by_user_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP, CONSTRAINT has_recipients CHECK ( (recipient_user_ids IS NOT NULL AND array_length(recipient_user_ids, 1) > 0) OR (recipient_emails IS NOT NULL AND array_length(recipient_emails, 1) > 0) ) ); CREATE INDEX IF NOT EXISTS idx_sag_reminders_sag_id ON sag_reminders(sag_id) WHERE is_active = true AND deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_sag_reminders_next_check ON sag_reminders(next_check_at) WHERE is_active = true AND deleted_at IS NULL AND next_check_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_sag_reminders_active ON sag_reminders(is_active, deleted_at) WHERE is_active = true AND deleted_at IS NULL; -- ============================================================================ -- Reminder Queue Table (for trigger-based events) -- ============================================================================ CREATE TABLE IF NOT EXISTS sag_reminder_queue ( id SERIAL PRIMARY KEY, reminder_id INTEGER NOT NULL REFERENCES sag_reminders(id) ON DELETE CASCADE, sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, -- Event that triggered this trigger_event VARCHAR(50) NOT NULL, -- e.g., 'status_changed' event_data JSONB, -- e.g., {"old_status": "åben", "new_status": "i_gang"} -- Processing status status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'rate_limited', 'skipped')), error_message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, processed_at TIMESTAMP, CONSTRAINT trigger_check CHECK ( trigger_event IN ('status_changed', 'deadline_reached', 'manual') ) ); CREATE INDEX IF NOT EXISTS idx_sag_reminder_queue_status ON sag_reminder_queue(status) WHERE status IN ('pending', 'processing'); CREATE INDEX IF NOT EXISTS idx_sag_reminder_queue_created ON sag_reminder_queue(created_at) WHERE status = 'pending'; -- ============================================================================ -- Reminder Execution Log Table -- ============================================================================ CREATE TABLE IF NOT EXISTS sag_reminder_logs ( id SERIAL PRIMARY KEY, reminder_id INTEGER REFERENCES sag_reminders(id) ON DELETE SET NULL, sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, user_id INTEGER, -- User who was notified (for rate limiting) -- Execution Details triggered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, channels_used TEXT[], -- Array: ['mattermost', 'email', 'frontend'] notification_payload JSONB, -- Delivery Status status VARCHAR(20) CHECK (status IN ('sent', 'failed', 'snoozed', 'dismissed', 'rate_limited')), error_message TEXT, -- User Actions acknowledged_by_user_id INTEGER, acknowledged_at TIMESTAMP, snoozed_until TIMESTAMP, snoozed_by_user_id INTEGER, dismissed_at TIMESTAMP, dismissed_by_user_id INTEGER, CONSTRAINT action_user_check CHECK ( (acknowledged_by_user_id IS NULL AND acknowledged_at IS NULL) OR (acknowledged_by_user_id IS NOT NULL AND acknowledged_at IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS idx_sag_reminder_logs_user ON sag_reminder_logs(user_id, triggered_at) WHERE status = 'sent'; CREATE INDEX IF NOT EXISTS idx_sag_reminder_logs_sag ON sag_reminder_logs(sag_id, triggered_at); CREATE INDEX IF NOT EXISTS idx_sag_reminder_logs_status ON sag_reminder_logs(status, snoozed_until) WHERE status IN ('snoozed', 'sent'); -- ============================================================================ -- Trigger Functions -- ============================================================================ -- Function: Check rate limiting (global per user, max 5/hour) CREATE OR REPLACE FUNCTION check_reminder_rate_limit(user_id_param INTEGER) RETURNS BOOLEAN AS $$ DECLARE notification_count INTEGER; BEGIN SELECT COUNT(*) INTO notification_count FROM sag_reminder_logs WHERE user_id = user_id_param AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour' AND status = 'sent'; RETURN notification_count < 5; END; $$ LANGUAGE plpgsql STABLE; -- Trigger: Auto-update updated_at timestamp CREATE OR REPLACE FUNCTION update_sag_reminders_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER sag_reminders_updated_at_trigger BEFORE UPDATE ON sag_reminders FOR EACH ROW EXECUTE FUNCTION update_sag_reminders_updated_at(); -- Trigger: Auto-update user preferences updated_at CREATE OR REPLACE FUNCTION update_user_notification_preferences_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER user_notification_preferences_updated_at_trigger BEFORE UPDATE ON user_notification_preferences FOR EACH ROW EXECUTE FUNCTION update_user_notification_preferences_updated_at(); -- Trigger: Status change on sag_sager triggers reminders CREATE OR REPLACE FUNCTION sag_status_change_reminder_trigger() RETURNS TRIGGER AS $$ DECLARE reminder RECORD; recipient_user_id INTEGER; v_target_status VARCHAR; BEGIN -- Only process if status actually changed IF OLD.status IS NOT DISTINCT FROM NEW.status THEN RETURN NEW; END IF; -- Find reminders with status_change trigger for this case FOR reminder IN SELECT * FROM sag_reminders WHERE sag_id = NEW.id AND is_active = true AND deleted_at IS NULL AND trigger_type = 'status_change' LOOP v_target_status := reminder.trigger_config->>'target_status'; -- Queue event if reminder targets this new status IF v_target_status = NEW.status THEN INSERT INTO sag_reminder_queue ( reminder_id, sag_id, trigger_event, event_data, status ) VALUES ( reminder.id, NEW.id, 'status_changed', jsonb_build_object( 'old_status', OLD.status, 'new_status', NEW.status, 'changed_at', CURRENT_TIMESTAMP ), 'pending' ); END IF; END LOOP; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Create trigger on sag_sager DROP TRIGGER IF EXISTS sag_status_change_reminder_trigger_exec ON sag_sager; CREATE TRIGGER sag_status_change_reminder_trigger_exec AFTER UPDATE OF status ON sag_sager FOR EACH ROW EXECUTE FUNCTION sag_status_change_reminder_trigger(); -- ============================================================================ -- Helper Views -- ============================================================================ -- View: Pending reminders ready to send CREATE OR REPLACE VIEW v_pending_reminders AS SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.recipient_user_ids, r.recipient_emails, r.notify_mattermost, r.notify_email, r.notify_frontend, r.override_user_preferences, r.trigger_type, r.trigger_config, r.recurrence_type, r.scheduled_at, r.next_check_at FROM sag_reminders r WHERE r.is_active = true AND r.deleted_at IS NULL AND r.next_check_at IS NOT NULL AND r.next_check_at <= CURRENT_TIMESTAMP; -- View: Pending queue events CREATE OR REPLACE VIEW v_pending_reminder_queue AS SELECT q.id, q.reminder_id, q.sag_id, q.trigger_event, q.event_data, r.recipient_user_ids, r.recipient_emails, r.notify_mattermost, r.notify_email, r.notify_frontend, r.override_user_preferences, r.title, r.message, r.priority FROM sag_reminder_queue q JOIN sag_reminders r ON q.reminder_id = r.id WHERE q.status = 'pending' AND r.is_active = true AND r.deleted_at IS NULL ORDER BY r.priority DESC, q.created_at ASC; -- ============================================================================ -- Comments -- ============================================================================ COMMENT ON TABLE sag_reminders IS 'Defines reminder rules for cases (triggers, recipients, notifications)'; COMMENT ON TABLE sag_reminder_queue IS 'Queues reminder events from trigger functions for processing'; COMMENT ON TABLE sag_reminder_logs IS 'Logs all reminder notifications sent, including user interactions'; COMMENT ON TABLE user_notification_preferences IS 'User default notification preferences (can be overridden per reminder)'; COMMENT ON COLUMN sag_reminders.trigger_config IS 'JSON config for trigger type (e.g. {"target_status": "i_gang"})'; COMMENT ON COLUMN sag_reminders.recipient_user_ids IS 'PostgreSQL array of user IDs to notify'; COMMENT ON COLUMN sag_reminders.next_check_at IS 'When this reminder should be checked/sent next'; COMMENT ON COLUMN sag_reminder_logs.user_id IS 'User who received notification (for rate limiting)'; COMMENT ON FUNCTION check_reminder_rate_limit IS 'Returns true if user has sent <5 notifications in past hour';