-- ============================================================================ -- Team activity feed -- ---------------------------------------------------------------------------- -- Adds a team-scoped activity/comment stream so members can see shared notes -- and system events (member changes, invites, shared meetings/documents). -- -- Follows the locked RPC write model established in -- 20260821000008_atomic_team_security.sql: team tables have no direct -- INSERT/UPDATE/DELETE policies, so all writes go through SECURITY DEFINER -- RPCs. Reads are RLS-filtered to team members via public.user_team_ids(). -- -- Also registers the team tables (and this one) with the realtime publication. -- Previously teams/team_members/team_invites were never added, so the mobile -- team Realtime subscriptions could not receive postgres_changes events. -- ============================================================================ BEGIN; CREATE TABLE IF NOT EXISTS public.team_activities ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE, actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, kind text NOT NULL CHECK ( kind IN ( 'note', 'member_joined', 'member_left', 'invite_created', 'meeting_shared', 'document_shared' ) ), body text CHECK (body IS NULL OR char_length(body) <= 2000), metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_team_activities_team_created ON public.team_activities (team_id, created_at DESC); ALTER TABLE public.team_activities ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS team_activities_read_member ON public.team_activities; CREATE POLICY team_activities_read_member ON public.team_activities FOR SELECT TO authenticated USING (team_id IN (SELECT public.user_team_ids(auth.uid()))); -- Writes are RPC-only: no INSERT/UPDATE/DELETE policies exist on purpose. GRANT SELECT ON public.team_activities TO authenticated; REVOKE INSERT, UPDATE, DELETE ON public.team_activities FROM PUBLIC, anon, authenticated; CREATE OR REPLACE FUNCTION public.create_team_activity( p_team_id uuid, p_kind text, p_body text, p_metadata jsonb DEFAULT '{}'::jsonb ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); member_role text; normalized_body text; new_id uuid; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF p_kind IS NULL OR p_kind NOT IN ( 'note', 'member_joined', 'member_left', 'invite_created', 'meeting_shared', 'document_shared' ) THEN RAISE EXCEPTION 'invalid_activity_kind' USING ERRCODE = '22023'; END IF; normalized_body := nullif(btrim(coalesce(p_body, '')), ''); IF normalized_body IS NOT NULL AND char_length(normalized_body) > 2000 THEN RAISE EXCEPTION 'activity_body_too_long' USING ERRCODE = '22001'; END IF; IF normalized_body IS NULL AND p_kind = 'note' THEN RAISE EXCEPTION 'activity_body_required' USING ERRCODE = '22023'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(p_team_id::text, 73053)); SELECT role INTO member_role FROM public.team_members WHERE team_id = p_team_id AND user_id = current_user_id; IF member_role IS NULL THEN RAISE EXCEPTION 'team_member_required' USING ERRCODE = '42501'; END IF; INSERT INTO public.team_activities (team_id, actor_id, kind, body, metadata) VALUES ( p_team_id, current_user_id, p_kind, normalized_body, coalesce(p_metadata, '{}'::jsonb) ) RETURNING id INTO new_id; RETURN jsonb_build_object( 'id', new_id, 'team_id', p_team_id, 'actor_id', current_user_id, 'kind', p_kind, 'body', normalized_body, 'created_at', now() ); END; $$; REVOKE ALL ON FUNCTION public.create_team_activity(uuid, text, text, jsonb) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.create_team_activity(uuid, text, text, jsonb) TO authenticated; -- Realtime: register team tables + activities. ALTER PUBLICATION ADD TABLE has -- no IF NOT EXISTS, so probe pg_publication_tables first. DO $$ DECLARE t text; BEGIN FOR t IN SELECT unnest(ARRAY[ 'team_activities', 'teams', 'team_members', 'team_invites' ]) LOOP IF NOT EXISTS ( SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = t ) THEN EXECUTE format( 'ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', t ); END IF; END LOOP; END $$; COMMIT;