@rocapine/community 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/lib/commands/adopt.d.ts +38 -0
  4. package/lib/commands/adopt.js +105 -0
  5. package/lib/commands/init.d.ts +29 -0
  6. package/lib/commands/init.js +152 -0
  7. package/lib/commands/upgrade.d.ts +33 -0
  8. package/lib/commands/upgrade.js +280 -0
  9. package/lib/index.d.ts +2 -0
  10. package/lib/index.js +123 -0
  11. package/lib/install-shared.d.ts +72 -0
  12. package/lib/install-shared.js +256 -0
  13. package/lib/manifest.d.ts +53 -0
  14. package/lib/manifest.js +74 -0
  15. package/lib/substitute.d.ts +15 -0
  16. package/lib/substitute.js +25 -0
  17. package/package.json +32 -0
  18. package/templates/functions/_shared/client.ts +20 -0
  19. package/templates/functions/_shared/config.ts +23 -0
  20. package/templates/functions/_shared/moderation.ts +102 -0
  21. package/templates/functions/_shared/push.ts +33 -0
  22. package/templates/functions/_shared/slack.ts +12 -0
  23. package/templates/functions/broadcast-post/index.ts +73 -0
  24. package/templates/functions/daily-moderation/index.ts +175 -0
  25. package/templates/functions/moderate-one/index.ts +62 -0
  26. package/templates/functions/notify-comment/index.ts +63 -0
  27. package/templates/functions/notify-like/index.ts +103 -0
  28. package/templates/functions/notify-reaction/index.ts +186 -0
  29. package/templates/functions/report-to-slack/index.ts +37 -0
  30. package/templates/functions/update-profile/index.ts +131 -0
  31. package/templates/migrations/core/001_tables.sql +177 -0
  32. package/templates/migrations/core/002_rls.sql +154 -0
  33. package/templates/migrations/core/003_moderation.sql +68 -0
  34. package/templates/migrations/core/004_dashboard.sql +102 -0
  35. package/templates/migrations/core/005_avatars.sql +19 -0
  36. package/templates/migrations/core/006_meta.sql +11 -0
  37. package/templates/migrations/inbox/001_inbox.sql +237 -0
  38. package/templates/migrations/polls/001_polls.sql +120 -0
  39. package/templates/migrations/push/001_push.sql +42 -0
  40. package/templates/migrations/push/002_triggers.sql +89 -0
  41. package/templates/migrations/reaction/001_reactions.sql +139 -0
