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:
@@ -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
319
src/social.ts
Normal 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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user