diff --git a/lib/types/pipeline.ts b/lib/types/pipeline.ts index 675152e..eabf576 100644 --- a/lib/types/pipeline.ts +++ b/lib/types/pipeline.ts @@ -44,6 +44,7 @@ export interface NotificationChannel { channel_type: ChannelType; config: Record; is_active: boolean; + owner_user_id: string | null; created_at: Date; updated_at: Date; } @@ -169,3 +170,25 @@ export interface NotificationChannelInput { config: Record; is_active?: boolean; } + +// ============================================================================ +// Phase 9 — Event taxonomy + user subscriptions +// ============================================================================ + +export interface NotifyEventKey { + key: string; + display_label: string; + description: string | null; + sort_order: number; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface UserEventSubscription { + user_id: string; + event_key: string; + channel_type: ChannelType; + enabled: boolean; + updated_at: Date; +} diff --git a/migrations/085_personal_notification_channels.sql b/migrations/085_personal_notification_channels.sql new file mode 100644 index 0000000..75bdea2 --- /dev/null +++ b/migrations/085_personal_notification_channels.sql @@ -0,0 +1,31 @@ +-- ============================================================================= +-- Personal notification channels (Phase 9 — CHAN-01) +-- ============================================================================= +-- Adds owner_user_id to notification_channels so a row can be either: +-- - Global (owner_user_id IS NULL) — existing rows, admin-managed +-- - Personal (owner_user_id = "user".id) — created via /api/me/channels +-- +-- Cascade delete: removing a Better Auth user removes their personal channels. +-- +-- Defense-in-depth: a partial unique index enforces one-personal-channel-per- +-- type-per-user at the DB layer (D-02 / CHAN-02). The API-layer WITH-CTE UPSERT +-- in Plan 02 still has a small race window for concurrent saves; this index +-- closes that gap. The WHERE clause keeps existing global rows (NULL owner) +-- exempt — multiple global rows of the same type can still coexist. +-- ============================================================================= + +ALTER TABLE notification_channels + ADD COLUMN IF NOT EXISTS owner_user_id TEXT + REFERENCES "user"(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_notification_channels_owner + ON notification_channels(owner_user_id, channel_type) + WHERE owner_user_id IS NOT NULL; + +-- One personal channel per (owner, type). Globals (NULL owner) unaffected. +CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq + ON notification_channels (owner_user_id, channel_type) + WHERE owner_user_id IS NOT NULL; + +COMMENT ON COLUMN notification_channels.owner_user_id IS + 'Personal channel owner. NULL = global channel (admin-managed). NOT NULL = personal channel reachable only by the owner and admins.'; diff --git a/migrations/086_notify_event_keys_and_subscriptions.sql b/migrations/086_notify_event_keys_and_subscriptions.sql new file mode 100644 index 0000000..5b933f2 --- /dev/null +++ b/migrations/086_notify_event_keys_and_subscriptions.sql @@ -0,0 +1,46 @@ +-- ============================================================================= +-- Notify event taxonomy + per-user subscriptions (Phase 9 — SUB-01, SUB-02) +-- ============================================================================= +-- notify_event_keys: humanization layer for the event keys that appear in +-- pipeline_steps.config->'route_to_user'->>'event_key'. Admin-managed at +-- /admin/workflow/event-keys. NOT a gate — keys not in this table are +-- still routable; they just render with the raw key as their label. +-- +-- user_event_subscriptions: opt-out matrix per (user, event_key, channel_type). +-- Row absence = enabled (D-15 default-enabled / opt-out). +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS notify_event_keys ( + key TEXT PRIMARY KEY, + display_label TEXT NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notify_event_keys_active + ON notify_event_keys(is_active, sort_order, key); + +CREATE TABLE IF NOT EXISTS user_event_subscriptions ( + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, + channel_type VARCHAR(20) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, event_key, channel_type) +); + +CREATE INDEX IF NOT EXISTS idx_user_event_subs_user + ON user_event_subscriptions(user_id); + +COMMENT ON TABLE notify_event_keys IS + 'Humanization layer for event keys used in notify-step route_to_user blocks. Admins curate display_label/description/sort. Not a gate — unknown keys are still routable.'; +COMMENT ON TABLE user_event_subscriptions IS + 'Opt-out matrix. Row absence = enabled. Composite PK enforces one row per (user, event_key, channel_type).'; + +-- Seed an example row so the admin UI is not empty on first run. +INSERT INTO notify_event_keys (key, display_label, description, sort_order, is_active) +VALUES ('ticket_assigned_to_me', 'Ticket assigned to me', 'Fires when an Autotask ticket is assigned to your resource.', 10, true) +ON CONFLICT (key) DO NOTHING;