Notifications Database Schema
Understand the notifications table structure, Row Level Security policies, and how to extend the schema.
The notifications system uses a single table with Row Level Security. This page documents the schema, security policies, and extension patterns.
Table structure
create table if not exists public.notifications ( id bigint generated always as identity primary key, account_id uuid not null references public.accounts(id) on delete cascade, type public.notification_type not null default 'info', body varchar(5000) not null, link varchar(255), channel public.notification_channel not null default 'in_app', dismissed boolean not null default false, expires_at timestamptz default (now() + interval '1 month'), created_at timestamptz not null default now());Columns
| Column | Type | Default | Description |
|---|---|---|---|
id | bigint | Auto-generated | Primary key |
account_id | uuid | Required | Personal or team account ID |
type | notification_type | 'info' | Severity: info, warning, error |
body | varchar(5000) | Required | Message text or translation key |
link | varchar(255) | null | Optional URL for clickable notifications |
channel | notification_channel | 'in_app' | Delivery method |
dismissed | boolean | false | Has user dismissed this notification |
expires_at | timestamptz | Now + 1 month | Auto-expiration timestamp |
created_at | timestamptz | Now | Creation timestamp |
Enums
create type public.notification_type as enum('info', 'warning', 'error');create type public.notification_channel as enum('in_app', 'email');Row Level Security
Three key security constraints:
1. Read policy
Users can read notifications for their personal account or any team account they belong to:
create policy notifications_read_self on public.notificationsfor select to authenticated using ( account_id = (select auth.uid()) or has_role_on_account(account_id));2. Update policy
Users can update notifications they have access to:
create policy notifications_update_self on public.notificationsfor update to authenticated using ( account_id = (select auth.uid()) or has_role_on_account(account_id));3. Update trigger (dismissed only)
A trigger prevents updating any field except dismissed:
create or replace function kit.update_notification_dismissed_status()returns trigger set search_path to '' as $$begin old.dismissed := new.dismissed; if (new is distinct from old) then raise exception 'UPDATE of columns other than "dismissed" is forbidden'; end if; return old;end;$$ language plpgsql;create trigger update_notification_dismissed_statusbefore update on public.notificationsfor each row execute procedure kit.update_notification_dismissed_status();This ensures users cannot modify notification content, type, or other fields after creation.
Insert permissions
Only service_role can insert notifications:
revoke all on public.notifications from authenticated, service_role;grant select, update on table public.notifications to authenticated, service_role;grant insert on table public.notifications to service_role;This is why you must use getSupabaseServerAdminClient() when creating notifications.
Indexes
One composite index optimizes the common query pattern:
create index idx_notifications_account_dismissedon notifications (account_id, dismissed, expires_at);This index supports queries that filter by account, dismissed status, and expiration.
Realtime
The table is added to Supabase Realtime publication:
alter publication supabase_realtime add table public.notifications;This enables the real-time subscription feature when NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true.
Common modifications
Adding a read_at timestamp
Track when notifications were first viewed (not just dismissed):
alter table public.notificationsadd column read_at timestamptz default null;Update the trigger to allow read_at updates:
create or replace function kit.update_notification_status()returns trigger set search_path to '' as $$begin old.dismissed := new.dismissed; old.read_at := new.read_at; if (new is distinct from old) then raise exception 'UPDATE of columns other than "dismissed" and "read_at" is forbidden'; end if; return old;end;$$ language plpgsql;Adding notification categories
Extend with a category for filtering:
create type public.notification_category as enum( 'system', 'billing', 'team', 'content');alter table public.notificationsadd column category public.notification_category default 'system';Batch deletion of old notifications
Create a function to clean up expired notifications:
create or replace function kit.cleanup_expired_notifications()returns integerlanguage plpgsqlsecurity definerset search_path to ''as $$declare deleted_count integer;begin with deleted as ( delete from public.notifications where expires_at < now() returning id ) select count(*) into deleted_count from deleted; return deleted_count;end;$$;Call this from a cron job, Supabase Edge Function, or pg_cron extension.
Testing RLS policies
The schema includes pgTAP tests in apps/web/supabase/tests/database/notifications.test.sql:
-- Users cannot insert notificationsselect throws_ok( $$ insert into notifications (account_id, body) values ('...', 'test') $$, 'new row violates row-level security policy for table "notifications"');-- Service role can insertset role service_role;select lives_ok( $$ insert into notifications (account_id, body) values ('...', 'test') $$);Run tests with:
pnpm --filter web supabase test dbExpected output on success:
# Running: notifications.test.sqlok 1 - Users cannot insert notificationsok 2 - Service role can insert notificationsok 3 - Users can read their own notifications...Related documentation
- Notifications overview: Feature overview and when to use notifications
- Configuration: Environment variables and feature flags
- Sending notifications: Server-side API for creating notifications
- UI Components: Display notifications in your app