@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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/lib/commands/adopt.d.ts +38 -0
- package/lib/commands/adopt.js +105 -0
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +152 -0
- package/lib/commands/upgrade.d.ts +33 -0
- package/lib/commands/upgrade.js +280 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +123 -0
- package/lib/install-shared.d.ts +72 -0
- package/lib/install-shared.js +256 -0
- package/lib/manifest.d.ts +53 -0
- package/lib/manifest.js +74 -0
- package/lib/substitute.d.ts +15 -0
- package/lib/substitute.js +25 -0
- package/package.json +32 -0
- package/templates/functions/_shared/client.ts +20 -0
- package/templates/functions/_shared/config.ts +23 -0
- package/templates/functions/_shared/moderation.ts +102 -0
- package/templates/functions/_shared/push.ts +33 -0
- package/templates/functions/_shared/slack.ts +12 -0
- package/templates/functions/broadcast-post/index.ts +73 -0
- package/templates/functions/daily-moderation/index.ts +175 -0
- package/templates/functions/moderate-one/index.ts +62 -0
- package/templates/functions/notify-comment/index.ts +63 -0
- package/templates/functions/notify-like/index.ts +103 -0
- package/templates/functions/notify-reaction/index.ts +186 -0
- package/templates/functions/report-to-slack/index.ts +37 -0
- package/templates/functions/update-profile/index.ts +131 -0
- package/templates/migrations/core/001_tables.sql +177 -0
- package/templates/migrations/core/002_rls.sql +154 -0
- package/templates/migrations/core/003_moderation.sql +68 -0
- package/templates/migrations/core/004_dashboard.sql +102 -0
- package/templates/migrations/core/005_avatars.sql +19 -0
- package/templates/migrations/core/006_meta.sql +11 -0
- package/templates/migrations/inbox/001_inbox.sql +237 -0
- package/templates/migrations/polls/001_polls.sql +120 -0
- package/templates/migrations/push/001_push.sql +42 -0
- package/templates/migrations/push/002_triggers.sql +89 -0
- 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
|
+
$$;
|