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:
@@ -19,6 +19,27 @@ export interface TenantEventMap {
|
|||||||
announcement: { id: string; title: string; body?: string; [k: string]: unknown };
|
announcement: { id: string; title: string; body?: string; [k: string]: unknown };
|
||||||
status_update: { id: string; component?: string; status?: string; [k: string]: unknown };
|
status_update: { id: string; component?: string; status?: string; [k: string]: unknown };
|
||||||
event: { type: string; payload?: unknown; [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>;
|
[key: string]: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,39 @@ export interface SocialNotificationList {
|
|||||||
unread_count: number;
|
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
|
// Bindings
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -148,6 +181,10 @@ export interface SocialBindings {
|
|||||||
updateMyProfile(patch: SocialProfileUpdate): Promise<SocialProfile>;
|
updateMyProfile(patch: SocialProfileUpdate): Promise<SocialProfile>;
|
||||||
getProfile(userId: string): Promise<SocialProfile>;
|
getProfile(userId: string): Promise<SocialProfile>;
|
||||||
listProfiles(): 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 ----------
|
// ---------- articles ----------
|
||||||
listArticles(opts?: { authorId?: string }): Promise<SocialArticle[]>;
|
listArticles(opts?: { authorId?: string }): Promise<SocialArticle[]>;
|
||||||
@@ -180,6 +217,14 @@ export interface SocialBindings {
|
|||||||
): Promise<SocialSubscription>;
|
): Promise<SocialSubscription>;
|
||||||
removeSubscription(agentId: string): Promise<void>;
|
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 ----------
|
// ---------- notifications ----------
|
||||||
listNotifications(opts?: {
|
listNotifications(opts?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -213,6 +258,15 @@ export function createSocialBindings(client: ArcadiaClient): SocialBindings {
|
|||||||
.then(unwrap),
|
.then(unwrap),
|
||||||
listProfiles: () =>
|
listProfiles: () =>
|
||||||
client.GET<Envelope<SocialProfile[]>>(`${base}/profiles`).then(unwrap),
|
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
|
// articles
|
||||||
listArticles: (opts) =>
|
listArticles: (opts) =>
|
||||||
@@ -301,6 +355,20 @@ export function createSocialBindings(client: ArcadiaClient): SocialBindings {
|
|||||||
await client.DELETE<void>(`${base}/subscriptions/${agentId}`);
|
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
|
// notifications
|
||||||
listNotifications: (opts) =>
|
listNotifications: (opts) =>
|
||||||
client.GET<SocialNotificationList>(`${base}/notifications`, {
|
client.GET<SocialNotificationList>(`${base}/notifications`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user