Social: typed realtime events + searchUsers + cross-resource search

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) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-16 15:25:00 +10:00
parent c117284381
commit e1a557a213
2 changed files with 89 additions and 0 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>;
}

View File

@@ -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<SocialProfile>;
getProfile(userId: string): Promise<SocialProfile>;
listProfiles(): Promise<SocialProfile[]>;
/** @ 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<SocialUserMatch[]>;
// ---------- articles ----------
listArticles(opts?: { authorId?: string }): Promise<SocialArticle[]>;
@@ -180,6 +217,14 @@ export interface SocialBindings {
): Promise<SocialSubscription>;
removeSubscription(agentId: string): Promise<void>;
// ---------- search ----------
/** Cross-resource search across posts + articles, tenant-scoped.
* Empty query → empty result. */
search(
query: string,
opts?: { limit?: number; kinds?: ("post" | "article")[] },
): Promise<SocialSearchHit[]>;
// ---------- notifications ----------
listNotifications(opts?: {
limit?: number;
@@ -213,6 +258,15 @@ export function createSocialBindings(client: ArcadiaClient): SocialBindings {
.then(unwrap),
listProfiles: () =>
client.GET<Envelope<SocialProfile[]>>(`${base}/profiles`).then(unwrap),
searchUsers: (prefix) => {
const trimmed = prefix.trim()
if (trimmed === "") return Promise.resolve([])
return client
.GET<Envelope<SocialUserMatch[]>>(`${base}/users/search`, {
params: { q: trimmed },
})
.then(unwrap)
},
// articles
listArticles: (opts) =>
@@ -301,6 +355,20 @@ export function createSocialBindings(client: ArcadiaClient): SocialBindings {
await client.DELETE<void>(`${base}/subscriptions/${agentId}`);
},
// search
search: (query, opts) => {
const q = query.trim()
if (q === "") return Promise.resolve([])
const params: Record<string, string | number> = { q }
if (opts?.limit !== undefined) params.limit = opts.limit
if (opts?.kinds && opts.kinds.length > 0) {
params.kinds = opts.kinds.join(",")
}
return client
.GET<Envelope<SocialSearchHit[]>>(`${base}/search`, { params })
.then(unwrap)
},
// notifications
listNotifications: (opts) =>
client.GET<SocialNotificationList>(`${base}/notifications`, {