bmc_hub/migrations/096_reminder_system.sql
Christian b43e9f797d feat: Add reminder system for sag cases with user preferences and notification channels
- Implemented user notification preferences table for managing default notification settings.
- Created sag_reminders table to define reminder rules with various trigger types and recipient configurations.
- Developed sag_reminder_queue for processing reminder events triggered by status changes or scheduled times.
- Added sag_reminder_logs to track reminder notifications and user interactions.
- Introduced frontend notification system using Bootstrap 5 Toast for displaying reminders.
- Created email template for sending reminders with case details and action links.
- Implemented rate limiting for user notifications to prevent spamming.
- Added triggers and functions for automatic updates and reminder processing.
2026-02-06 10:47:14 +01:00

351 lines
12 KiB
PL/PgSQL

-- 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';