@@ -0,0 +1,19 @@
1
+ -- community-sdk core/005_avatars.sql (schema v1)
2
+ -- Community SDK — 05 avatars Storage bucket.
3
+ -- Public read (avatar URLs are plain public object URLs), 1MB cap, JPEG only.
4
+ -- Uploads land at avatars/{uid}/{timestamp}.jpg and are pending until the
5
+ -- update-profile Edge Function approves them; a rejected object is deleted by
6
+ -- the function. An unapproved object is technically fetchable at its URL until
7
+ -- then, but the URL is unguessable in practice and never linked. Accepted.
8
+ insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
9
+ values ('avatars', 'avatars', true, 1048576, array['image/jpeg'])
10
+ on conflict (id) do nothing;
11
+
12
+ -- Clients may only CREATE objects inside their own folder. No UPDATE/DELETE
13
+ -- policies: replacement cleanup is done by the function with the service role.
14
+ create policy "avatar upload to own folder"
15
+ on storage.objects for insert to authenticated
16
+ with check (
17
+ bucket_id = 'avatars'
18
+ and (storage.foldername(name))[1] = auth.uid()::text
19
+ );
@@ -0,0 +1,11 @@
1
+ -- community-sdk core/006_meta.sql (schema v1)
2
+ -- Community SDK — 06 schema metadata: lets the CLI / dashboard detect which
3
+ -- schema version an installed community backend is running.
4
+ create table if not exists public.community_meta (
5
+ id boolean primary key default true check (id),
6
+ schema_version int not null
7
+ );
8
+ insert into public.community_meta (schema_version) values (1)
9
+ on conflict (id) do update set schema_version = excluded.schema_version;
10
+ alter table public.community_meta enable row level security;
11
+ create policy "community_meta readable" on public.community_meta for select using (true);
@@ -0,0 +1,237 @@
1
+ -- community-sdk inbox/001_inbox.sql (schema v1)
2
+ -- Community SDK — inbox module (OPTIONAL): a notification-center inbox for
3
+ -- actor -> recipient events (like / comment / reaction, plus any custom
4
+ -- service-role kind a host app writes itself — see the `kind` column note
5
+ -- below). Official-account posts are NOT fanned out per user: they are
6
+ -- unioned straight from posts in list_notifications() so a broadcast post
7
+ -- shows up for everyone without a write per recipient. Read state is one
8
+ -- seen_at marker per user (opening the center marks everything read), not
9
+ -- per-row read_at. Rows are written by SQL triggers so the inbox fills
10
+ -- independently of any push pipeline and of push permission.
11
+ --
12
+ -- Ported from a host app's two-migration notification-center feature,
13
+ -- merged into one file; this ships the FINAL form (the later revision's
14
+ -- list_notifications(), which unions ALL visible posts by an is_official
15
+ -- profile regardless of topic, supersedes the earlier topic-scoped version —
16
+ -- there is no intermediate "topic = news only" state to replay).
17
+ --
18
+ -- Dependencies: core module tables (public.posts, public.profiles,
19
+ -- public.likes, public.comments, public.blocks) — the core module must be
20
+ -- installed first. The reaction-kind trigger additionally depends on the
21
+ -- reaction module's public.post_reactions table; that trigger is created
22
+ -- inside a guarded DO block (`if to_regclass('public.post_reactions') is not
23
+ -- null then ...`) so this module installs cleanly without the reaction
24
+ -- module — likes/comments notifications work either way.
25
+ --
26
+ -- `kind` is intentionally an UNCONSTRAINED text column (no CHECK), unlike
27
+ -- the source migration this was ported from: a host's own backend (e.g. a
28
+ -- support/help-desk integration) may write custom kinds via service role
29
+ -- (spec §6), and this module must not need a schema change to allow that.
30
+
31
+ create extension if not exists pg_cron;
32
+
33
+ -- ============ NOTIFICATIONS ============
34
+ create table public.notifications (
35
+ id uuid primary key default gen_random_uuid(),
36
+ user_id uuid not null references public.profiles(id) on delete cascade,
37
+ -- Unconstrained on purpose — see header note. Built-in kinds emitted by
38
+ -- this module: 'like', 'comment', 'reaction', 'official_post' (the last is
39
+ -- synthesized by list_notifications(), never stored as a row).
40
+ kind text not null,
41
+ actor_id uuid references public.profiles(id) on delete cascade,
42
+ post_id uuid references public.posts(id) on delete cascade,
43
+ created_at timestamptz not null default now()
44
+ );
45
+ create index notifications_user_idx on public.notifications (user_id, created_at desc);
46
+
47
+ alter table public.notifications enable row level security;
48
+
49
+ -- Owner-only read; writes happen only via the security-definer triggers
50
+ -- below (and, optionally, service-role inserts from a host's own backend for
51
+ -- custom kinds).
52
+ create policy "read own notifications" on public.notifications
53
+ for select to authenticated using (user_id = auth.uid());
54
+
55
+ -- "Everything up to seen_at is read". Upserted by the client when the center
56
+ -- opens; both the stored rows and the unioned official posts compare
57
+ -- against it.
58
+ create table public.notification_seen (
59
+ user_id uuid primary key references public.profiles(id) on delete cascade,
60
+ seen_at timestamptz not null default now()
61
+ );
62
+
63
+ alter table public.notification_seen enable row level security;
64
+
65
+ create policy "read own seen" on public.notification_seen
66
+ for select to authenticated using (user_id = auth.uid());
67
+ create policy "insert own seen" on public.notification_seen
68
+ for insert to authenticated with check (user_id = auth.uid());
69
+ create policy "update own seen" on public.notification_seen
70
+ for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
71
+
72
+ -- ============ TRIGGERS ============
73
+ -- Same skip rules throughout: never notify yourself, never notify a
74
+ -- recipient who blocked the actor.
75
+
76
+ create or replace function public.inbox_on_like()
77
+ returns trigger language plpgsql security definer set search_path = public as $$
78
+ declare recipient uuid;
79
+ begin
80
+ select author_id into recipient from posts where id = new.post_id;
81
+ if recipient is null or recipient = new.user_id then return new; end if;
82
+ if exists (select 1 from blocks where blocker_id = recipient and blocked_id = new.user_id) then
83
+ return new;
84
+ end if;
85
+ begin
86
+ insert into notifications (user_id, kind, actor_id, post_id)
87
+ values (recipient, 'like', new.user_id, new.post_id);
88
+ exception when others then null; -- the inbox must never break a like
89
+ end;
90
+ return new;
91
+ end; $$;
92
+
93
+ create trigger on_like_inbox
94
+ after insert on public.likes
95
+ for each row execute function public.inbox_on_like();
96
+
97
+ -- Unlike retracts the inbox entry (also keeps like/unlike toggling from
98
+ -- accumulating duplicate rows).
99
+ create or replace function public.inbox_on_unlike()
100
+ returns trigger language plpgsql security definer set search_path = public as $$
101
+ begin
102
+ begin
103
+ delete from notifications
104
+ where kind = 'like' and post_id = old.post_id and actor_id = old.user_id;
105
+ exception when others then null; -- the inbox must never break an unlike
106
+ end;
107
+ return old;
108
+ end; $$;
109
+
110
+ create trigger on_unlike_inbox
111
+ after delete on public.likes
112
+ for each row execute function public.inbox_on_unlike();
113
+
114
+ -- Comments enter as 'pending' and publish on moderation; mirror the push
115
+ -- module's own comment triggers: fire on pending -> visible, and on direct
116
+ -- visible inserts (dashboard replies).
117
+ create or replace function public.inbox_on_comment()
118
+ returns trigger language plpgsql security definer set search_path = public as $$
119
+ declare recipient uuid;
120
+ begin
121
+ select author_id into recipient from posts where id = new.post_id;
122
+ if recipient is null or recipient = new.author_id then return new; end if;
123
+ if exists (select 1 from blocks where blocker_id = recipient and blocked_id = new.author_id) then
124
+ return new;
125
+ end if;
126
+ begin
127
+ insert into notifications (user_id, kind, actor_id, post_id)
128
+ values (recipient, 'comment', new.author_id, new.post_id);
129
+ exception when others then null; -- the inbox must never break a comment
130
+ end;
131
+ return new;
132
+ end; $$;
133
+
134
+ create trigger on_comment_published_inbox
135
+ after update on public.comments
136
+ for each row
137
+ when (old.status = 'pending' and new.status = 'visible')
138
+ execute function public.inbox_on_comment();
139
+
140
+ create trigger on_comment_created_visible_inbox
141
+ after insert on public.comments
142
+ for each row
143
+ when (new.status = 'visible')
144
+ execute function public.inbox_on_comment();
145
+
146
+ -- Reaction inbox entries (reaction module's post_reactions -> 'reaction'
147
+ -- kind). The function is safe to create unconditionally — it only reads
148
+ -- core tables (posts, blocks) and writes notifications — but the trigger
149
+ -- itself must target public.post_reactions, so its creation is guarded.
150
+ create or replace function public.inbox_on_reaction()
151
+ returns trigger language plpgsql security definer set search_path = public as $$
152
+ declare recipient uuid;
153
+ begin
154
+ select author_id into recipient from posts where id = new.post_id;
155
+ if recipient is null or recipient = new.user_id then return new; end if;
156
+ if exists (select 1 from blocks where blocker_id = recipient and blocked_id = new.user_id) then
157
+ return new;
158
+ end if;
159
+ begin
160
+ insert into notifications (user_id, kind, actor_id, post_id)
161
+ values (recipient, 'reaction', new.user_id, new.post_id);
162
+ exception when others then null; -- the inbox must never break a reaction
163
+ end;
164
+ return new;
165
+ end; $$;
166
+
167
+ -- Guarded: only wire the trigger if the reaction module's table exists, so
168
+ -- the inbox module installs standalone without the reaction module.
169
+ do $$
170
+ begin
171
+ if to_regclass('public.post_reactions') is not null then
172
+ drop trigger if exists on_reaction_inbox on public.post_reactions;
173
+ create trigger on_reaction_inbox
174
+ after insert on public.post_reactions
175
+ for each row execute function public.inbox_on_reaction();
176
+ end if;
177
+ end $$;
178
+
179
+ -- ============ READ API ============
180
+ -- The center's single read path. Stored rows for me (minus rows whose post
181
+ -- was since hidden/deleted) unioned with recent posts by official accounts
182
+ -- (any topic — a broadcast push is topic-agnostic, so the inbox must be
183
+ -- too), newest first. SECURITY DEFINER to join profiles/posts across RLS.
184
+ create or replace function public.list_notifications(p_limit int default 50)
185
+ returns table (
186
+ id uuid,
187
+ kind text,
188
+ actor_id uuid,
189
+ actor_username text,
190
+ post_id uuid,
191
+ post_excerpt text,
192
+ created_at timestamptz
193
+ )
194
+ language sql stable security definer set search_path = public
195
+ as $$
196
+ (
197
+ select n.id, n.kind, n.actor_id, pr.username, n.post_id,
198
+ left(p.content, 140), n.created_at
199
+ from notifications n
200
+ left join profiles pr on pr.id = n.actor_id
201
+ left join posts p on p.id = n.post_id
202
+ where n.user_id = auth.uid()
203
+ and (n.post_id is null or p.status in ('visible', 'pending'))
204
+ )
205
+ union all
206
+ (
207
+ select p.id, 'official_post', p.author_id, pr.username, p.id,
208
+ left(p.content, 140), p.created_at
209
+ from posts p
210
+ join profiles pr on pr.id = p.author_id
211
+ where p.status = 'visible' and pr.is_official
212
+ and p.author_id <> auth.uid()
213
+ and p.created_at > now() - interval '90 days'
214
+ )
215
+ order by created_at desc
216
+ limit p_limit;
217
+ $$;
218
+
219
+ revoke execute on function public.list_notifications(int) from public, anon;
220
+ grant execute on function public.list_notifications(int) to authenticated;
221
+
222
+ -- Retention: the official-posts union already stops at 90 days; keep stored
223
+ -- rows symmetric.
224
+ do $$
225
+ begin
226
+ perform cron.schedule(
227
+ 'notifications-purge', '15 3 * * *',
228
+ $cron$ delete from public.notifications where created_at < now() - interval '90 days'; $cron$
229
+ );
230
+ exception when others then
231
+ if sqlerrm like '%already exists%' then
232
+ raise notice 'cron job notifications-purge already scheduled, skipping';
233
+ else
234
+ raise;
235
+ end if;
236
+ end;
237
+ $$;
@@ -0,0 +1,120 @@
1
+ -- community-sdk polls/001_polls.sql (schema v1)
2
+ -- Community SDK — polls module (OPTIONAL): a post can carry 2-4 short
3
+ -- options (the post content is the question). One vote per user per poll,
4
+ -- changeable. Votes are anonymous at the API level: poll_votes is readable
5
+ -- only by its owner, aggregate counts go through the security-definer
6
+ -- poll_vote_counts(). Skip this module if the app does not ship polls
7
+ -- (and set the app's dashboard feature flag accordingly).
8
+ -- References core module tables (public.posts, public.profiles,
9
+ -- public.current_user_banned()) — the core module must be installed first.
10
+
11
+ -- ============ POLL OPTIONS ============
12
+ create table public.poll_options (
13
+ id uuid primary key default gen_random_uuid(),
14
+ post_id uuid not null references public.posts(id) on delete cascade,
15
+ idx smallint not null check (idx between 0 and 3),
16
+ label text not null check (char_length(label) between 1 and 60),
17
+ created_at timestamptz not null default now(),
18
+ unique (post_id, idx),
19
+ -- Composite FK target so a vote's option is forced to belong to the same
20
+ -- post the vote points at (see poll_votes).
21
+ unique (id, post_id)
22
+ );
23
+
24
+ -- ============ POLL VOTES ============
25
+ create table public.poll_votes (
26
+ post_id uuid not null references public.posts(id) on delete cascade,
27
+ option_id uuid not null,
28
+ user_id uuid not null references public.profiles(id) on delete cascade,
29
+ created_at timestamptz not null default now(),
30
+ primary key (post_id, user_id),
31
+ foreign key (option_id, post_id) references public.poll_options(id, post_id) on delete cascade
32
+ );
33
+ create index poll_votes_counts_idx on public.poll_votes (post_id, option_id);
34
+
35
+ -- ============ RLS ============
36
+ alter table public.poll_options enable row level security;
37
+ alter table public.poll_votes enable row level security;
38
+
39
+ -- Options follow their post's visibility: the subquery runs under the caller's
40
+ -- posts RLS, so options of hidden posts / blocked authors stay invisible too.
41
+ create policy "poll options follow post visibility"
42
+ on public.poll_options for select to authenticated
43
+ using (exists (select 1 from public.posts p where p.id = post_id));
44
+
45
+ create policy "insert options on own post if not banned"
46
+ on public.poll_options for insert to authenticated
47
+ with check (
48
+ not public.current_user_banned()
49
+ and exists (select 1 from public.posts p where p.id = post_id and p.author_id = auth.uid())
50
+ );
51
+ -- No update/delete policies: options are immutable once posted.
52
+
53
+ -- Votes are private: only the voter reads her own row (the app uses it for
54
+ -- "my choice"); everyone else only ever sees aggregates.
55
+ create policy "read own poll votes"
56
+ on public.poll_votes for select to authenticated
57
+ using (user_id = auth.uid());
58
+
59
+ create policy "vote as self on a readable post"
60
+ on public.poll_votes for insert to authenticated
61
+ with check (
62
+ user_id = auth.uid()
63
+ and not public.current_user_banned()
64
+ and exists (select 1 from public.posts p where p.id = post_id)
65
+ );
66
+
67
+ -- Changing your vote = updating option_id on your own row (client upsert).
68
+ create policy "change own vote"
69
+ on public.poll_votes for update to authenticated
70
+ using (user_id = auth.uid())
71
+ with check (user_id = auth.uid());
72
+
73
+ -- ============ VOTE COUNTS ============
74
+ -- Per-option counts for a batch of posts (one call per feed page).
75
+ -- SECURITY DEFINER bypasses the private poll_votes RLS but returns counts only.
76
+ create or replace function public.poll_vote_counts(p_post_ids uuid[])
77
+ returns table (post_id uuid, option_id uuid, votes bigint)
78
+ language sql stable security definer set search_path = public
79
+ as $$
80
+ select v.post_id, v.option_id, count(*)::bigint
81
+ from poll_votes v
82
+ where v.post_id = any(p_post_ids)
83
+ group by v.post_id, v.option_id;
84
+ $$;
85
+
86
+ revoke execute on function public.poll_vote_counts(uuid[]) from public, anon;
87
+ grant execute on function public.poll_vote_counts(uuid[]) to authenticated;
88
+
89
+ -- ============ POLL POST CREATION ============
90
+ -- Post + options in one transaction (no orphan post if an option insert
91
+ -- fails). SECURITY INVOKER: posts/poll_options RLS applies, so the banned
92
+ -- check and the pending-only insert status hold exactly as for a plain insert.
93
+ create or replace function public.create_poll_post(
94
+ p_topic text,
95
+ p_content text,
96
+ p_options text[]
97
+ ) returns uuid
98
+ language plpgsql set search_path = public
99
+ as $$
100
+ declare
101
+ new_id uuid;
102
+ n int := coalesce(array_length(p_options, 1), 0);
103
+ i int;
104
+ begin
105
+ if n < 2 or n > 4 then
106
+ raise exception 'a poll needs 2 to 4 options';
107
+ end if;
108
+ insert into public.posts (author_id, topic, content)
109
+ values (auth.uid(), p_topic, p_content)
110
+ returning id into new_id;
111
+ for i in 1..n loop
112
+ insert into public.poll_options (post_id, idx, label)
113
+ values (new_id, i - 1, p_options[i]);
114
+ end loop;
115
+ return new_id;
116
+ end;
117
+ $$;
118
+
119
+ revoke execute on function public.create_poll_post(text, text, text[]) from public, anon;
120
+ grant execute on function public.create_poll_post(text, text, text[]) to authenticated;
@@ -0,0 +1,42 @@
1
+ -- community-sdk push/001_push.sql (schema v1)
2
+ -- Community SDK — push module (OPTIONAL): comment + like notifications.
3
+ -- Skip this module entirely if the app does not ship community push.
4
+ -- References core module tables (public.profiles, public.likes) — the core
5
+ -- module must be installed first. likes.notified_at (core/001_tables.sql)
6
+ -- simply stays unused if this module is skipped.
7
+ --
8
+ -- This file: push_tokens storage + RLS, plus profiles.locale (localized push
9
+ -- copy needs it regardless of which notify-* functions are deployed). The
10
+ -- webhook functions, triggers and cron digest job (which embed the project
11
+ -- URL / anon key) live in push/002_triggers.sql.
12
+
13
+ -- Owner-only token+prefs table (a push token must NOT live on the
14
+ -- world-readable profiles table).
15
+ create table public.push_tokens (
16
+ user_id uuid primary key references public.profiles(id) on delete cascade,
17
+ expo_push_token text,
18
+ notify_likes boolean not null default true,
19
+ notify_comments boolean not null default true,
20
+ updated_at timestamptz not null default now()
21
+ );
22
+ alter table public.push_tokens enable row level security;
23
+
24
+ create policy "read own push row" on public.push_tokens for select to authenticated
25
+ using (user_id = auth.uid());
26
+ create policy "insert own push row" on public.push_tokens for insert to authenticated
27
+ with check (user_id = auth.uid());
28
+ create policy "update own push row" on public.push_tokens for update to authenticated
29
+ using (user_id = auth.uid()) with check (user_id = auth.uid());
30
+
31
+ -- Recipient locale for localized push copy (written by the client's profile
32
+ -- sync; read by the notify-* functions). Nullable: absent locale falls back
33
+ -- to en. Loose length check only, same as Eve's reference migration
34
+ -- (20260814150000_profile_locale.sql) — adding a language needs no migration.
35
+ alter table public.profiles add column if not exists locale text check (char_length(locale) <= 10);
36
+
37
+ -- profiles updates are column-scoped (core/002_rls.sql); grants are additive,
38
+ -- so this just adds locale to what the client's own-profile sync may write.
39
+ grant update (locale) on table public.profiles to authenticated;
40
+
41
+ -- Like coalescing marker index (column lives in core/001_tables.sql).
42
+ create index likes_unnotified_idx on public.likes (post_id) where notified_at is null;
@@ -0,0 +1,89 @@
1
+ -- community-sdk push/002_triggers.sql (schema v1)
2
+ -- Community SDK — push module (OPTIONAL): webhook functions, triggers and the
3
+ -- hourly digest cron job. Requires the notify-comment / notify-like Edge
4
+ -- Functions and an EXPO_ACCESS_TOKEN secret. References core module tables
5
+ -- (public.comments, public.likes) — the core module must be installed first,
6
+ -- and push/001_push.sql (push_tokens) before this file.
7
+ --
8
+ -- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
9
+ -- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
10
+ -- its anon key. The anon key is public by design (shipped inside the app
11
+ -- binary): the Edge Functions do privileged work through their own
12
+ -- service-role env, the JWT only passes verify_jwt.
13
+ --
14
+ -- The DO block below fails the migration loudly if the placeholders were not
15
+ -- replaced.
16
+ do $$ begin
17
+ if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
18
+ raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
19
+ end if;
20
+ end $$;
21
+
22
+ create extension if not exists pg_cron;
23
+ create extension if not exists pg_net;
24
+
25
+ -- ============ WEBHOOK FUNCTIONS ============
26
+ create or replace function public.notify_comment_webhook()
27
+ returns trigger language plpgsql security definer set search_path = public as $$
28
+ begin
29
+ perform net.http_post(
30
+ url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-comment',
31
+ headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
32
+ body := jsonb_build_object('record', to_jsonb(new))
33
+ );
34
+ return new;
35
+ end; $$;
36
+
37
+ create or replace function public.notify_like_webhook()
38
+ returns trigger language plpgsql security definer set search_path = public as $$
39
+ begin
40
+ perform net.http_post(
41
+ url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-like',
42
+ headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
43
+ body := jsonb_build_object('record', to_jsonb(new))
44
+ );
45
+ return new;
46
+ end; $$;
47
+
48
+ -- ============ TRIGGERS ============
49
+ -- Notify the post author only once a comment is actually published, never on
50
+ -- the pending insert (a rejected comment must never send a push).
51
+ create trigger on_comment_published
52
+ after update on public.comments
53
+ for each row
54
+ when (old.status = 'pending' and new.status = 'visible')
55
+ execute function public.notify_comment_webhook();
56
+
57
+ -- Dashboard comments are inserted directly as 'visible' (service role) and
58
+ -- must notify too. App comments are always inserted 'pending' (RLS-enforced),
59
+ -- so nothing double-notifies.
60
+ create trigger on_comment_created_visible
61
+ after insert on public.comments
62
+ for each row
63
+ when (new.status = 'visible')
64
+ execute function public.notify_comment_webhook();
65
+
66
+ create trigger on_like_created
67
+ after insert on public.likes
68
+ for each row execute function public.notify_like_webhook();
69
+
70
+ -- Hourly digest: flush the tail of any coalesced like bursts.
71
+ do $$
72
+ begin
73
+ perform cron.schedule(
74
+ 'community-like-digest', '0 * * * *',
75
+ $cron$
76
+ select net.http_post(
77
+ url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-like',
78
+ headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__')
79
+ );
80
+ $cron$
81
+ );
82
+ exception when others then
83
+ if sqlerrm like '%already exists%' then
84
+ raise notice 'cron job community-like-digest already scheduled, skipping';
85
+ else
86
+ raise;
87
+ end if;
88
+ end;
89
+ $$;
@@ -0,0 +1,139 @@
1
+ -- community-sdk reaction/001_reactions.sql (schema v1)
2
+ -- Community SDK — reaction module (OPTIONAL): a generic "I stand with you"
3
+ -- reaction on a post, one row per (post, user). Counts + push only — the
4
+ -- reaction carries no visible server-side content; any richer text a host
5
+ -- app attaches client-side (an encouragement, a note, a kind word) never
6
+ -- reaches this table.
7
+ --
8
+ -- References core module tables (public.posts, public.profiles,
9
+ -- public.current_user_banned()) — the core module must be installed first.
10
+ -- Optionally extends the push module's push_tokens table with a
11
+ -- notify_reactions preference column; that ALTER is guarded with
12
+ -- to_regclass() so this module installs cleanly without the push module (the
13
+ -- notify-reaction webhook below still fires either way — it is the Edge
14
+ -- Function's job to look up push_tokens if/when that module is present).
15
+ --
16
+ -- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
17
+ -- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
18
+ -- its anon key. The anon key is public by design (shipped inside the app
19
+ -- binary): the Edge Functions do privileged work through their own
20
+ -- service-role env, the JWT only passes verify_jwt.
21
+ --
22
+ -- The DO block below fails the migration loudly if the placeholders were not
23
+ -- replaced.
24
+ do $$ begin
25
+ if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
26
+ raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
27
+ end if;
28
+ end $$;
29
+
30
+ create extension if not exists pg_cron;
31
+ create extension if not exists pg_net;
32
+
33
+ -- ============ POST_REACTIONS ============
34
+ create table public.post_reactions (
35
+ post_id uuid not null references public.posts(id) on delete cascade,
36
+ user_id uuid not null references public.profiles(id) on delete cascade,
37
+ created_at timestamptz not null default now(),
38
+ -- Push-module coalescing marker (harmless if the push module is skipped).
39
+ notified_at timestamptz,
40
+ primary key (post_id, user_id)
41
+ );
42
+
43
+ alter table public.post_reactions enable row level security;
44
+
45
+ -- Rows are private, like poll_votes: the client only ever selects its own
46
+ -- rows (e.g. "did I already react"); counts and the latest-reactor line for
47
+ -- the feed go through the security-definer post_reaction_summary() RPC below.
48
+ create policy "read own reactions" on public.post_reactions
49
+ for select to authenticated using (user_id = auth.uid());
50
+
51
+ -- One reaction row per (post, user); reacting again is a local-only event.
52
+ -- No delete policy: a reaction is not retractable.
53
+ create policy "react own" on public.post_reactions
54
+ for insert to authenticated
55
+ with check (
56
+ user_id = auth.uid()
57
+ and not public.current_user_banned()
58
+ and exists (select 1 from public.posts p where p.id = post_id)
59
+ );
60
+
61
+ -- Per-post summary for a feed page: count, the display name of the most
62
+ -- recent reactor (null when they have not chosen a display name — the client
63
+ -- falls back to a generic label), and whether the caller has reacted.
64
+ -- SECURITY DEFINER only to join profiles.username and read auth.uid() across
65
+ -- rows that RLS would otherwise keep private to their own owner.
66
+ create or replace function public.post_reaction_summary(p_post_ids uuid[])
67
+ returns table (post_id uuid, reaction_count int, last_reactor_name text, has_reacted boolean)
68
+ language sql stable security definer set search_path = public
69
+ as $$
70
+ select
71
+ r.post_id,
72
+ count(*)::int as reaction_count,
73
+ (
74
+ select pr.username
75
+ from post_reactions r2
76
+ join profiles pr on pr.id = r2.user_id
77
+ where r2.post_id = r.post_id
78
+ order by r2.created_at desc
79
+ limit 1
80
+ ) as last_reactor_name,
81
+ bool_or(r.user_id = auth.uid()) as has_reacted
82
+ from post_reactions r
83
+ where r.post_id = any(p_post_ids)
84
+ group by r.post_id;
85
+ $$;
86
+
87
+ revoke execute on function public.post_reaction_summary(uuid[]) from public, anon;
88
+ grant execute on function public.post_reaction_summary(uuid[]) to authenticated;
89
+
90
+ create index post_reactions_unnotified_idx on public.post_reactions (post_id) where notified_at is null;
91
+
92
+ -- Push preference (default on, same as likes/comments). Guarded so this
93
+ -- module installs cleanly without the push module (push_tokens missing).
94
+ do $$
95
+ begin
96
+ if to_regclass('public.push_tokens') is not null then
97
+ alter table public.push_tokens add column if not exists notify_reactions boolean not null default true;
98
+ end if;
99
+ end $$;
100
+
101
+ -- ============ WEBHOOK + DIGEST ============
102
+ -- Same pattern as push/002_triggers.sql's notify-like: an immediate webhook
103
+ -- on insert plus a coalescing digest cron for anything the webhook missed.
104
+ -- Requires the notify-reaction Edge Function.
105
+ create or replace function public.notify_reaction_webhook()
106
+ returns trigger language plpgsql security definer set search_path = public as $$
107
+ begin
108
+ perform net.http_post(
109
+ url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-reaction',
110
+ headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
111
+ body := jsonb_build_object('record', to_jsonb(new))
112
+ );
113
+ return new;
114
+ end; $$;
115
+
116
+ create trigger on_post_reaction_created
117
+ after insert on public.post_reactions
118
+ for each row execute function public.notify_reaction_webhook();
119
+
120
+ -- Half-hourly digest: flush the tail of any coalesced reaction bursts.
121
+ do $$
122
+ begin
123
+ perform cron.schedule(
124
+ 'community-reaction-digest', '30 * * * *',
125
+ $cron$
126
+ select net.http_post(
127
+ url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-reaction',
128
+ headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__')
129
+ );
130
+ $cron$
131
+ );
132
+ exception when others then
133
+ if sqlerrm like '%already exists%' then
134
+ raise notice 'cron job community-reaction-digest already scheduled, skipping';
135
+ else
136
+ raise;
137
+ end if;
138
+ end;
139
+ $$;