@rocapine/community-core 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 +173 -0
- package/lib/config.d.ts +74 -0
- package/lib/config.js +51 -0
- package/lib/events.d.ts +20 -0
- package/lib/events.js +33 -0
- package/lib/hooks.d.ts +122 -0
- package/lib/hooks.js +494 -0
- package/lib/identity.d.ts +23 -0
- package/lib/identity.js +103 -0
- package/lib/inbox-hooks.d.ts +23 -0
- package/lib/inbox-hooks.js +57 -0
- package/lib/inbox-service.d.ts +26 -0
- package/lib/inbox-service.js +94 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +89 -0
- package/lib/models.d.ts +187 -0
- package/lib/models.js +178 -0
- package/lib/provider.d.ts +7 -0
- package/lib/provider.js +23 -0
- package/lib/schema-check.d.ts +19 -0
- package/lib/schema-check.js +52 -0
- package/lib/service.d.ts +147 -0
- package/lib/service.js +454 -0
- package/lib/time.d.ts +21 -0
- package/lib/time.js +32 -0
- package/package.json +32 -0
package/lib/service.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Community backend service — every function is a thin wrapper around the injected
|
|
3
|
+
// Supabase client (`cfg.requireClient()`), so a host with `supabase: null` degrades to
|
|
4
|
+
// CommunityDisabledError instead of crashing when no backend is configured. No React
|
|
5
|
+
// imports here.
|
|
6
|
+
//
|
|
7
|
+
// Ported from the mold (`sdk/client/services/community.ts`) with a host app's newer
|
|
8
|
+
// feed/thread composition (search, poll batching via `poll_vote_counts`, reaction batching
|
|
9
|
+
// generalized from that host's prayer-style feature's summary RPC into `post_reaction_summary`).
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.buildFeedSelect = buildFeedSelect;
|
|
12
|
+
exports.fetchReactionSummaries = fetchReactionSummaries;
|
|
13
|
+
exports.fetchFeedPage = fetchFeedPage;
|
|
14
|
+
exports.countNewPosts = countNewPosts;
|
|
15
|
+
exports.fetchProfile = fetchProfile;
|
|
16
|
+
exports.fetchUserPosts = fetchUserPosts;
|
|
17
|
+
exports.searchPosts = searchPosts;
|
|
18
|
+
exports.fetchThread = fetchThread;
|
|
19
|
+
exports.createPost = createPost;
|
|
20
|
+
exports.votePoll = votePoll;
|
|
21
|
+
exports.createComment = createComment;
|
|
22
|
+
exports.moderateOne = moderateOne;
|
|
23
|
+
exports.setLike = setLike;
|
|
24
|
+
exports.setReaction = setReaction;
|
|
25
|
+
exports.reportContent = reportContent;
|
|
26
|
+
exports.blockUser = blockUser;
|
|
27
|
+
exports.deleteOwnPost = deleteOwnPost;
|
|
28
|
+
exports.deleteOwnComment = deleteOwnComment;
|
|
29
|
+
exports.updateProfile = updateProfile;
|
|
30
|
+
exports.uploadAvatar = uploadAvatar;
|
|
31
|
+
const supabase_js_1 = require("@supabase/supabase-js");
|
|
32
|
+
const identity_1 = require("./identity");
|
|
33
|
+
const models_1 = require("./models");
|
|
34
|
+
/** Avatar images live in this Storage bucket on every install (mold value, unchanged). */
|
|
35
|
+
const AVATAR_BUCKET = "avatars";
|
|
36
|
+
async function requireUid(cfg) {
|
|
37
|
+
const uid = await (0, identity_1.ensureIdentity)(cfg);
|
|
38
|
+
if (!uid)
|
|
39
|
+
throw new Error("You appear to be offline. Please try again.");
|
|
40
|
+
return uid;
|
|
41
|
+
}
|
|
42
|
+
// Embedded filter: comment counts only count visible comments, so the badge
|
|
43
|
+
// on a card always matches what the opened thread renders.
|
|
44
|
+
const FEED_SELECT = "id, author_id, topic, content, status, pinned_at, created_at, profiles!posts_author_id_fkey(username, is_official, handle, avatar_url), likes(count), comments(count), poll_options(id, idx, label)";
|
|
45
|
+
const PROFILE_SELECT = "id, username, handle, is_official, bio, avatar_url";
|
|
46
|
+
/** Minimal injection guard for `CommunityConfig.feed.extraPostColumns`: a bare
|
|
47
|
+
* column name only, no `.`, `(`, `:`, whitespace, etc. — those are how a
|
|
48
|
+
* PostgREST select embeds a relation or renames a column, and letting them
|
|
49
|
+
* through would extend the query in ways this extension point isn't meant to
|
|
50
|
+
* allow. */
|
|
51
|
+
const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
|
|
52
|
+
/**
|
|
53
|
+
* Appends `extraPostColumns` (if any) to the posts feed select. Pure and
|
|
54
|
+
* exported so it's unit-testable without a Supabase client. Invalid column
|
|
55
|
+
* names are dropped with a `console.warn` rather than failing the query.
|
|
56
|
+
*/
|
|
57
|
+
function buildFeedSelect(extraPostColumns) {
|
|
58
|
+
if (!extraPostColumns || extraPostColumns.length === 0)
|
|
59
|
+
return FEED_SELECT;
|
|
60
|
+
const valid = extraPostColumns.filter((column) => {
|
|
61
|
+
if (VALID_EXTRA_COLUMN.test(column))
|
|
62
|
+
return true;
|
|
63
|
+
console.warn(`[@rocapine/community-core] ignoring invalid feed.extraPostColumns entry: ${JSON.stringify(column)}`);
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
if (valid.length === 0)
|
|
67
|
+
return FEED_SELECT;
|
|
68
|
+
return `${FEED_SELECT}, ${valid.join(", ")}`;
|
|
69
|
+
}
|
|
70
|
+
async function fetchMyLikes(client, postIds, uid) {
|
|
71
|
+
if (postIds.length === 0)
|
|
72
|
+
return new Set();
|
|
73
|
+
const { data, error } = await client
|
|
74
|
+
.from("likes")
|
|
75
|
+
.select("post_id")
|
|
76
|
+
.eq("user_id", uid)
|
|
77
|
+
.in("post_id", postIds);
|
|
78
|
+
if (error)
|
|
79
|
+
throw error;
|
|
80
|
+
return new Set((data ?? []).map((r) => r.post_id));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Batched poll state for the poll posts of one page: per-option counts via the
|
|
84
|
+
* security-definer RPC (poll_votes rows are private), my votes via RLS select.
|
|
85
|
+
* Gated on `cfg.modules.polls`: off (or no poll posts on the page) skips the
|
|
86
|
+
* RPC/select round trip entirely and returns the empty shape.
|
|
87
|
+
*/
|
|
88
|
+
async function fetchPollData(cfg, postIds, uid) {
|
|
89
|
+
if (!cfg.modules.polls || postIds.length === 0)
|
|
90
|
+
return models_1.EMPTY_POLL_DATA;
|
|
91
|
+
const client = cfg.requireClient();
|
|
92
|
+
const [countsRes, myVotesRes] = await Promise.all([
|
|
93
|
+
client.rpc("poll_vote_counts", { p_post_ids: postIds }),
|
|
94
|
+
client
|
|
95
|
+
.from("poll_votes")
|
|
96
|
+
.select("post_id, option_id")
|
|
97
|
+
.eq("user_id", uid)
|
|
98
|
+
.in("post_id", postIds),
|
|
99
|
+
]);
|
|
100
|
+
if (countsRes.error)
|
|
101
|
+
throw countsRes.error;
|
|
102
|
+
if (myVotesRes.error)
|
|
103
|
+
throw myVotesRes.error;
|
|
104
|
+
const counts = new Map();
|
|
105
|
+
for (const row of (countsRes.data ?? [])) {
|
|
106
|
+
counts.set(row.option_id, Number(row.votes));
|
|
107
|
+
}
|
|
108
|
+
const myVotes = new Map();
|
|
109
|
+
for (const row of (myVotesRes.data ?? [])) {
|
|
110
|
+
myVotes.set(row.post_id, row.option_id);
|
|
111
|
+
}
|
|
112
|
+
return { counts, myVotes };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Batched reaction state for one page: count + last reactor's display name + my-reaction
|
|
116
|
+
* flag, all from the single security-definer `post_reaction_summary` RPC (the backend
|
|
117
|
+
* already resolves "did I react" server-side, so unlike the mold's prayer batching this
|
|
118
|
+
* needs no second query). Gated on `cfg.modules.reaction`; a missing/erroring RPC (e.g.
|
|
119
|
+
* the reaction module's migration not installed yet) degrades to no reaction data instead
|
|
120
|
+
* of taking the whole feed down.
|
|
121
|
+
*/
|
|
122
|
+
async function fetchReactionSummaries(cfg, postIds) {
|
|
123
|
+
if (!cfg.modules.reaction || postIds.length === 0)
|
|
124
|
+
return models_1.EMPTY_REACTION_DATA;
|
|
125
|
+
const client = cfg.requireClient();
|
|
126
|
+
const { data, error } = await client.rpc("post_reaction_summary", { p_post_ids: postIds });
|
|
127
|
+
if (error)
|
|
128
|
+
return models_1.EMPTY_REACTION_DATA;
|
|
129
|
+
const counts = new Map();
|
|
130
|
+
const mine = new Set();
|
|
131
|
+
const lastReactorName = new Map();
|
|
132
|
+
for (const row of (data ?? [])) {
|
|
133
|
+
counts.set(row.post_id, Number(row.reaction_count));
|
|
134
|
+
lastReactorName.set(row.post_id, row.last_reactor_name);
|
|
135
|
+
if (row.has_reacted)
|
|
136
|
+
mine.add(row.post_id);
|
|
137
|
+
}
|
|
138
|
+
return { counts, mine, lastReactorName };
|
|
139
|
+
}
|
|
140
|
+
/** Attach my-like state + poll state + reaction state to a batch of raw post
|
|
141
|
+
* rows and map to the UI model. */
|
|
142
|
+
async function toFeedPosts(cfg, client, rows, uid) {
|
|
143
|
+
const postIds = rows.map((r) => r.id);
|
|
144
|
+
const [liked, pollData, reactionData] = await Promise.all([
|
|
145
|
+
fetchMyLikes(client, postIds, uid),
|
|
146
|
+
fetchPollData(cfg, rows.filter((r) => r.poll_options.length > 0).map((r) => r.id), uid),
|
|
147
|
+
fetchReactionSummaries(cfg, postIds),
|
|
148
|
+
]);
|
|
149
|
+
return rows.map((r) => (0, models_1.mapPostRow)(r, uid, cfg.anonymousAuthorFallback, cfg.topics, liked, pollData, reactionData, cfg.feed.transformPost));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* One feed page, newest first. RLS already excludes other users' hidden and
|
|
153
|
+
* blocked-author content; we only exclude our own soft-deleted posts.
|
|
154
|
+
*/
|
|
155
|
+
async function fetchFeedPage(cfg, opts = {}) {
|
|
156
|
+
const client = cfg.requireClient();
|
|
157
|
+
const uid = await requireUid(cfg);
|
|
158
|
+
const page = opts.cursor ?? 0;
|
|
159
|
+
let query = client
|
|
160
|
+
.from("posts")
|
|
161
|
+
.select(buildFeedSelect(cfg.feed.extraPostColumns))
|
|
162
|
+
.eq("comments.status", "visible")
|
|
163
|
+
// Others' rows are already limited to 'visible' by RLS; this also shows the
|
|
164
|
+
// author their own 'pending' posts (optimistic) while hiding their moderated-out
|
|
165
|
+
// ones.
|
|
166
|
+
.in("status", ["visible", "pending"])
|
|
167
|
+
.order("pinned_at", { ascending: false, nullsFirst: false })
|
|
168
|
+
.order("created_at", { ascending: false })
|
|
169
|
+
.range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
|
|
170
|
+
if (opts.topic)
|
|
171
|
+
query = query.eq("topic", opts.topic);
|
|
172
|
+
const { data, error } = await query;
|
|
173
|
+
if (error)
|
|
174
|
+
throw error;
|
|
175
|
+
return toFeedPosts(cfg, client, (data ?? []), uid);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Count of visible posts newer than `sinceIso`, excluding my own, for the
|
|
179
|
+
* given topic. RLS already excludes blocked authors and hidden/deleted rows.
|
|
180
|
+
* `topic` is beyond the brief's literal signature but kept (both source apps
|
|
181
|
+
* take it) so a topic-filtered feed's "N new posts" pill stays accurate.
|
|
182
|
+
*/
|
|
183
|
+
async function countNewPosts(cfg, sinceIso, topic) {
|
|
184
|
+
const client = cfg.requireClient();
|
|
185
|
+
const uid = await requireUid(cfg);
|
|
186
|
+
let query = client
|
|
187
|
+
.from("posts")
|
|
188
|
+
.select("id", { count: "exact", head: true })
|
|
189
|
+
.eq("status", "visible")
|
|
190
|
+
.gt("created_at", sinceIso)
|
|
191
|
+
.neq("author_id", uid);
|
|
192
|
+
if (topic)
|
|
193
|
+
query = query.eq("topic", topic);
|
|
194
|
+
const { count, error } = await query;
|
|
195
|
+
if (error)
|
|
196
|
+
throw error;
|
|
197
|
+
return count ?? 0;
|
|
198
|
+
}
|
|
199
|
+
/** A single profile by id, e.g. for the profile page header. */
|
|
200
|
+
async function fetchProfile(cfg, userId) {
|
|
201
|
+
const client = cfg.requireClient();
|
|
202
|
+
const { data, error } = await client
|
|
203
|
+
.from("profiles")
|
|
204
|
+
.select(PROFILE_SELECT)
|
|
205
|
+
.eq("id", userId)
|
|
206
|
+
.single();
|
|
207
|
+
if (error)
|
|
208
|
+
throw error;
|
|
209
|
+
return (0, models_1.mapProfileRow)(data, cfg.anonymousAuthorFallback);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* One user's posts, newest first (no pinned ordering — a profile page is
|
|
213
|
+
* strictly chronological). Same visibility rules as the main feed, same
|
|
214
|
+
* page-index cursor as `fetchFeedPage` (product decision, reversing the
|
|
215
|
+
* earlier single-page scope cut).
|
|
216
|
+
*/
|
|
217
|
+
async function fetchUserPosts(cfg, userId, opts = {}) {
|
|
218
|
+
const client = cfg.requireClient();
|
|
219
|
+
const uid = await requireUid(cfg);
|
|
220
|
+
const page = opts.cursor ?? 0;
|
|
221
|
+
const { data, error } = await client
|
|
222
|
+
.from("posts")
|
|
223
|
+
.select(buildFeedSelect(cfg.feed.extraPostColumns))
|
|
224
|
+
.eq("author_id", userId)
|
|
225
|
+
.eq("comments.status", "visible")
|
|
226
|
+
.in("status", ["visible", "pending"])
|
|
227
|
+
.order("created_at", { ascending: false })
|
|
228
|
+
.range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
|
|
229
|
+
if (error)
|
|
230
|
+
throw error;
|
|
231
|
+
return toFeedPosts(cfg, client, (data ?? []), uid);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Full-text-ish search over visible post content (newest first), same
|
|
235
|
+
* page-index cursor as `fetchFeedPage` (product decision, reversing the
|
|
236
|
+
* earlier single-page scope cut).
|
|
237
|
+
*/
|
|
238
|
+
async function searchPosts(cfg, query, opts = {}) {
|
|
239
|
+
const cleaned = query.trim();
|
|
240
|
+
if (cleaned.length === 0)
|
|
241
|
+
return [];
|
|
242
|
+
const client = cfg.requireClient();
|
|
243
|
+
const uid = await requireUid(cfg);
|
|
244
|
+
const page = opts.cursor ?? 0;
|
|
245
|
+
const { data, error } = await client
|
|
246
|
+
.from("posts")
|
|
247
|
+
.select(buildFeedSelect(cfg.feed.extraPostColumns))
|
|
248
|
+
.eq("comments.status", "visible")
|
|
249
|
+
.eq("status", "visible")
|
|
250
|
+
.ilike("content", `%${cleaned}%`)
|
|
251
|
+
.order("created_at", { ascending: false })
|
|
252
|
+
.range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
|
|
253
|
+
if (error)
|
|
254
|
+
throw error;
|
|
255
|
+
return toFeedPosts(cfg, client, (data ?? []), uid);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Note: `cfg.feed.extraPostColumns`/`transformPost` do NOT apply here — this
|
|
259
|
+
* queries `comments`, not `posts` (the thread's parent post is passed in by
|
|
260
|
+
* the caller from an already-fetched, already-transformed `FeedPost`, not
|
|
261
|
+
* re-fetched).
|
|
262
|
+
*/
|
|
263
|
+
async function fetchThread(cfg, postId) {
|
|
264
|
+
const client = cfg.requireClient();
|
|
265
|
+
const uid = await requireUid(cfg);
|
|
266
|
+
const { data, error } = await client
|
|
267
|
+
.from("comments")
|
|
268
|
+
.select("id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)")
|
|
269
|
+
.eq("post_id", postId)
|
|
270
|
+
.in("status", ["visible", "pending"])
|
|
271
|
+
.order("created_at", { ascending: true });
|
|
272
|
+
if (error)
|
|
273
|
+
throw error;
|
|
274
|
+
return (data ?? []).map((r) => (0, models_1.mapCommentRow)(r, uid, cfg.anonymousAuthorFallback));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Insert a post as 'pending' (invisible to others until moderated). Returns id.
|
|
278
|
+
* With pollOptions, the post and its options are created transactionally by
|
|
279
|
+
* the create_poll_post RPC (security invoker, same RLS as a plain insert);
|
|
280
|
+
* that branch is gated on `cfg.modules.polls`.
|
|
281
|
+
*/
|
|
282
|
+
async function createPost(cfg, input) {
|
|
283
|
+
const { topic, body, pollOptions } = input;
|
|
284
|
+
const hasPoll = !!pollOptions && pollOptions.length > 0;
|
|
285
|
+
if (hasPoll && !cfg.modules.polls)
|
|
286
|
+
throw new Error("polls module is not enabled");
|
|
287
|
+
const client = cfg.requireClient();
|
|
288
|
+
const uid = await requireUid(cfg);
|
|
289
|
+
if (hasPoll) {
|
|
290
|
+
const { data, error } = await client.rpc("create_poll_post", {
|
|
291
|
+
p_topic: topic,
|
|
292
|
+
p_content: body,
|
|
293
|
+
p_options: pollOptions,
|
|
294
|
+
});
|
|
295
|
+
if (error)
|
|
296
|
+
throw error;
|
|
297
|
+
return data;
|
|
298
|
+
}
|
|
299
|
+
const { data, error } = await client
|
|
300
|
+
.from("posts")
|
|
301
|
+
.insert({ author_id: uid, topic, content: body })
|
|
302
|
+
.select("id")
|
|
303
|
+
.single();
|
|
304
|
+
if (error)
|
|
305
|
+
throw error;
|
|
306
|
+
return data.id;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Cast or change my vote: one row per (post, user), upsert swaps the option.
|
|
310
|
+
* `optionId` is the poll option's row id (matching `FeedPoll.myOptionId` /
|
|
311
|
+
* `applyPollVote`'s optimistic-update contract from Task 3), not a numeric
|
|
312
|
+
* index — see the task report for this naming call against the brief.
|
|
313
|
+
* Gated on `cfg.modules.polls` for consistent DX with the reaction module.
|
|
314
|
+
*/
|
|
315
|
+
async function votePoll(cfg, postId, optionId) {
|
|
316
|
+
if (!cfg.modules.polls)
|
|
317
|
+
throw new Error("polls module is not enabled");
|
|
318
|
+
const client = cfg.requireClient();
|
|
319
|
+
const uid = await requireUid(cfg);
|
|
320
|
+
const { error } = await client
|
|
321
|
+
.from("poll_votes")
|
|
322
|
+
.upsert({ post_id: postId, option_id: optionId, user_id: uid }, { onConflict: "post_id,user_id" });
|
|
323
|
+
if (error)
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
/** Insert a comment as 'pending'. Returns id. */
|
|
327
|
+
async function createComment(cfg, postId, body) {
|
|
328
|
+
const client = cfg.requireClient();
|
|
329
|
+
const uid = await requireUid(cfg);
|
|
330
|
+
const { data, error } = await client
|
|
331
|
+
.from("comments")
|
|
332
|
+
.insert({ post_id: postId, author_id: uid, content: body })
|
|
333
|
+
.select("id")
|
|
334
|
+
.single();
|
|
335
|
+
if (error)
|
|
336
|
+
throw error;
|
|
337
|
+
return data.id;
|
|
338
|
+
}
|
|
339
|
+
/** Run synchronous moderation on a just-created pending post/comment. */
|
|
340
|
+
async function moderateOne(cfg, input) {
|
|
341
|
+
const client = cfg.requireClient();
|
|
342
|
+
const { data, error } = await client.functions.invoke("moderate-one", { body: input });
|
|
343
|
+
if (error)
|
|
344
|
+
return { status: "pending", reason: "moderation unavailable" };
|
|
345
|
+
return data;
|
|
346
|
+
}
|
|
347
|
+
async function setLike(cfg, postId, on) {
|
|
348
|
+
const client = cfg.requireClient();
|
|
349
|
+
const uid = await requireUid(cfg);
|
|
350
|
+
if (on) {
|
|
351
|
+
const { error } = await client.from("likes").insert({ post_id: postId, user_id: uid });
|
|
352
|
+
// 23505 = already liked (double tap / retry): the desired state holds.
|
|
353
|
+
if (error && error.code !== "23505")
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const { error } = await client.from("likes").delete().match({ post_id: postId, user_id: uid });
|
|
358
|
+
if (error)
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* `setPrayed` generalized to `setReaction`: insert my private, non-retractable
|
|
364
|
+
* reaction row (rows are never deleted — see spec §6). Gated on `cfg.modules.reaction`.
|
|
365
|
+
*/
|
|
366
|
+
async function setReaction(cfg, postId) {
|
|
367
|
+
if (!cfg.modules.reaction)
|
|
368
|
+
throw new Error("reaction module is not enabled");
|
|
369
|
+
const client = cfg.requireClient();
|
|
370
|
+
const uid = await requireUid(cfg);
|
|
371
|
+
const { error } = await client.from("post_reactions").insert({ post_id: postId, user_id: uid });
|
|
372
|
+
// 23505 = already reacted to this post: the desired state holds.
|
|
373
|
+
if (error && error.code !== "23505")
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
async function reportContent(cfg, input) {
|
|
377
|
+
const client = cfg.requireClient();
|
|
378
|
+
const uid = await requireUid(cfg);
|
|
379
|
+
const { error } = await client.from("reports").insert({
|
|
380
|
+
reporter_id: uid,
|
|
381
|
+
reported_user_id: input.reportedUserId,
|
|
382
|
+
post_id: input.postId ?? null,
|
|
383
|
+
comment_id: input.commentId ?? null,
|
|
384
|
+
reason: input.reason,
|
|
385
|
+
details: input.details?.trim() || null,
|
|
386
|
+
});
|
|
387
|
+
if (error)
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
async function blockUser(cfg, blockedId) {
|
|
391
|
+
const client = cfg.requireClient();
|
|
392
|
+
const uid = await requireUid(cfg);
|
|
393
|
+
const { error } = await client.from("blocks").insert({ blocker_id: uid, blocked_id: blockedId });
|
|
394
|
+
if (error && error.code !== "23505")
|
|
395
|
+
throw error; // already blocked is fine
|
|
396
|
+
}
|
|
397
|
+
async function deleteOwnPost(cfg, postId) {
|
|
398
|
+
const client = cfg.requireClient();
|
|
399
|
+
await requireUid(cfg);
|
|
400
|
+
const { error } = await client.from("posts").update({ status: "deleted" }).eq("id", postId);
|
|
401
|
+
if (error)
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
async function deleteOwnComment(cfg, commentId) {
|
|
405
|
+
const client = cfg.requireClient();
|
|
406
|
+
await requireUid(cfg);
|
|
407
|
+
const { error } = await client.from("comments").update({ status: "deleted" }).eq("id", commentId);
|
|
408
|
+
if (error)
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The only write path for handle/bio/avatar — moderated server-side by the
|
|
413
|
+
* `update-profile` Edge Function. `invoke` treats any non-2xx as `error`; a
|
|
414
|
+
* `FunctionsHttpError` carries the typed JSON body (e.g. `handle_taken`) on
|
|
415
|
+
* its `context` response, other error kinds mean the request never landed.
|
|
416
|
+
*/
|
|
417
|
+
async function updateProfile(cfg, input) {
|
|
418
|
+
const client = cfg.requireClient();
|
|
419
|
+
const { handle, bio, avatarUrl } = input;
|
|
420
|
+
const { data, error } = await client.functions.invoke("update-profile", {
|
|
421
|
+
body: { handle, bio, avatarPath: avatarUrl },
|
|
422
|
+
});
|
|
423
|
+
if (!error)
|
|
424
|
+
return data;
|
|
425
|
+
if (error instanceof supabase_js_1.FunctionsHttpError) {
|
|
426
|
+
// A platform-level 5xx can carry a non-JSON body: degrade to a network error.
|
|
427
|
+
try {
|
|
428
|
+
return (await error.context.json());
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return { status: "error", error: "network" };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return { status: "error", error: "network" };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Upload a JPEG avatar to the `avatars` bucket from a local file URI. Returns
|
|
438
|
+
* the storage path. Takes a `fileUri` (not a base64 string, unlike the mold)
|
|
439
|
+
* so the core has no Hermes/`atob` dependency — `fetch` on a `file://` URI is
|
|
440
|
+
* the portable way to read local bytes in React Native.
|
|
441
|
+
*/
|
|
442
|
+
async function uploadAvatar(cfg, fileUri) {
|
|
443
|
+
const client = cfg.requireClient();
|
|
444
|
+
const uid = await requireUid(cfg);
|
|
445
|
+
const path = `${uid}/${Date.now()}.jpg`;
|
|
446
|
+
const response = await fetch(fileUri);
|
|
447
|
+
const bytes = await response.arrayBuffer();
|
|
448
|
+
const { error } = await client.storage
|
|
449
|
+
.from(AVATAR_BUCKET)
|
|
450
|
+
.upload(path, bytes, { contentType: "image/jpeg" });
|
|
451
|
+
if (error)
|
|
452
|
+
throw error;
|
|
453
|
+
return path;
|
|
454
|
+
}
|
package/lib/time.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Pure, language-free decomposition of an elapsed duration, for a consumer
|
|
2
|
+
* (e.g. `@rocapine/community-ui`'s `formatTimeAgo`) to localize into its own
|
|
3
|
+
* catalog. `unit: "now"` carries no `value` worth formatting. */
|
|
4
|
+
export type TimeAgoParts = {
|
|
5
|
+
unit: "now";
|
|
6
|
+
value: 0;
|
|
7
|
+
} | {
|
|
8
|
+
unit: "minute" | "hour" | "day";
|
|
9
|
+
value: number;
|
|
10
|
+
};
|
|
11
|
+
/** Decompose `iso` relative to `nowMs` into a unit + count, with no language
|
|
12
|
+
* baked in — see `timeAgo` for the hardcoded-English string this replaces
|
|
13
|
+
* core-internally, kept for back-compat. */
|
|
14
|
+
export declare function timeAgoParts(iso: string, nowMs: number): TimeAgoParts;
|
|
15
|
+
/**
|
|
16
|
+
* Compact relative label: "now", "12m", "5h", "3d". Hardcoded English —
|
|
17
|
+
* kept for back-compat, but `@rocapine/community-ui` no longer calls this
|
|
18
|
+
* itself; it localizes through `timeAgoParts` + its own catalog instead (see
|
|
19
|
+
* `formatTimeAgo` in the ui package).
|
|
20
|
+
*/
|
|
21
|
+
export declare function timeAgo(iso: string, nowMs: number): string;
|
package/lib/time.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Display helpers for feeds. No React Native / Supabase imports: pure, exercised in node (vitest).
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.timeAgoParts = timeAgoParts;
|
|
5
|
+
exports.timeAgo = timeAgo;
|
|
6
|
+
const MIN = 60000;
|
|
7
|
+
const HOUR = 3600000;
|
|
8
|
+
/** Decompose `iso` relative to `nowMs` into a unit + count, with no language
|
|
9
|
+
* baked in — see `timeAgo` for the hardcoded-English string this replaces
|
|
10
|
+
* core-internally, kept for back-compat. */
|
|
11
|
+
function timeAgoParts(iso, nowMs) {
|
|
12
|
+
const elapsed = Math.max(0, nowMs - Date.parse(iso));
|
|
13
|
+
if (elapsed < MIN)
|
|
14
|
+
return { unit: "now", value: 0 };
|
|
15
|
+
if (elapsed < HOUR)
|
|
16
|
+
return { unit: "minute", value: Math.floor(elapsed / MIN) };
|
|
17
|
+
if (elapsed < 24 * HOUR)
|
|
18
|
+
return { unit: "hour", value: Math.floor(elapsed / HOUR) };
|
|
19
|
+
return { unit: "day", value: Math.floor(elapsed / (24 * HOUR)) };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Compact relative label: "now", "12m", "5h", "3d". Hardcoded English —
|
|
23
|
+
* kept for back-compat, but `@rocapine/community-ui` no longer calls this
|
|
24
|
+
* itself; it localizes through `timeAgoParts` + its own catalog instead (see
|
|
25
|
+
* `formatTimeAgo` in the ui package).
|
|
26
|
+
*/
|
|
27
|
+
function timeAgo(iso, nowMs) {
|
|
28
|
+
const parts = timeAgoParts(iso, nowMs);
|
|
29
|
+
if (parts.unit === "now")
|
|
30
|
+
return "now";
|
|
31
|
+
return `${parts.value}${parts.unit === "minute" ? "m" : parts.unit === "hour" ? "h" : "d"}`;
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rocapine/community-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless Supabase-backed community backend: identity, feed/thread/moderation services, and React Query hooks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Rocapine/community-sdk"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"types": "lib/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"build": "tsc -p tsconfig.json --outDir lib --noEmit false",
|
|
19
|
+
"test": "vitest run"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": ">=18",
|
|
23
|
+
"@supabase/supabase-js": "^2.45.0",
|
|
24
|
+
"@tanstack/react-query": "^5.50.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"react": "19.2.0",
|
|
28
|
+
"@supabase/supabase-js": "^2.45.0",
|
|
29
|
+
"@tanstack/react-query": "^5.50.0",
|
|
30
|
+
"@types/react": "^19.2.0"
|
|
31
|
+
}
|
|
32
|
+
}
|