Add notifications

This commit is contained in:
Arthur Belleville 2025-11-16 12:55:30 +01:00
parent a49dec34ff
commit 766364def4
No known key found for this signature in database
4 changed files with 1185 additions and 4 deletions

View file

@ -0,0 +1,331 @@
-- Create notifications table
CREATE TABLE IF NOT EXISTS public.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action_type TEXT NOT NULL CHECK (action_type IN ('created', 'updated')),
message TEXT NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE public.notifications IS 'Stores user notifications triggered by changes to major tables';
COMMENT ON COLUMN public.notifications.user_id IS 'Recipient of the notification';
COMMENT ON COLUMN public.notifications.actor_id IS 'User who triggered the action (can be NULL if user deleted)';
COMMENT ON COLUMN public.notifications.entity_type IS 'Type of entity: tablo, task, event, note, tablo_access, tablo_invite';
COMMENT ON COLUMN public.notifications.entity_id IS 'ID of the entity that changed';
COMMENT ON COLUMN public.notifications.action_type IS 'Operation performed: created or updated';
COMMENT ON COLUMN public.notifications.message IS 'Human-readable notification message';
COMMENT ON COLUMN public.notifications.metadata IS 'Additional context as JSONB (old/new values, etc.)';
COMMENT ON COLUMN public.notifications.read_at IS 'When the user read the notification (NULL = unread)';
-- Create indexes for efficient querying
CREATE INDEX idx_notifications_user_id ON public.notifications(user_id);
CREATE INDEX idx_notifications_entity_type ON public.notifications(entity_type);
CREATE INDEX idx_notifications_entity_id ON public.notifications(entity_id);
CREATE INDEX idx_notifications_read_at ON public.notifications(read_at) WHERE read_at IS NULL;
CREATE INDEX idx_notifications_created_at ON public.notifications(created_at DESC);
-- Enable Row Level Security
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
-- RLS Policy: Users can only view their own notifications
CREATE POLICY "Users can view their own notifications"
ON public.notifications
FOR SELECT
USING (auth.uid() = user_id);
-- RLS Policy: Users can mark their own notifications as read
CREATE POLICY "Users can mark their own notifications as read"
ON public.notifications
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id AND read_at IS NOT NULL);
-- Create a single generic trigger function to handle all table notifications
CREATE OR REPLACE FUNCTION public.notify_users()
RETURNS TRIGGER AS $$
DECLARE
affected_users UUID[];
affected_user UUID;
actor UUID;
action TEXT;
msg TEXT;
meta JSONB;
entity_type_name TEXT;
entity_identifier TEXT;
BEGIN
-- Determine if this is an INSERT or UPDATE
IF TG_OP = 'INSERT' THEN
action := 'created';
ELSE
action := 'updated';
-- Skip if soft delete happened (only for tables with deleted_at column)
IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
RETURN NEW;
END IF;
END IF;
END IF;
-- Get the actor (current user)
actor := auth.uid();
-- Set entity type and ID based on the table
entity_type_name := TG_TABLE_NAME;
-- Determine entity ID and affected users based on table
CASE TG_TABLE_NAME
WHEN 'tablos' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New tablo "' || NEW.name || '" was created';
-- Notify owner (but not if they are the creator)
IF NEW.owner_id != actor THEN
affected_users := ARRAY[NEW.owner_id];
END IF;
ELSE
msg := 'Tablo "' || NEW.name || '" was updated';
-- Notify owner and all collaborators
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.id
AND is_active = true
AND user_id != actor
UNION
SELECT NEW.owner_id
WHERE NEW.owner_id != actor
);
END IF;
meta := jsonb_build_object(
'tablo_name', NEW.name,
'status', NEW.status,
'color', NEW.color
);
WHEN 'tasks' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New task "' || NEW.title || '" was created';
ELSE
IF OLD.status != NEW.status THEN
msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status;
ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN
msg := 'You were assigned to task "' || NEW.title || '"';
ELSE
msg := 'Task "' || NEW.title || '" was updated';
END IF;
END IF;
-- Notify tablo collaborators and assignee
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.tablo_id
AND is_active = true
AND user_id != actor
UNION
SELECT t.owner_id
FROM public.tablos t
WHERE t.id = NEW.tablo_id
AND t.owner_id != actor
UNION
SELECT NEW.assignee_id
WHERE NEW.assignee_id IS NOT NULL
AND NEW.assignee_id != actor
);
meta := jsonb_build_object(
'task_title', NEW.title,
'status', NEW.status,
'tablo_id', NEW.tablo_id,
'assignee_id', NEW.assignee_id
);
WHEN 'events' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New event "' || NEW.title || '" was created';
ELSE
msg := 'Event "' || NEW.title || '" was updated';
END IF;
-- Notify tablo collaborators
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.tablo_access
WHERE tablo_id = NEW.tablo_id
AND is_active = true
AND user_id != actor
UNION
SELECT t.owner_id
FROM public.tablos t
WHERE t.id = NEW.tablo_id
AND t.owner_id != actor
UNION
SELECT NEW.created_by
WHERE NEW.created_by != actor
);
meta := jsonb_build_object(
'event_title', NEW.title,
'start_date', NEW.start_date,
'start_time', NEW.start_time,
'tablo_id', NEW.tablo_id
);
WHEN 'notes' THEN
entity_identifier := NEW.id;
IF TG_OP = 'INSERT' THEN
msg := 'New note "' || NEW.title || '" was created';
ELSE
msg := 'Note "' || NEW.title || '" was updated';
END IF;
-- Notify note owner and users with access
affected_users := ARRAY(
SELECT DISTINCT user_id
FROM public.note_access
WHERE note_id = NEW.id
AND is_active = true
AND user_id != actor
UNION
SELECT NEW.user_id
WHERE NEW.user_id != actor
);
meta := jsonb_build_object(
'note_title', NEW.title,
'note_owner', NEW.user_id
);
WHEN 'tablo_access' THEN
entity_identifier := NEW.id::TEXT;
IF TG_OP = 'INSERT' THEN
msg := 'You were granted access to a tablo';
ELSE
IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN
msg := 'You were promoted to admin on a tablo';
ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN
msg := 'Your access to a tablo was revoked';
ELSE
msg := 'Your tablo access was updated';
END IF;
END IF;
-- Notify the user being granted/modified access
IF NEW.user_id != actor THEN
affected_users := ARRAY[NEW.user_id];
END IF;
meta := jsonb_build_object(
'tablo_id', NEW.tablo_id,
'is_admin', NEW.is_admin,
'is_active', NEW.is_active,
'granted_by', NEW.granted_by
);
WHEN 'tablo_invites' THEN
entity_identifier := NEW.id::TEXT;
IF TG_OP = 'INSERT' THEN
msg := 'You were invited to collaborate on a tablo';
ELSE
IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN
msg := 'Your tablo invitation status changed';
ELSE
msg := 'Your tablo invitation was updated';
END IF;
END IF;
-- Find the user by email and notify them
affected_users := ARRAY(
SELECT id
FROM auth.users
WHERE email = NEW.invited_email
AND id != actor
);
meta := jsonb_build_object(
'tablo_id', NEW.tablo_id,
'invited_email', NEW.invited_email,
'invited_by', NEW.invited_by,
'is_pending', NEW.is_pending
);
ELSE
-- Unknown table, skip
RETURN NEW;
END CASE;
-- Insert notifications for all affected users
IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN
FOREACH affected_user IN ARRAY affected_users
LOOP
INSERT INTO public.notifications (
user_id,
actor_id,
entity_type,
entity_id,
action_type,
message,
metadata
) VALUES (
affected_user,
actor,
entity_type_name,
entity_identifier,
action,
msg,
meta
);
END LOOP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
COMMENT ON FUNCTION public.notify_users() IS 'Single trigger function that handles notifications for all major tables';
-- Create triggers on all major tables using the same function
CREATE TRIGGER notify_users_on_tablos
AFTER INSERT OR UPDATE ON public.tablos
FOR EACH ROW
EXECUTE FUNCTION public.notify_users();
CREATE TRIGGER notify_users_on_tasks
AFTER INSERT OR UPDATE ON public.tasks
FOR EACH ROW
EXECUTE FUNCTION public.notify_users();
CREATE TRIGGER notify_users_on_events
AFTER INSERT OR UPDATE ON public.events
FOR EACH ROW
EXECUTE FUNCTION public.notify_users();
CREATE TRIGGER notify_users_on_notes
AFTER INSERT OR UPDATE ON public.notes
FOR EACH ROW
EXECUTE FUNCTION public.notify_users();
CREATE TRIGGER notify_users_on_tablo_access
AFTER INSERT OR UPDATE ON public.tablo_access
FOR EACH ROW
EXECUTE FUNCTION public.notify_users();
CREATE TRIGGER notify_users_on_tablo_invites
AFTER INSERT OR UPDATE ON public.tablo_invites
FOR EACH ROW
EXECUTE FUNCTION public.notify_users();

