diff --git a/src/index.tsx b/src/index.tsx index 3cad2d0..b9dd8c7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,11 @@ // useArcadiaSubscription // Realtime: createArcadiaRealtime, socketUrlFromBaseUrl, // type ArcadiaRealtime, type TenantEventMap, type RealtimeStatus +// Social: createSocialBindings, type SocialBindings, +// + wire types (SocialProfile, SocialArticle, SocialPost, +// SocialThread, SocialFavourite, SocialSubscription, +// SocialNotification, SocialNotificationList, +// SocialNotificationKind, SocialVisibility) // Types: re-exported subset from ./types and ./generated/openapi // =========================================================================== "use client"; @@ -27,4 +32,5 @@ export * from "./client"; export * from "./errors"; export * from "./provider"; export * from "./realtime"; +export * from "./social"; export * from "./types"; diff --git a/src/social.ts b/src/social.ts new file mode 100644 index 0000000..fe9a60d --- /dev/null +++ b/src/social.ts @@ -0,0 +1,319 @@ +// Typed bindings for /api/v1/social/* — profiles, articles, board +// posts, agent favourites + DNA-change subscriptions, per-user +// notifications. +// +// Uses the generic `client.GET/POST/...` rather than the typed +// openapi-fetch surface because the OpenAPI spec doesn't yet include +// these endpoints. The shapes below mirror the Phoenix JSON modules +// in `apps/arcadia_core/lib/arcadia_web/controllers/api/social/`. +// When the spec is regenerated to include /social, these can migrate +// to client.typed without changing the caller surface. + +import type { ArcadiaClient } from "./client"; + +// ============================================================================ +// Wire types +// ============================================================================ + +export type SocialVisibility = "private" | "tenant"; + +export interface SocialProfile { + id: string; + user_id: string; + tenant_id: string; + display_name: string | null; + headline: string | null; + bio: string; + location: string | null; + website: string | null; + visibility: SocialVisibility | "tenant"; + inserted_at: string; + updated_at: string; +} + +export interface SocialProfileUpdate { + display_name?: string | null; + headline?: string | null; + bio?: string; + location?: string | null; + website?: string | null; + visibility?: SocialVisibility; +} + +export interface SocialArticle { + id: string; + author_id: string; + tenant_id: string; + title: string; + slug: string; + body_md: string; + summary: string | null; + visibility: SocialVisibility; + deleted_at: string | null; + inserted_at: string; + updated_at: string; +} + +export interface SocialArticleCreate { + title: string; + slug: string; + body_md: string; + summary?: string | null; + visibility?: SocialVisibility; +} + +export type SocialArticleUpdate = Partial; + +export interface SocialPost { + id: string; + author_id: string; + tenant_id: string; + parent_id: string | null; + topic: string | null; + body_md: string; + reply_count: number; + deleted_at: string | null; + inserted_at: string; + updated_at: string; +} + +export interface SocialPostCreate { + body_md: string; + /** Set to a top-level post id to create a reply. Replies to replies + * fold up to the top-level parent server-side. */ + parent_id?: string; + topic?: string | null; +} + +export interface SocialThread { + post: SocialPost; + replies: SocialPost[]; +} + +export interface SocialFavourite { + id: string; + user_id: string; + tenant_id: string; + agent_id: string; + note: string; + inserted_at: string; + updated_at: string; +} + +export interface SocialSubscription { + id: string; + user_id: string; + tenant_id: string; + agent_id: string; + last_seen_dna_hash: string | null; + inserted_at: string; + updated_at: string; +} + +export type SocialNotificationKind = + | "mention" + | "post_reply" + | "dna_change" + | (string & { _unknownKind?: never }); + +export interface SocialNotification { + id: string; + kind: SocialNotificationKind; + payload: Record; + read_at: string | null; + inserted_at: string; +} + +export interface SocialNotificationList { + data: SocialNotification[]; + unread_count: number; +} + +// ============================================================================ +// Bindings +// ============================================================================ + +interface Envelope { + data: T; +} + +interface ThreadEnvelope { + data: SocialPost; + replies: SocialPost[]; +} + +export interface SocialBindings { + // ---------- profiles ---------- + getMyProfile(): Promise; + updateMyProfile(patch: SocialProfileUpdate): Promise; + getProfile(userId: string): Promise; + listProfiles(): Promise; + + // ---------- articles ---------- + listArticles(opts?: { authorId?: string }): Promise; + getArticle(id: string): Promise; + createArticle(attrs: SocialArticleCreate): Promise; + updateArticle(id: string, patch: SocialArticleUpdate): Promise; + deleteArticle(id: string): Promise; + + // ---------- board ---------- + listFeed(opts?: { limit?: number }): Promise; + getThread(postId: string): Promise; + createPost(body: SocialPostCreate): Promise; + reply(parentId: string, body: string): Promise; + updatePost(id: string, body: string): Promise; + deletePost(id: string): Promise; + + // ---------- favourites ---------- + listFavourites(): Promise; + addFavourite(agentId: string, note?: string): Promise; + removeFavourite(agentId: string): Promise; + + // ---------- subscriptions ---------- + listSubscriptions(): Promise; + /** Add a DNA-change subscription. Pass `lastSeenDnaHash` (the + * hash you just vetted) so the next change you hear about is a + * *real* change, not a notification at the current value. */ + addSubscription( + agentId: string, + opts?: { lastSeenDnaHash?: string }, + ): Promise; + removeSubscription(agentId: string): Promise; + + // ---------- notifications ---------- + listNotifications(opts?: { + limit?: number; + unreadOnly?: boolean; + }): Promise; + markNotificationRead(id: string): Promise; + markAllNotificationsRead(): Promise; +} + +/** Build a typed bindings object over an `ArcadiaClient`. The client + * is responsible for auth + tenant context — these methods just + * shape paths + payloads. */ +export function createSocialBindings(client: ArcadiaClient): SocialBindings { + const base = "/api/v1/social"; + + function unwrap(envelope: { data: T }): T { + return envelope.data; + } + + return { + // profiles + getMyProfile: () => + client.GET>(`${base}/me/profile`).then(unwrap), + updateMyProfile: (patch) => + client + .PUT>(`${base}/me/profile`, { body: patch }) + .then(unwrap), + getProfile: (userId) => + client + .GET>(`${base}/profiles/${userId}`) + .then(unwrap), + listProfiles: () => + client.GET>(`${base}/profiles`).then(unwrap), + + // articles + listArticles: (opts) => + client + .GET>(`${base}/articles`, { + params: opts?.authorId ? { author_id: opts.authorId } : undefined, + }) + .then(unwrap), + getArticle: (id) => + client + .GET>(`${base}/articles/${id}`) + .then(unwrap), + createArticle: (attrs) => + client + .POST>(`${base}/articles`, { body: attrs }) + .then(unwrap), + updateArticle: (id, patch) => + client + .PUT>(`${base}/articles/${id}`, { body: patch }) + .then(unwrap), + deleteArticle: async (id) => { + await client.DELETE(`${base}/articles/${id}`); + }, + + // board + listFeed: (opts) => + client + .GET>(`${base}/posts`, { + params: opts?.limit ? { limit: opts.limit } : undefined, + }) + .then(unwrap), + getThread: (postId) => + client + .GET(`${base}/posts/${postId}`) + .then((r) => ({ post: r.data, replies: r.replies })), + createPost: (body) => + client + .POST(`${base}/posts`, { body }) + .then((r) => r.data), + reply: (parentId, body) => + client + .POST(`${base}/posts`, { + body: { body_md: body, parent_id: parentId }, + }) + .then((r) => r.data), + updatePost: (id, body) => + client + .PUT(`${base}/posts/${id}`, { body: { body_md: body } }) + .then((r) => r.data), + deletePost: async (id) => { + await client.DELETE(`${base}/posts/${id}`); + }, + + // favourites + listFavourites: () => + client + .GET>(`${base}/favourites`) + .then(unwrap), + addFavourite: (agentId, note) => + client + .POST>(`${base}/favourites`, { + body: { agent_id: agentId, ...(note !== undefined ? { note } : {}) }, + }) + .then(unwrap), + removeFavourite: async (agentId) => { + await client.DELETE(`${base}/favourites/${agentId}`); + }, + + // subscriptions + listSubscriptions: () => + client + .GET>(`${base}/subscriptions`) + .then(unwrap), + addSubscription: (agentId, opts) => + client + .POST>(`${base}/subscriptions`, { + body: { + agent_id: agentId, + ...(opts?.lastSeenDnaHash !== undefined + ? { last_seen_dna_hash: opts.lastSeenDnaHash } + : {}), + }, + }) + .then(unwrap), + removeSubscription: async (agentId) => { + await client.DELETE(`${base}/subscriptions/${agentId}`); + }, + + // notifications + listNotifications: (opts) => + client.GET(`${base}/notifications`, { + params: { + ...(opts?.limit !== undefined ? { limit: opts.limit } : {}), + ...(opts?.unreadOnly ? { unread_only: true } : {}), + }, + }), + markNotificationRead: async (id) => { + await client.POST(`${base}/notifications/${id}/read`); + }, + markAllNotificationsRead: async () => { + await client.POST(`${base}/notifications/read-all`); + }, + }; +}