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
|
||||
// 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";
|
||||
|
||||
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