Every notification depended on Firebase Cloud Messaging, so a missing Firebase project, which is the current state, meant no notification could be delivered on any platform. Web Push and token-based Apple Push are now first class transports alongside FCM, chosen per registered device, and a scheduled Cloudflare Worker drain retries an outbox so a provider outage delays rather than drops a message.
148 lines
4.6 KiB
PL/PgSQL
148 lines
4.6 KiB
PL/PgSQL
-- ============================================================================
|
|
-- 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;
|