feat(server): deliver push without a Firebase project

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.
This commit is contained in:
Yun Chan 2026-09-16 23:24:44 +09:00
parent 5aa268970a
commit bb0e54dcee
13 changed files with 1412 additions and 41 deletions

View file

@ -0,0 +1,148 @@
-- ============================================================================
-- 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;