View file

@ -9,7 +9,7 @@ This directory contains comprehensive pgTAP tests for the Xtablo database, cover
## Test Files
The tests are organized into 8 files, covering different aspects of the database:
The tests are organized into 10 files, covering different aspects of the database:
1. **01_schema_structure.test.sql** - Tests table existence, columns, data types, and basic constraints
2. **02_rls_policies_core.test.sql** - Tests RLS policies for core tables (tablos, tablo_access, tablo_invites)
@ -19,6 +19,8 @@ The tests are organized into 8 files, covering different aspects of the database
6. **06_stripe_functions.test.sql** - Tests Stripe integration functions and security
7. **07_views.test.sql** - Tests database views and their behavior
8. **08_indexes_performance.test.sql** - Tests index coverage and performance optimizations
9. **09_notifications.test.sql** - Tests notifications table structure, triggers, and trigger behavior
10. **10_rls_policies_notifications.test.sql** - Tests RLS policies for notifications table
## Running Tests
@ -47,6 +49,7 @@ supabase test db --verbose
## Test Coverage
### Tables Tested
- profiles
- feedbacks
- tablos
@ -56,32 +59,40 @@ supabase test db --verbose
- notes
- shared_notes
- note_access
- notifications
### RLS Policies Tested
- ✅ User isolation and access control
- ✅ Tablo ownership and sharing
- ✅ Note privacy and public sharing
- ✅ Event access based on tablo permissions
- ✅ Feedback insertion restrictions
- ✅ Notification visibility and update permissions
### Triggers Tested
- ✅ Auto-creation of tablo_access for owners
- ✅ Profile last_signed_in updates
- ✅ Tablo invite status updates on login
- ✅ Stripe subscription profile updates
- ✅ Notification creation for all major table changes (tablos, tasks, events, notes, tablo_access, tablo_invites)
### Functions Tested
- ✅ Stripe customer and subscription queries
- ✅ User payment status checks
- ✅ Security definer permissions
- ✅ Active subscription retrieval
### Views Tested
- ✅ user_tablos view with access levels
- ✅ active_subscriptions view
- ✅ Security invoker settings
### Indexes Tested
- ✅ Foreign key indexes
- ✅ Query optimization indexes
- ✅ Unique constraints
@ -95,8 +106,10 @@ After running tests, you'll see output like:
supabase/tests/database/01_schema_structure.test.sql .. ok
supabase/tests/database/02_rls_policies_core.test.sql .. ok
supabase/tests/database/03_rls_policies_notes.test.sql .. ok
supabase/tests/database/09_notifications.test.sql .. ok
supabase/tests/database/10_rls_policies_notifications.test.sql .. ok
All tests successful.
Files=8, Tests=295, 5 wallclock secs
Files=10, Tests=374, 1 wallclock secs
Result: PASS
```
@ -105,6 +118,7 @@ Result: PASS
### Test Failures
If tests fail, check:
1. All migrations have been applied to your local database
2. The stripe schema exists (from stripe-sync-engine)
3. The pgTAP extension is installed
@ -131,6 +145,7 @@ supabase test db
## Writing New Tests
When adding new features:
1. Add schema tests to `01_schema_structure.test.sql`
2. Add RLS policy tests to the appropriate RLS test file
3. Add trigger/function tests to `05_triggers.test.sql` or `06_stripe_functions.test.sql`
@ -140,7 +155,7 @@ Use the pgTAP documentation for available test functions: https://pgtap.org/docu
## Total Test Count
- **295 tests** across 8 test files
- **374 tests** across 10 test files (63 notifications schema/triggers + 19 notifications RLS)
- Comprehensive coverage of all database components
- Security-focused testing for RLS and permissions
- Full notification system coverage including triggers and RLS

View file

@ -0,0 +1,599 @@
begin;
select plan(63); -- Total number of tests (54 original + 9 multiple notifications tests)
-- ============================================================================
-- Table Existence Tests
-- ============================================================================
SELECT has_table('public', 'notifications', 'notifications table should exist');
-- ============================================================================
-- Notifications Table Structure
-- ============================================================================
SELECT has_column('public', 'notifications', 'id', 'notifications should have id column');
SELECT has_column('public', 'notifications', 'user_id', 'notifications should have user_id column');
SELECT has_column('public', 'notifications', 'actor_id', 'notifications should have actor_id column');
SELECT has_column('public', 'notifications', 'entity_type', 'notifications should have entity_type column');
SELECT has_column('public', 'notifications', 'entity_id', 'notifications should have entity_id column');
SELECT has_column('public', 'notifications', 'action_type', 'notifications should have action_type column');
SELECT has_column('public', 'notifications', 'message', 'notifications should have message column');
SELECT has_column('public', 'notifications', 'metadata', 'notifications should have metadata column');
SELECT has_column('public', 'notifications', 'read_at', 'notifications should have read_at column');
SELECT has_column('public', 'notifications', 'created_at', 'notifications should have created_at column');
SELECT col_type_is('public', 'notifications', 'id', 'uuid', 'notifications.id should be uuid');
SELECT col_type_is('public', 'notifications', 'user_id', 'uuid', 'notifications.user_id should be uuid');
SELECT col_type_is('public', 'notifications', 'actor_id', 'uuid', 'notifications.actor_id should be uuid');
SELECT col_type_is('public', 'notifications', 'entity_type', 'text', 'notifications.entity_type should be text');
SELECT col_type_is('public', 'notifications', 'entity_id', 'text', 'notifications.entity_id should be text');
SELECT col_type_is('public', 'notifications', 'action_type', 'text', 'notifications.action_type should be text');
SELECT col_type_is('public', 'notifications', 'message', 'text', 'notifications.message should be text');
SELECT col_type_is('public', 'notifications', 'metadata', 'jsonb', 'notifications.metadata should be jsonb');
SELECT col_type_is('public', 'notifications', 'read_at', 'timestamp with time zone', 'notifications.read_at should be timestamptz');
SELECT col_type_is('public', 'notifications', 'created_at', 'timestamp with time zone', 'notifications.created_at should be timestamptz');
SELECT col_not_null('public', 'notifications', 'id', 'notifications.id should be NOT NULL');
SELECT col_not_null('public', 'notifications', 'user_id', 'notifications.user_id should be NOT NULL');
SELECT col_not_null('public', 'notifications', 'entity_type', 'notifications.entity_type should be NOT NULL');
SELECT col_not_null('public', 'notifications', 'entity_id', 'notifications.entity_id should be NOT NULL');
SELECT col_not_null('public', 'notifications', 'action_type', 'notifications.action_type should be NOT NULL');
SELECT col_not_null('public', 'notifications', 'message', 'notifications.message should be NOT NULL');
SELECT col_not_null('public', 'notifications', 'created_at', 'notifications.created_at should be NOT NULL');
SELECT col_has_default('public', 'notifications', 'id', 'notifications.id should have default');
SELECT col_has_default('public', 'notifications', 'metadata', 'notifications.metadata should have default');
SELECT col_has_default('public', 'notifications', 'created_at', 'notifications.created_at should have default');
-- Check action_type constraint
SELECT ok(
(SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'public'
AND constraint_name LIKE '%action_type%') > 0,
'notifications.action_type should have check constraint'
);
-- ============================================================================
-- Index Existence Tests
-- ============================================================================
SELECT ok(
(SELECT COUNT(*) FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'notifications'
AND indexname = 'idx_notifications_user_id') > 0,
'Index idx_notifications_user_id should exist'
);
SELECT ok(
(SELECT COUNT(*) FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'notifications'
AND indexname = 'idx_notifications_entity_type') > 0,
'Index idx_notifications_entity_type should exist'
);
SELECT ok(
(SELECT COUNT(*) FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'notifications'
AND indexname = 'idx_notifications_entity_id') > 0,
'Index idx_notifications_entity_id should exist'
);
SELECT ok(
(SELECT COUNT(*) FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'notifications'
AND indexname = 'idx_notifications_read_at') > 0,
'Index idx_notifications_read_at should exist'
);
SELECT ok(
(SELECT COUNT(*) FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'notifications'
AND indexname = 'idx_notifications_created_at') > 0,
'Index idx_notifications_created_at should exist'
);
-- ============================================================================
-- Trigger Function Tests
-- ============================================================================
SELECT has_function('public', 'notify_users', 'Function notify_users should exist');
-- ============================================================================
-- Trigger Existence Tests
-- ============================================================================
SELECT has_trigger('public', 'tablos', 'notify_users_on_tablos',
'Trigger notify_users_on_tablos should exist on tablos table');
SELECT has_trigger('public', 'tasks', 'notify_users_on_tasks',
'Trigger notify_users_on_tasks should exist on tasks table');
SELECT has_trigger('public', 'events', 'notify_users_on_events',
'Trigger notify_users_on_events should exist on events table');
SELECT has_trigger('public', 'notes', 'notify_users_on_notes',
'Trigger notify_users_on_notes should exist on notes table');
SELECT has_trigger('public', 'tablo_access', 'notify_users_on_tablo_access',
'Trigger notify_users_on_tablo_access should exist on tablo_access table');
SELECT has_trigger('public', 'tablo_invites', 'notify_users_on_tablo_invites',
'Trigger notify_users_on_tablo_invites should exist on tablo_invites table');
-- ============================================================================
-- Trigger Behavior Tests - Setup Test Data
-- ============================================================================
DO $$
DECLARE
test_user1_id uuid := gen_random_uuid();
test_user2_id uuid := gen_random_uuid();
test_tablo_id text;
test_task_id text;
test_event_id text;
test_note_id text;
BEGIN
-- Insert test users
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(test_user1_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest1_' || test_user1_id::text || '@test.com', 'encrypted', now(), now(), now()),
(test_user2_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest2_' || test_user2_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
-- Insert test profiles
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(test_user1_id, 'notiftest1_' || test_user1_id::text || '@test.com', 'NotifTest', 'User1', substring(test_user1_id::text from 1 for 8)),
(test_user2_id, 'notiftest2_' || test_user2_id::text || '@test.com', 'NotifTest', 'User2', substring(test_user2_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
-- Set auth context to user1
PERFORM set_config('request.jwt.claims', json_build_object('sub', test_user1_id::text)::text, true);
-- Create a tablo as user1
INSERT INTO public.tablos (owner_id, name, status, position)
VALUES (test_user1_id, 'Notification Test Tablo', 'todo', 0)
RETURNING id INTO test_tablo_id;
-- Grant access to user2
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
VALUES (test_tablo_id, test_user2_id, test_user1_id, true, false);
-- Create a task
INSERT INTO public.tasks (tablo_id, title, description, status, assignee_id, position)
VALUES (test_tablo_id, 'Test Task', 'Test Description', 'todo', test_user2_id, 0)
RETURNING id INTO test_task_id;
-- Create an event
INSERT INTO public.events (tablo_id, title, description, start_date, start_time, created_by)
VALUES (test_tablo_id, 'Test Event', 'Test Event Description', CURRENT_DATE, '10:00', test_user1_id)
RETURNING id INTO test_event_id;
-- Create a note
INSERT INTO public.notes (user_id, title, content)
VALUES (test_user1_id, 'Test Note', 'Test Note Content')
RETURNING id INTO test_note_id;
-- Share note with user2
INSERT INTO public.note_access (note_id, user_id, is_active)
VALUES (test_note_id, test_user2_id, true);
-- Store test IDs for use in tests
PERFORM set_config('test.user1_id', test_user1_id::text, true);
PERFORM set_config('test.user2_id', test_user2_id::text, true);
PERFORM set_config('test.tablo_id', test_tablo_id, true);
PERFORM set_config('test.task_id', test_task_id, true);
PERFORM set_config('test.event_id', test_event_id, true);
PERFORM set_config('test.note_id', test_note_id, true);
END $$;
-- ============================================================================
-- Tablo Trigger Tests
-- ============================================================================
-- Update tablo and check notification for user2
DO $$
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
UPDATE public.tablos
SET name = 'Updated Test Tablo'
WHERE id = current_setting('test.tablo_id');
END $$;
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'tablos'
AND entity_id = current_setting('test.tablo_id')
AND user_id = current_setting('test.user2_id')::uuid
AND action_type = 'updated') > 0,
'Notification should be created for collaborator when tablo is updated'
);
-- ============================================================================
-- Task Trigger Tests
-- ============================================================================
-- Update task status
DO $$
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
UPDATE public.tasks
SET status = 'in_progress'
WHERE id = current_setting('test.task_id');
END $$;
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'tasks'
AND entity_id = current_setting('test.task_id')
AND action_type = 'updated') > 0,
'Notification should be created when task is updated'
);
-- ============================================================================
-- Event Trigger Tests
-- ============================================================================
-- Update event
DO $$
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
UPDATE public.events
SET title = 'Updated Test Event'
WHERE id = current_setting('test.event_id');
END $$;
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'events'
AND entity_id = current_setting('test.event_id')
AND action_type = 'updated') > 0,
'Notification should be created when event is updated'
);
-- ============================================================================
-- Note Trigger Tests
-- ============================================================================
-- Update note
DO $$
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
UPDATE public.notes
SET title = 'Updated Test Note'
WHERE id = current_setting('test.note_id');
END $$;
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'notes'
AND entity_id = current_setting('test.note_id')
AND action_type = 'updated') > 0,
'Notification should be created when note is updated'
);
-- ============================================================================
-- Tablo Access Trigger Tests
-- ============================================================================
-- Grant access to a new user and verify notification
DO $$
DECLARE
test_user3_id uuid := gen_random_uuid();
BEGIN
-- Insert test user3
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(test_user3_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest3_' || test_user3_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(test_user3_id, 'notiftest3_' || test_user3_id::text || '@test.com', 'NotifTest', 'User3', substring(test_user3_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
PERFORM set_config('test.user3_id', test_user3_id::text, true);
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
-- Grant access
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
VALUES (current_setting('test.tablo_id'), test_user3_id, current_setting('test.user1_id')::uuid, true, false);
END $$;
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'tablo_access'
AND user_id = current_setting('test.user3_id')::uuid
AND action_type = 'created') > 0,
'Notification should be created when user is granted tablo access'
);
-- ============================================================================
-- Tablo Invite Trigger Tests
-- ============================================================================
-- Create an invite
DO $$
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
VALUES (
current_setting('test.tablo_id'),
'notiftest2_' || current_setting('test.user2_id') || '@test.com',
current_setting('test.user1_id')::uuid,
'test-token-' || gen_random_uuid()::text,
true
);
END $$;
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE entity_type = 'tablo_invites'
AND user_id = current_setting('test.user2_id')::uuid) > 0,
'Notification should be created when user is invited'
);
-- ============================================================================
-- Multiple Notifications Tests (Exact Count Verification)
-- ============================================================================
-- Setup: Create additional users with access to test tablo
DO $$
DECLARE
test_user4_id uuid := gen_random_uuid();
test_user5_id uuid := gen_random_uuid();
BEGIN
-- Insert test users 4 and 5
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(test_user4_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest4_' || test_user4_id::text || '@test.com', 'encrypted', now(), now(), now()),
(test_user5_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest5_' || test_user5_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(test_user4_id, 'notiftest4_' || test_user4_id::text || '@test.com', 'NotifTest', 'User4', substring(test_user4_id::text from 1 for 8)),
(test_user5_id, 'notiftest5_' || test_user5_id::text || '@test.com', 'NotifTest', 'User5', substring(test_user5_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
-- Grant access to users 4 and 5 on the test tablo
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
VALUES
(current_setting('test.tablo_id'), test_user4_id, current_setting('test.user1_id')::uuid, true, false),
(current_setting('test.tablo_id'), test_user5_id, current_setting('test.user1_id')::uuid, true, false);
PERFORM set_config('test.user4_id', test_user4_id::text, true);
PERFORM set_config('test.user5_id', test_user5_id::text, true);
END $$;
-- Test: Multiple users notified when tablo is updated
-- Expected: user2, user3, user4, user5 should be notified (4 notifications), but NOT user1 (the actor)
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
-- Count notifications before update
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- Update tablo (user1 is the actor)
UPDATE public.tablos
SET name = 'Tablo Updated for Multiple Users Test'
WHERE id = current_setting('test.tablo_id');
-- Count notifications after update
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.multi_notif_count', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.multi_notif_count', true)::integer,
4,
'Exactly 4 notifications should be created when tablo with 4 collaborators (excluding actor) is updated'
);
-- Test: Verify each specific user received the notification
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE entity_type = 'tablos'
AND entity_id = current_setting('test.tablo_id')
AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated'
AND user_id = current_setting('test.user2_id')::uuid),
1,
'User2 should receive exactly 1 notification for tablo update'
);
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE entity_type = 'tablos'
AND entity_id = current_setting('test.tablo_id')
AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated'
AND user_id = current_setting('test.user4_id')::uuid),
1,
'User4 should receive exactly 1 notification for tablo update'
);
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE entity_type = 'tablos'
AND entity_id = current_setting('test.tablo_id')
AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated'
AND user_id = current_setting('test.user5_id')::uuid),
1,
'User5 should receive exactly 1 notification for tablo update'
);
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE entity_type = 'tablos'
AND entity_id = current_setting('test.tablo_id')
AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated'
AND user_id = current_setting('test.user3_id')::uuid),
1,
'User3 should receive exactly 1 notification for tablo update'
);
-- Test: Actor (user1) should NOT receive a notification
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE entity_type = 'tablos'
AND entity_id = current_setting('test.tablo_id')
AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated'
AND actor_id = current_setting('test.user1_id')::uuid
AND user_id = current_setting('test.user1_id')::uuid),
0,
'Actor should not receive notification for their own action'
);
-- Test: Multiple users notified when new task is created
-- Expected: user1 (owner), user2, user4, user5 should be notified, but NOT user3 (who created the task)
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
new_task_id text;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user3_id'))::text, true);
-- Count notifications before creating task
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- User3 creates a new task
INSERT INTO public.tasks (tablo_id, title, description, status, position)
VALUES (current_setting('test.tablo_id'), 'Multi-User Test Task', 'Task to test multiple notifications', 'todo', 1)
RETURNING id INTO new_task_id;
-- Count notifications after creating task
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.multi_task_notif_count', (notif_count_after - notif_count_before)::text, true);
PERFORM set_config('test.multi_task_id', new_task_id, true);
END $$;
SELECT is(
current_setting('test.multi_task_notif_count', true)::integer,
4,
'Exactly 4 notifications should be created when task is added to tablo with 4 collaborators (owner + 3 others, excluding actor)'
);
-- Test: Multiple users notified when task is assigned
-- Expected: Assignee (user5) + owner (user1) + collaborators (user2, user4) should be notified, but NOT user3 (the actor)
DO $$
DECLARE
notif_count_before bigint;
notif_count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user3_id'))::text, true);
-- Count notifications before update
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
-- User3 assigns the task to user5 (without changing status to avoid message priority issues)
UPDATE public.tasks
SET assignee_id = current_setting('test.user5_id')::uuid
WHERE id = current_setting('test.multi_task_id');
-- Count notifications after update
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
PERFORM set_config('test.task_assign_notif_count', (notif_count_after - notif_count_before)::text, true);
END $$;
SELECT is(
current_setting('test.task_assign_notif_count', true)::integer,
4,
'Exactly 4 notifications should be created when task is assigned (owner + assignee + 2 other collaborators, excluding actor)'
);
-- Test: Assignee (user5) receives notification about being assigned
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE entity_type = 'tasks'
AND entity_id = current_setting('test.multi_task_id')
AND user_id = current_setting('test.user5_id')::uuid
AND message LIKE '%assigned%'),
1,
'Assignee should receive exactly 1 notification about task assignment'
);
-- ============================================================================
-- Metadata and Message Tests
-- ============================================================================
-- Test: Notification metadata should be valid JSONB
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE jsonb_typeof(metadata) = 'object') > 0,
'Notification metadata should be valid JSONB objects'
);
-- Test: Messages should be non-empty
SELECT ok(
(SELECT COUNT(*) FROM public.notifications WHERE length(message) = 0) = 0,
'All notification messages should be non-empty'
);
-- Test: Action types should be valid
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE action_type NOT IN ('created', 'updated')) = 0,
'All notifications should have valid action_type (created or updated)'
);
-- ============================================================================
-- Edge Cases
-- ============================================================================
-- Test: Soft delete should not create notification
DO $$
DECLARE
soft_delete_tablo_id text;
count_before bigint;
count_after bigint;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
-- Create a tablo and immediately soft delete it
INSERT INTO public.tablos (owner_id, name, status, position)
VALUES (current_setting('test.user1_id')::uuid, 'Soft Delete Test', 'todo', 0)
RETURNING id INTO soft_delete_tablo_id;
-- Count notifications before soft delete
SELECT COUNT(*) INTO count_before
FROM public.notifications
WHERE entity_id = soft_delete_tablo_id;
-- Soft delete
UPDATE public.tablos
SET deleted_at = now()
WHERE id = soft_delete_tablo_id;
-- Count notifications after soft delete
SELECT COUNT(*) INTO count_after
FROM public.notifications
WHERE entity_id = soft_delete_tablo_id;
-- Store results
PERFORM set_config('test.notif_count_before', count_before::text, true);
PERFORM set_config('test.notif_count_after', count_after::text, true);
END $$;
SELECT is(
current_setting('test.notif_count_before', true),
current_setting('test.notif_count_after', true),
'Soft delete should not create additional notifications'
);
select * from finish();
rollback;

View file

@ -0,0 +1,236 @@
begin;
select plan(19); -- Total number of tests
-- ============================================================================
-- RLS Enabled Test
-- ============================================================================
SELECT is(
(SELECT relrowsecurity FROM pg_class WHERE relname = 'notifications' AND relnamespace = 'public'::regnamespace),
true,
'RLS should be enabled on notifications table'
);
-- ============================================================================
-- Notifications Table RLS Policies
-- ============================================================================
SELECT ok(
(SELECT COUNT(*) FROM pg_policies WHERE tablename = 'notifications' AND policyname = 'Users can view their own notifications') > 0,
'Policy for viewing own notifications should exist'
);
SELECT ok(
(SELECT COUNT(*) FROM pg_policies WHERE tablename = 'notifications' AND policyname = 'Users can mark their own notifications as read') > 0,
'Policy for marking notifications as read should exist'
);
-- Test policy commands
SELECT is(
(SELECT cmd FROM pg_policies WHERE tablename = 'notifications' AND policyname = 'Users can view their own notifications' LIMIT 1),
'SELECT',
'View notifications policy should be for SELECT'
);
SELECT is(
(SELECT cmd FROM pg_policies WHERE tablename = 'notifications' AND policyname = 'Users can mark their own notifications as read' LIMIT 1),
'UPDATE',
'Mark as read policy should be for UPDATE'
);
-- Test policy uses auth.uid() for isolation
SELECT ok(
(SELECT qual::text FROM pg_policies
WHERE tablename = 'notifications'
AND policyname = 'Users can view their own notifications'
LIMIT 1) LIKE '%auth.uid()%',
'SELECT policy should use auth.uid() for user isolation'
);
-- Test UPDATE policy allows updating read_at
SELECT ok(
(SELECT with_check::text FROM pg_policies
WHERE tablename = 'notifications'
AND policyname = 'Users can mark their own notifications as read'
LIMIT 1) LIKE '%read_at%',
'UPDATE policy should check read_at field'
);
-- ============================================================================
-- Notifications Behavior Tests with Mock Data
-- ============================================================================
-- Create test users and generate some notifications
DO $$
DECLARE
user1_id uuid := gen_random_uuid();
user2_id uuid := gen_random_uuid();
test_tablo_id text;
BEGIN
-- Insert test users
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(user1_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notifrlsuser1_' || user1_id::text || '@test.com', 'encrypted', now(), now(), now()),
(user2_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notifrlsuser2_' || user2_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
-- Insert test profiles
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(user1_id, 'notifrlsuser1_' || user1_id::text || '@test.com', 'NotifRLS', 'User1', substring(user1_id::text from 1 for 8)),
(user2_id, 'notifrlsuser2_' || user2_id::text || '@test.com', 'NotifRLS', 'User2', substring(user2_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
-- Set auth context to user1 for creating test data
PERFORM set_config('request.jwt.claims', json_build_object('sub', user1_id::text)::text, true);
-- Create a tablo as user1
INSERT INTO public.tablos (owner_id, name, status, position)
VALUES (user1_id, 'RLS Test Tablo', 'todo', 0)
RETURNING id INTO test_tablo_id;
-- Grant access to user2
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
VALUES (test_tablo_id, user2_id, user1_id, true, false);
-- Update tablo to trigger notification for user2
UPDATE public.tablos
SET name = 'RLS Test Tablo Updated'
WHERE id = test_tablo_id;
-- Store test IDs
PERFORM set_config('test.rls_user1_id', user1_id::text, true);
PERFORM set_config('test.rls_user2_id', user2_id::text, true);
PERFORM set_config('test.rls_tablo_id', test_tablo_id, true);
END $$;
-- Test: Notifications exist for test users
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE user_id = current_setting('test.rls_user2_id')::uuid) > 0,
'Notifications should exist for test user2'
);
-- Test: User2 received notification about tablo update
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE user_id = current_setting('test.rls_user2_id')::uuid
AND entity_type = 'tablos'
AND entity_id = current_setting('test.rls_tablo_id')) > 0,
'User2 should have notification for tablo update'
);
-- Test: Actor (user1) did not receive notification for their own action
SELECT is(
(SELECT COUNT(*)::integer FROM public.notifications
WHERE user_id = current_setting('test.rls_user1_id')::uuid
AND actor_id = current_setting('test.rls_user1_id')::uuid
AND entity_id = current_setting('test.rls_tablo_id')),
0,
'User should not receive notifications for their own actions'
);
-- Test: Notifications have proper structure
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE user_id = current_setting('test.rls_user2_id')::uuid
AND message IS NOT NULL
AND length(message) > 0) > 0,
'Notifications should have non-empty messages'
);
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE user_id = current_setting('test.rls_user2_id')::uuid
AND metadata IS NOT NULL
AND jsonb_typeof(metadata) = 'object') > 0,
'Notifications should have valid JSONB metadata'
);
SELECT ok(
(SELECT COUNT(*) FROM public.notifications
WHERE user_id = current_setting('test.rls_user2_id')::uuid
AND action_type IN ('created', 'updated')) > 0,
'Notifications should have valid action_type'
);
-- ============================================================================
-- Test Notification Updates (Mark as Read)
-- ============================================================================
-- Test: Update read_at on a notification
DO $$
DECLARE
test_notif_id uuid;
BEGIN
-- Get an unread notification for user2
SELECT id INTO test_notif_id
FROM public.notifications
WHERE user_id = current_setting('test.rls_user2_id')::uuid
AND read_at IS NULL
LIMIT 1;
IF test_notif_id IS NOT NULL THEN
-- Update the notification
UPDATE public.notifications
SET read_at = now()
WHERE id = test_notif_id;
PERFORM set_config('test.updated_notif_id', test_notif_id::text, true);
PERFORM set_config('test.notif_was_updated', 'true', true);
ELSE
PERFORM set_config('test.notif_was_updated', 'false', true);
PERFORM set_config('test.updated_notif_id', '00000000-0000-0000-0000-000000000000', true);
END IF;
END $$;
-- Verify the update worked
SELECT ok(
CASE
WHEN current_setting('test.notif_was_updated', true) = 'true' THEN
(SELECT read_at IS NOT NULL
FROM public.notifications
WHERE id = current_setting('test.updated_notif_id', true)::uuid
LIMIT 1)
ELSE true -- Skip if no notification was found
END,
'Notification should be updatable with read_at timestamp'
);
-- ============================================================================
-- Test Data Isolation
-- ============================================================================
-- Test: Count notifications for user1 vs user2
SELECT ok(
(SELECT COUNT(*) FROM public.notifications WHERE user_id = current_setting('test.rls_user1_id')::uuid) >= 0,
'User1 notifications count should be valid'
);
SELECT ok(
(SELECT COUNT(*) FROM public.notifications WHERE user_id = current_setting('test.rls_user2_id')::uuid) > 0,
'User2 should have at least one notification'
);
-- ============================================================================
-- Foreign Key Constraints Tests
-- ============================================================================
SELECT has_fk('public', 'notifications', 'notifications should have foreign key constraints');
-- Test that notifications.user_id references auth.users.id
SELECT fk_ok(
'public', 'notifications', 'user_id',
'auth', 'users', 'id',
'notifications.user_id should reference auth.users.id'
);
-- Test that notifications.actor_id references auth.users.id
SELECT fk_ok(
'public', 'notifications', 'actor_id',
'auth', 'users', 'id',
'notifications.actor_id should reference auth.users.id'
);
select * from finish();
rollback;