351 lines
12 KiB
MySQL
351 lines
12 KiB
MySQL
|
|
-- 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';
|