From e1a557a213649248284d485451d8bffab99d5e69 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 16 May 2026 15:25:00 +1000 Subject: [PATCH] Social: typed realtime events + searchUsers + cross-resource search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires three new shapes to the bindings: - TenantEventMap gains `social:notification`, `social:post`, `social:article` — typed payloads for the channel pushes the Phoenix TenantChannel now forwards. Consumers subscribe via the existing `useArcadiaSubscription` hook. - SocialBindings.searchUsers(prefix) → `[{user_id, handle, display_name}]`. Empty prefix returns []. Powers @ autocomplete. - SocialBindings.search(query, {limit, kinds}) → discriminated union of post/article hits. Tenant-scoped server-side; visibility honored. Empty query returns []. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/realtime.ts | 21 +++++++++++++++ src/social.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/realtime.ts b/src/realtime.ts index 65156c7..9e9022e 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -19,6 +19,27 @@ export interface TenantEventMap { announcement: { id: string; title: string; body?: string; [k: string]: unknown }; status_update: { id: string; component?: string; status?: string; [k: string]: unknown }; event: { type: string; payload?: unknown; [k: string]: unknown }; + /** Social: notification row written for the connected user. */ + "social:notification": { + id: string; + kind: string; + payload: Record; + read_at: string | null; + inserted_at: string; + }; + /** Social: board post created/replied/updated/deleted in tenant. */ + "social:post": { + action: "created" | "reply" | "updated" | "deleted"; + post_id: string; + parent_id: string | null; + author_id: string; + }; + /** Social: article created/updated/deleted in tenant. */ + "social:article": { + action: "created" | "updated" | "deleted"; + article_id: string; + author_id: string; + }; [key: string]: Record; } diff --git a/src/social.ts b/src/social.ts index fe9a60d..2ffd6a4 100644 --- a/src/social.ts +++ b/src/social.ts @@ -129,6 +129,39 @@ export interface SocialNotificationList { unread_count: number; } +/** One row from `/social/users/search`. `handle` is the lowercased + * email local-part stand-in until profiles carry their own handle + * column. `display_name` is null when the user hasn't filled in + * their profile. */ +export interface SocialUserMatch { + user_id: string; + handle: string; + display_name: string | null; +} + +/** Search result — discriminated by `kind`. Post hits carry a + * body snippet; article hits carry title + summary + body snippet. */ +export type SocialSearchHit = + | { + kind: "post"; + id: string; + parent_id: string | null; + author_id: string; + snippet: string; + inserted_at: string; + } + | { + kind: "article"; + id: string; + author_id: string; + title: string; + slug: string; + summary: string | null; + visibility: SocialVisibility; + snippet: string; + inserted_at: string; + }; + // ============================================================================ // Bindings // ============================================================================ @@ -148,6 +181,10 @@ export interface SocialBindings { updateMyProfile(patch: SocialProfileUpdate): Promise; getProfile(userId: string): Promise; listProfiles(): Promise; + /** @ autocomplete — returns up to 8 users in the viewer's tenant + * whose handle (email local-part stand-in) starts with `prefix`. + * Empty prefix → empty array (no full enumeration). */ + searchUsers(prefix: string): Promise; // ---------- articles ---------- listArticles(opts?: { authorId?: string }): Promise; @@ -180,6 +217,14 @@ export interface SocialBindings { ): Promise; removeSubscription(agentId: string): Promise; + // ---------- search ---------- + /** Cross-resource search across posts + articles, tenant-scoped. + * Empty query → empty result. */ + search( + query: string, + opts?: { limit?: number; kinds?: ("post" | "article")[] }, + ): Promise; + // ---------- notifications ---------- listNotifications(opts?: { limit?: number; @@ -213,6 +258,15 @@ export function createSocialBindings(client: ArcadiaClient): SocialBindings { .then(unwrap), listProfiles: () => client.GET>(`${base}/profiles`).then(unwrap), + searchUsers: (prefix) => { + const trimmed = prefix.trim() + if (trimmed === "") return Promise.resolve([]) + return client + .GET>(`${base}/users/search`, { + params: { q: trimmed }, + }) + .then(unwrap) + }, // articles listArticles: (opts) => @@ -301,6 +355,20 @@ export function createSocialBindings(client: ArcadiaClient): SocialBindings { await client.DELETE(`${base}/subscriptions/${agentId}`); }, + // search + search: (query, opts) => { + const q = query.trim() + if (q === "") return Promise.resolve([]) + const params: Record = { q } + if (opts?.limit !== undefined) params.limit = opts.limit + if (opts?.kinds && opts.kinds.length > 0) { + params.kinds = opts.kinds.join(",") + } + return client + .GET>(`${base}/search`, { params }) + .then(unwrap) + }, + // notifications listNotifications: (opts) => client.GET(`${base}/notifications`, {