Files
spsays-training/lib/social.ts
T
2026-06-04 19:30:50 +05:30

497 lines
16 KiB
TypeScript

/**
* Browser-side BFF client for the social features: posts, bookmarks,
* collections, public profiles.
*
* All requests run with `credentials: "include"` so the auth cookie set
* by /auth/login is sent along automatically. Errors come back as
* `SocialError` instances carrying the HTTP status code and the message
* from the BFF envelope.
*
* Endpoint paths mirror the BFF routes 1:1 — there's no Next.js proxy
* for these in the current setup (BFF is on the same registrable domain
* in prod, and localhost is same-site in dev).
*/
const BFF_BASE =
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class SocialError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "SocialError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
meta?: { page: number; limit: number; total: number; total_pages: number };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new SocialError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
export type PostImage = { id: string; url: string; position: number };
export type Post = {
id: string;
user_id: string;
caption: string;
source_url: string;
source_title: string;
images: PostImage[];
created_at: string;
updated_at: string;
author_name?: string;
author_handle?: string;
author_avatar_url?: string | null;
/** True when the calling viewer has reshared the canonical version
* of this post. Populated by endpoints that go through OptionalAuth
* (single post, profile feed, collection contents). Always false /
* absent for anonymous viewers. Drives the post-card "Reshare ↔ Send"
* swap. */
viewer_has_reshared?: boolean;
/** Non-null only when this row is itself a reshare — points at the
* canonical original. The four `original_author_*` fields carry the
* canonical author's profile info for rendering the
* "Reshared from @handle" link in the card. Authored canonical posts
* leave all of these null/absent. */
reshared_from_post_id?: string | null;
original_author_name?: string | null;
original_author_handle?: string | null;
original_author_avatar_url?: string | null;
};
export type Bookmark = {
id: string;
user_id: string;
source_url: string;
source_title: string;
excerpt: string | null;
note: string | null;
created_at: string;
};
export type Collection = {
id: string;
user_id: string;
title: string;
description: string | null;
cover_url: string | null;
is_public: boolean;
item_count: number;
created_at: string;
updated_at: string;
};
/** The viewer's relationship to a profile, all "viewer → target". Null
* when the viewer is anonymous or looking at their own profile. */
export type RelationshipState = {
/** viewer → target follow is accepted (you follow them). */
following: boolean;
/** viewer → target follow is pending (you've requested). */
follow_requested: boolean;
/** target → viewer follow is accepted (they follow you). */
follows_you: boolean;
/** target → viewer follow is pending (they've asked to follow you). */
requests_you: boolean;
/** you have blocked them. */
blocked: boolean;
/** they have blocked you. */
blocked_by: boolean;
};
/** Returned by every relationship mutation: the recomputed state plus
* the target's live follower/following counts. */
export type RelationshipResult = {
relationship: RelationshipState;
follower_count: number;
following_count: number;
};
export type PublicProfile = {
handle: string;
name: string;
avatar_url: string | null;
joined_at: string;
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
country: string | null;
post_count: number;
follower_count: number;
following_count: number;
relationship: RelationshipState | null;
public_collections: Collection[];
};
export type Feedback = {
id: string;
user_id?: string;
category: string;
message: string;
contact_email?: string;
created_at: string;
};
// ─── Posts ───────────────────────────────────────────────────────────────
export const posts = {
create(input: {
caption: string;
source_url: string;
source_title?: string;
image_urls?: string[];
}) {
return call<Post>("/posts", {
method: "POST",
body: JSON.stringify({
caption: input.caption,
source_url: input.source_url,
source_title: input.source_title ?? "",
image_urls: input.image_urls ?? [],
}),
});
},
get(id: string) {
return call<Post>(`/posts/${id}`);
},
delete(id: string) {
return call<void>(`/posts/${id}`, { method: "DELETE" });
},
/** Swap the post's image set. Empty array removes all images. */
replaceImages(id: string, image_urls: string[]) {
return call<Post>(`/posts/${id}/images`, {
method: "PUT",
body: JSON.stringify({ image_urls }),
});
},
mine() {
return call<Post[]>("/posts/mine");
},
/** Record a reshare. Idempotent — calling twice is a no-op server
* side. Resharing your own post is rejected with 400 (not currently
* surfaced as a distinct error type because the UI only ever offers
* Reshare on non-owned posts). */
/** Materialise a reshare. Returns the new post (or the existing
* reshare row if the caller already reshared this canonical), so
* callers can drop it straight into local feed state. */
reshare(id: string) {
return call<Post>(`/posts/${id}/reshare`, { method: "POST" });
},
/** Remove a previously recorded reshare. Idempotent. */
unreshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
},
/** Report a post. category from PostReportCategory; reason optional.
* Auth-required (401 for anonymous callers). Returns nothing (204). */
report(id: string, category: PostReportCategory, reason: string) {
return call<void>(`/posts/${id}/report`, {
method: "POST",
body: JSON.stringify({ category, reason }),
});
},
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
async uploadImage(file: File): Promise<string> {
const { upload_url, public_url } = await call<{
upload_url: string;
public_url: string;
}>("/posts/image-presign", {
method: "POST",
body: JSON.stringify({ content_type: file.type }),
});
const put = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new SocialError(put.status, "image upload failed");
return public_url;
},
};
// ─── Bookmarks ───────────────────────────────────────────────────────────
export const bookmarks = {
create(input: {
source_url: string;
source_title?: string;
excerpt?: string;
note?: string;
}) {
return call<Bookmark>("/bookmarks", {
method: "POST",
body: JSON.stringify({
source_url: input.source_url,
source_title: input.source_title ?? "",
excerpt: input.excerpt ?? null,
note: input.note ?? null,
}),
});
},
list() {
return call<Bookmark[]>("/bookmarks");
},
delete(id: string) {
return call<void>(`/bookmarks/${id}`, { method: "DELETE" });
},
};
// ─── Collections ─────────────────────────────────────────────────────────
export const collections = {
create(input: {
title: string;
description?: string;
cover_url?: string;
is_public?: boolean;
}) {
return call<Collection>("/collections", {
method: "POST",
body: JSON.stringify({
title: input.title,
description: input.description ?? null,
cover_url: input.cover_url ?? null,
is_public: !!input.is_public,
}),
});
},
update(
id: string,
input: Partial<{
title: string;
description: string;
cover_url: string;
is_public: boolean;
}>,
) {
return call<Collection>(`/collections/${id}`, {
method: "PATCH",
body: JSON.stringify({
title: input.title ?? undefined,
description: input.description ?? undefined,
cover_url: input.cover_url === undefined ? undefined : input.cover_url,
is_public: input.is_public ?? undefined,
}),
});
},
delete(id: string) {
return call<void>(`/collections/${id}`, { method: "DELETE" });
},
mine() {
return call<Collection[]>("/collections");
},
/** Public-or-private depending on visibility. Anonymous viewers get 403 on private collections. */
get(id: string) {
return call<{ collection: Collection; posts: Post[] }>(`/collections/${id}`);
},
addItem(collectionID: string, postID: string) {
return call<{ ok: boolean }>(`/collections/${collectionID}/items`, {
method: "POST",
body: JSON.stringify({ post_id: postID }),
});
},
removeItem(collectionID: string, postID: string) {
return call<void>(`/collections/${collectionID}/items/${postID}`, {
method: "DELETE",
});
},
/** Returns the ids of the caller's collections that already contain
* the given post. The save-to-collection drawer uses this to disable
* rows so the post can't be added a second time. */
async containingPost(postID: string): Promise<string[]> {
const r = await call<{ collection_ids: string[] }>(
`/collections/containing/${postID}`,
);
return r.collection_ids ?? [];
},
/** Rewrite the position of every post in `post_ids` to match the array
* order. The BFF runs the updates in a single tx — readers see either
* the old order or the new one, never a half-applied state. */
reorderItems(collectionID: string, post_ids: string[]) {
return call<void>(`/collections/${collectionID}/items/order`, {
method: "PUT",
body: JSON.stringify({ post_ids }),
});
},
/** R2 presigned PUT for the collection cover. */
async uploadCover(file: File): Promise<string> {
const { upload_url, public_url } = await call<{
upload_url: string;
public_url: string;
}>("/collections/cover-presign", {
method: "POST",
body: JSON.stringify({ content_type: file.type }),
});
const put = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new SocialError(put.status, "cover upload failed");
return public_url;
},
};
// ─── Feedback ────────────────────────────────────────────────────────────
export const feedback = {
/** Submit a /feedback page entry. When the viewer is signed in the
* BFF captures their user_id automatically via the auth cookie —
* the form doesn't ask for it. `contact_email` is required only
* for anonymous submissions; the BFF re-enforces that on its end. */
submit(input: {
category: "bug" | "feature" | "general" | "other";
message: string;
contact_email?: string;
}) {
return call<Feedback>("/feedback", {
method: "POST",
body: JSON.stringify({
category: input.category,
message: input.message,
contact_email: input.contact_email || undefined,
}),
});
},
};
// ─── Public profile ──────────────────────────────────────────────────────
export const profile = {
get(handle: string) {
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
},
feed(handle: string, limit = 20, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
},
};
// ─── Relationships (follow / block / report) ───────────────────────────────
/** Allowed user-report categories — mirrors the BFF allow-list. */
export type ReportCategory =
| "spam"
| "harassment"
| "impersonation"
| "inappropriate"
| "other";
/** Allowed post-report categories — mirrors the BFF allow-list. */
export type PostReportCategory =
| "spam"
| "harassment"
| "misinformation"
| "inappropriate"
| "other";
// ─── Notifications ─────────────────────────────────────────────────────────
/** One in-app notification for the signed-in user. `read_at` is absent
* (undefined) while unread. Actor fields describe who triggered it. */
export type AppNotification = {
id: string;
kind: string;
created_at: string;
read_at?: string | null;
actor_id?: string | null;
actor_name?: string | null;
actor_handle?: string | null;
actor_avatar_url?: string | null;
};
export const notifications = {
/** The caller's notifications, newest first. */
list(limit = 30, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<AppNotification[]>(`/notifications?${qs}`);
},
/** Unread count for the header bell badge. */
unreadCount() {
return call<{ count: number }>("/notifications/unread-count");
},
/** Mark every notification read (called when the page opens). */
markAllRead() {
return call<void>("/notifications/read", { method: "POST" });
},
};
export const relationships = {
/** Send a follow REQUEST. The target must accept before it counts as a
* follow — the returned relationship reflects `follow_requested: true`
* until then. Requires the viewer to be signed in (401 otherwise). */
follow(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/follow`, {
method: "POST",
});
},
/** Unfollow, or cancel a still-pending request. Idempotent. */
unfollow(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/follow`, {
method: "DELETE",
});
},
/** Accept the target's pending request to follow you. */
accept(handle: string) {
return call<RelationshipResult>(
`/u/${encodeURIComponent(handle)}/follow/accept`,
{ method: "POST" },
);
},
/** Reject the target's pending request to follow you. */
reject(handle: string) {
return call<RelationshipResult>(
`/u/${encodeURIComponent(handle)}/follow/reject`,
{ method: "POST" },
);
},
/** Remove the target from your followers (they currently follow you). */
removeFollower(handle: string) {
return call<RelationshipResult>(
`/u/${encodeURIComponent(handle)}/follower`,
{ method: "DELETE" },
);
},
/** Block the target. Severs follows both ways. */
block(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/block`, {
method: "POST",
});
},
/** Remove your block on the target. */
unblock(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/block`, {
method: "DELETE",
});
},
/** File a report against the target. Returns nothing (204). */
report(handle: string, category: ReportCategory, reason: string) {
return call<void>(`/u/${encodeURIComponent(handle)}/report`, {
method: "POST",
body: JSON.stringify({ category, reason }),
});
},
};