Social bindings — profiles, articles, board, favourites, subs, notifications

Typed wrappers for /api/v1/social/* on arcadia-app. Built over the
generic client.GET/POST surface rather than openapi-fetch — the spec
hasn't been regenerated to cover these endpoints, but the shapes
mirror the Phoenix JSON modules exactly and can migrate to the typed
surface later without caller changes.

Factory `createSocialBindings(client)` returns a SocialBindings
object with one method per operation:

  Profiles:    getMyProfile, updateMyProfile, getProfile, listProfiles
  Articles:    listArticles, getArticle, createArticle, updateArticle,
               deleteArticle
  Board:       listFeed, getThread, createPost, reply, updatePost,
               deletePost
  Favourites:  listFavourites, addFavourite, removeFavourite
  Subs:        listSubscriptions, addSubscription, removeSubscription
  Notifs:      listNotifications, markNotificationRead,
               markAllNotificationsRead

addSubscription takes an optional `lastSeenDnaHash` so callers can
record "the version I just vetted" — the platform's
on_dna_change fanout only pings users whose stored hash differs
from the new value, so passing it on subscribe means the user only
hears about *real* changes, not a notification at the value they
were already looking at.

Wire types mirror the Phoenix JSON modules; SocialNotificationKind
is `"mention" | "post_reply" | "dna_change" | (string & ...)` so
the union stays open to future kinds added platform-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-16 12:53:07 +10:00
parent 262a56c2e5
commit c117284381
2 changed files with 325 additions and 0 deletions

View File

@@ -19,6 +19,11 @@
// useArcadiaSubscription // useArcadiaSubscription
// Realtime: createArcadiaRealtime, socketUrlFromBaseUrl, // Realtime: createArcadiaRealtime, socketUrlFromBaseUrl,
// type ArcadiaRealtime, type TenantEventMap, type RealtimeStatus // 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 // Types: re-exported subset from ./types and ./generated/openapi
// =========================================================================== // ===========================================================================
"use client"; "use client";
@@ -27,4 +32,5 @@ export * from "./client";
export * from "./errors"; export * from "./errors";
export * from "./provider"; export * from "./provider";
export * from "./realtime"; export * from "./realtime";
export * from "./social";
export * from "./types"; export * from "./types";

319
src/social.ts Normal file
View File

@@ -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<SocialArticleCreate>;
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<string, unknown>;
read_at: string | null;
inserted_at: string;
}
export interface SocialNotificationList {
data: SocialNotification[];
unread_count: number;
}
// ============================================================================
// Bindings
// ============================================================================
interface Envelope<T> {
data: T;
}
interface ThreadEnvelope {
data: SocialPost;
replies: SocialPost[];
}
export interface SocialBindings {
// ---------- profiles ----------
getMyProfile(): Promise<SocialProfile>;
updateMyProfile(patch: SocialProfileUpdate): Promise<SocialProfile>;
getProfile(userId: string): Promise<SocialProfile>;
listProfiles(): Promise<SocialProfile[]>;
// ---------- articles ----------
listArticles(opts?: { authorId?: string }): Promise<SocialArticle[]>;
getArticle(id: string): Promise<SocialArticle>;
createArticle(attrs: SocialArticleCreate): Promise<SocialArticle>;
updateArticle(id: string, patch: SocialArticleUpdate): Promise<SocialArticle>;
deleteArticle(id: string): Promise<void>;
// ---------- board ----------
listFeed(opts?: { limit?: number }): Promise<SocialPost[]>;
getThread(postId: string): Promise<SocialThread>;
createPost(body: SocialPostCreate): Promise<SocialPost>;
reply(parentId: string, body: string): Promise<SocialPost>;
updatePost(id: string, body: string): Promise<SocialPost>;
deletePost(id: string): Promise<void>;
// ---------- favourites ----------
listFavourites(): Promise<SocialFavourite[]>;
addFavourite(agentId: string, note?: string): Promise<SocialFavourite>;
removeFavourite(agentId: string): Promise<void>;
// ---------- subscriptions ----------
listSubscriptions(): Promise<SocialSubscription[]>;
/** 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<SocialSubscription>;
removeSubscription(agentId: string): Promise<void>;
// ---------- notifications ----------
listNotifications(opts?: {
limit?: number;
unreadOnly?: boolean;
}): Promise<SocialNotificationList>;
markNotificationRead(id: string): Promise<void>;
markAllNotificationsRead(): Promise<void>;
}
/** 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<T>(envelope: { data: T }): T {
return envelope.data;
}
return {
// profiles
getMyProfile: () =>
client.GET<Envelope<SocialProfile>>(`${base}/me/profile`).then(unwrap),
updateMyProfile: (patch) =>
client
.PUT<Envelope<SocialProfile>>(`${base}/me/profile`, { body: patch })
.then(unwrap),
getProfile: (userId) =>
client
.GET<Envelope<SocialProfile>>(`${base}/profiles/${userId}`)
.then(unwrap),
listProfiles: () =>
client.GET<Envelope<SocialProfile[]>>(`${base}/profiles`).then(unwrap),
// articles
listArticles: (opts) =>
client
.GET<Envelope<SocialArticle[]>>(`${base}/articles`, {
params: opts?.authorId ? { author_id: opts.authorId } : undefined,
})
.then(unwrap),
getArticle: (id) =>
client
.GET<Envelope<SocialArticle>>(`${base}/articles/${id}`)
.then(unwrap),
createArticle: (attrs) =>
client
.POST<Envelope<SocialArticle>>(`${base}/articles`, { body: attrs })
.then(unwrap),
updateArticle: (id, patch) =>
client
.PUT<Envelope<SocialArticle>>(`${base}/articles/${id}`, { body: patch })
.then(unwrap),
deleteArticle: async (id) => {
await client.DELETE<void>(`${base}/articles/${id}`);
},
// board
listFeed: (opts) =>
client
.GET<Envelope<SocialPost[]>>(`${base}/posts`, {
params: opts?.limit ? { limit: opts.limit } : undefined,
})
.then(unwrap),
getThread: (postId) =>
client
.GET<ThreadEnvelope>(`${base}/posts/${postId}`)
.then((r) => ({ post: r.data, replies: r.replies })),
createPost: (body) =>
client
.POST<ThreadEnvelope>(`${base}/posts`, { body })
.then((r) => r.data),
reply: (parentId, body) =>
client
.POST<ThreadEnvelope>(`${base}/posts`, {
body: { body_md: body, parent_id: parentId },
})
.then((r) => r.data),
updatePost: (id, body) =>
client
.PUT<ThreadEnvelope>(`${base}/posts/${id}`, { body: { body_md: body } })
.then((r) => r.data),
deletePost: async (id) => {
await client.DELETE<void>(`${base}/posts/${id}`);
},
// favourites
listFavourites: () =>
client
.GET<Envelope<SocialFavourite[]>>(`${base}/favourites`)
.then(unwrap),
addFavourite: (agentId, note) =>
client
.POST<Envelope<SocialFavourite>>(`${base}/favourites`, {
body: { agent_id: agentId, ...(note !== undefined ? { note } : {}) },
})
.then(unwrap),
removeFavourite: async (agentId) => {
await client.DELETE<void>(`${base}/favourites/${agentId}`);
},
// subscriptions
listSubscriptions: () =>
client
.GET<Envelope<SocialSubscription[]>>(`${base}/subscriptions`)
.then(unwrap),
addSubscription: (agentId, opts) =>
client
.POST<Envelope<SocialSubscription>>(`${base}/subscriptions`, {
body: {
agent_id: agentId,
...(opts?.lastSeenDnaHash !== undefined
? { last_seen_dna_hash: opts.lastSeenDnaHash }
: {}),
},
})
.then(unwrap),
removeSubscription: async (agentId) => {
await client.DELETE<void>(`${base}/subscriptions/${agentId}`);
},
// notifications
listNotifications: (opts) =>
client.GET<SocialNotificationList>(`${base}/notifications`, {
params: {
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
...(opts?.unreadOnly ? { unread_only: true } : {}),
},
}),
markNotificationRead: async (id) => {
await client.POST<void>(`${base}/notifications/${id}/read`);
},
markAllNotificationsRead: async () => {
await client.POST<void>(`${base}/notifications/read-all`);
},
};
}