export interface PaginatedData { items: T[]; hasMore: boolean; /** The whole list's size — only where it's shown (search results). */ total?: number; } /** * API response envelope — every endpoint returns this shape. */ export type APIResponse = | { success: true; data: T } | { success: false; error: { message: string } }; /** * Parses an unknown JSON payload into a typed APIResponse. * Performs a minimal runtime check on the `success` discriminant so the * single internal `as` cast is safe; throws if the shape is unexpected. */ export function parseAPIResponse(raw: unknown): APIResponse { if (raw !== null && typeof raw === "object" && "success" in raw) { return raw as APIResponse; } throw new Error("Unexpected response format"); } /** * Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage. * WithStringDate replaces Date fields with string so we can type raw API responses. */ type WithStringDate = & Omit & { createdAt: string; updatedAt?: string }; /** * Dumps */ export interface RichContent { type: string; url: string; siteName?: string; title?: string; description?: string; /** A real preview image — never a favicon. Absent means "no artwork". */ thumbnailUrl?: string; /** The page's own icon, used as the placeholder's glyph. */ faviconUrl?: string; /** The page's declared brand color, normalized to `#rrggbb`. */ accentColor?: string; videoId?: string; embedUrl?: string; } export interface Dump { id: string; kind: "url" | "file"; title: string; slug?: string; comment?: string; userId: string; createdAt: Date; updatedAt?: Date; url?: string; richContent?: RichContent; fileName?: string; fileMime?: string; fileSize?: number; voteCount: number; commentCount: number; isPrivate: boolean; thumbnailMime?: string; categoryIds?: string[]; } export type RawDump = WithStringDate; /** * `GET /api/preview`. `reached` is false when the page could not be fetched, in * which case `richContent` is a hostname-only stub rather than real metadata. */ export interface UrlPreviewResponse { reached: boolean; richContent: RichContent | null; } /** An existing dump on the same URL, as returned by `GET /api/dumps/by-url`. */ export interface DumpUrlMatch { id: string; slug?: string; title: string; username: string; createdAt: Date; voteCount: number; commentCount: number; } export type RawDumpUrlMatch = WithStringDate; export function deserializeDumpUrlMatch(raw: RawDumpUrlMatch): DumpUrlMatch { return { ...raw, createdAt: new Date(raw.createdAt) }; } export function deserializeDump(raw: RawDump): Dump { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, }; } export function hydrateDump(raw: unknown): Dump { return deserializeDump(raw as RawDump); } /** * Categories */ export interface Category { id: string; slug: string; name: string; position: number; createdAt: Date; updatedAt?: Date; } export type RawCategory = WithStringDate; export function deserializeCategory(raw: RawCategory): Category { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, }; } export interface CreateCategoryRequest { slug: string; name: string; position?: number; } export interface UpdateCategoryRequest { slug?: string; name?: string; position?: number; } /** * Users */ export type Role = "user" | "moderator" | "admin"; export interface PublicUser { id: string; username: string; role: Role; createdAt: Date; updatedAt?: Date; avatarMime?: string; description?: string; invitedByUsername?: string; email?: string; } // User is the same shape as PublicUser in the frontend; they differ only // semantically (authenticated self vs. any public profile). export type User = PublicUser; export type RawPublicUser = WithStringDate; // Alias so imports of RawUser continue to work. export type RawUser = RawPublicUser; export function deserializePublicUser(raw: RawPublicUser): PublicUser { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, }; } // Alias so call sites using deserializeUser continue to work. export const deserializeUser = deserializePublicUser; export interface InviteTreeEntry { id: string; username: string; avatarMime?: string; invitedById: string; createdAt: Date; } export type RawInviteTreeEntry = Omit & { createdAt: string; }; export function deserializeInviteTreeEntry( raw: RawInviteTreeEntry, ): InviteTreeEntry { return { ...raw, createdAt: new Date(raw.createdAt) }; } /** * Authentication */ export interface AuthResponse { token: string; /** Read-only token for media URLs (`?token=`); absent on older sessions. */ mediaToken?: string; user: User; } export type RawAuthResponse = Omit & { user: RawPublicUser; }; export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse { return { ...raw, user: deserializePublicUser(raw.user) }; } /** * Comments */ export interface Comment { id: string; dumpId: string; userId: string; parentId?: string; body: string; createdAt: Date; updatedAt?: Date; deleted: boolean; likeCount: number; authorUsername: string; authorAvatarMime?: string; } export type RawComment = WithStringDate; export function deserializeComment(raw: RawComment): Comment { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, }; } /** * Chat */ export interface ChatMessage { id: string; userId: string; body: string; createdAt: Date; updatedAt?: Date; authorUsername: string; authorAvatarMime?: string; /** Id of the message this one replies to, if any. Kept even when the target * was deleted (the preview fields below then go undefined). */ replyToId?: string; /** Author/snippet of the replied-to message, for a subtle inline reference. * Absent when there's no reply, or the target has since been deleted. */ replyToAuthor?: string; replyToBody?: string; } export type RawChatMessage = WithStringDate; export function deserializeChatMessage(raw: RawChatMessage): ChatMessage { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, }; } /** * Playlists */ export interface Playlist { id: string; userId: string; title: string; slug?: string; description?: string; isPublic: boolean; createdAt: Date; updatedAt?: Date; imageMime?: string; dumpCount?: number; ownerUsername?: string; } export interface PlaylistWithDumps extends Playlist { dumps: Dump[]; } export interface PlaylistMembership { playlist: Playlist; hasDump: boolean; } export type RawPlaylist = WithStringDate; export type RawPlaylistWithDumps = & Omit, "dumps"> & { dumps: RawDump[] }; export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean }; export function deserializePlaylist(raw: RawPlaylist): Playlist { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, }; } export function deserializePlaylistWithDumps( raw: RawPlaylistWithDumps, ): PlaylistWithDumps { return { ...raw, createdAt: new Date(raw.createdAt), updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, dumps: raw.dumps.map(deserializeDump), }; } export function deserializePlaylistMembership( raw: RawPlaylistMembership, ): PlaylistMembership { return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump }; } export function hydratePlaylist(raw: unknown): Playlist { return deserializePlaylist(raw as RawPlaylist); } /** * Notifications */ export type NotificationType = | "playlist_followed" | "user_followed" | "user_dump_posted" | "playlist_dump_added" | "dump_upvoted" | "user_mentioned" | "dump_commented" | "comment_liked"; export interface PlaylistFollowedData { followerId: string; followerUsername: string; playlistId: string; playlistTitle: string; } export interface UserFollowedData { followerId: string; followerUsername: string; } export interface UserDumpPostedData { dumperId: string; dumperUsername: string; dumpId: string; dumpTitle: string; } export interface PlaylistDumpAddedData { dumpId: string; dumpTitle: string; playlistId: string; playlistTitle: string; } export interface DumpUpvotedData { voterId: string; voterUsername: string; dumpId: string; dumpTitle: string; } export interface UserMentionedData { mentionerId: string; mentionerUsername: string; contextType: "comment" | "dump" | "playlist" | "chat"; contextId: string; contextTitle: string; dumpId?: string; } export interface DumpCommentedData { commenterId: string; commenterUsername: string; commentId: string; dumpId: string; dumpTitle: string; } export interface CommentLikedData { likerId: string; likerUsername: string; commentId: string; dumpId: string; dumpTitle: string; } export type NotificationData = | PlaylistFollowedData | UserFollowedData | UserDumpPostedData | PlaylistDumpAddedData | DumpUpvotedData | UserMentionedData | DumpCommentedData | CommentLikedData; export interface Notification { id: string; userId: string; type: NotificationType; data: NotificationData; read: boolean; createdAt: Date; } export type RawNotification = WithStringDate; export function deserializeNotification(raw: RawNotification): Notification { return { ...raw, createdAt: new Date(raw.createdAt) }; } /** * WebSockets — online presence */ export interface OnlineUser { userId: string; username: string; hasAvatar: boolean; avatarVersion?: number; } /** * WebSocket messages — server → client (incoming) * * All messages share a `type` discriminant so the full union can be narrowed * with a switch/case in WSProvider without additional type guards. */ export interface WSPingMessage { type: "ping"; } export interface WSWelcomeMessage { type: "welcome"; users: OnlineUser[]; myVotes: string[]; myCommentLikes: string[]; unreadNotificationCount: number; chatReadAt?: string; unreadChatCount: number; } export interface WSPresenceUpdateMessage { type: "presence_update"; users: OnlineUser[]; } export interface WSVotesUpdateMessage { type: "votes_update"; dumpId: string; voteCount: number; voterId: string; action: "cast" | "remove"; } export interface WSVoteAckMessage { type: "vote_ack"; dumpId: string; action: "cast" | "remove"; voteCount: number; } export interface WSCommentLikesUpdateMessage { type: "comment_likes_update"; commentId: string; likeCount: number; likerId: string; action: "cast" | "remove"; } export interface WSCommentLikeAckMessage { type: "comment_like_ack"; commentId: string; action: "cast" | "remove"; likeCount: number; } export interface WSDumpCreatedMessage { type: "dump_created"; dump: RawDump; } export interface WSDumpUpdatedMessage { type: "dump_updated"; dump: RawDump; } export interface WSDumpDeletedMessage { type: "dump_deleted"; dumpId: string; } export interface WSPlaylistCreatedMessage { type: "playlist_created"; playlist: RawPlaylist; } export interface WSPlaylistUpdatedMessage { type: "playlist_updated"; playlist: RawPlaylist; } export interface WSPlaylistDeletedMessage { type: "playlist_deleted"; playlistId: string; userId: string; } export interface WSPlaylistDumpsUpdatedMessage { type: "playlist_dumps_updated"; playlistId: string; /** The members this recipient can see, in order. */ dumpIds: string[]; /** Every member, as Playlist.dumpCount counts them. */ dumpCount: number; } export interface WSUserUpdatedMessage { type: "user_updated"; user: RawPublicUser; } export interface WSCommentCreatedMessage { type: "comment_created"; comment: RawComment; } export interface WSCommentUpdatedMessage { type: "comment_updated"; comment: RawComment; } export interface WSCommentDeletedMessage { type: "comment_deleted"; commentId: string; dumpId: string; } export interface WSNotificationCreatedMessage { type: "notification_created"; notification: RawNotification; } export interface WSErrorMessage { type: "error"; message?: string; // Context for optimistic-action failures so the client can revert the exact // pending vote/like immediately instead of waiting for its ACK timeout. dumpId?: string; commentId?: string; action?: "cast" | "remove"; /** Set when a chat action failed, so it can be surfaced in the chat. */ scope?: "chat"; /** Echo of the failed chat_send's nonce. */ nonce?: string; } export interface WSForceLogoutMessage { type: "force_logout"; } export interface WSChatMessageMessage { type: "chat_message"; message: RawChatMessage; nonce?: string; } export interface WSChatMessageUpdatedMessage { type: "chat_message_updated"; message: RawChatMessage; } export interface WSChatMessageDeletedMessage { type: "chat_message_deleted"; id: string; } export interface WSChatTypingUpdateMessage { type: "chat_typing_update"; users: OnlineUser[]; } export interface WSChatReadUpdateMessage { type: "chat_read_update"; readAt: string; unread: number; } export type IncomingWSMessage = | WSPingMessage | WSWelcomeMessage | WSPresenceUpdateMessage | WSVotesUpdateMessage | WSVoteAckMessage | WSCommentLikesUpdateMessage | WSCommentLikeAckMessage | WSDumpCreatedMessage | WSDumpUpdatedMessage | WSDumpDeletedMessage | WSPlaylistCreatedMessage | WSPlaylistUpdatedMessage | WSPlaylistDeletedMessage | WSPlaylistDumpsUpdatedMessage | WSUserUpdatedMessage | WSCommentCreatedMessage | WSCommentUpdatedMessage | WSCommentDeletedMessage | WSNotificationCreatedMessage | WSErrorMessage | WSForceLogoutMessage | WSChatMessageMessage | WSChatMessageUpdatedMessage | WSChatMessageDeletedMessage | WSChatTypingUpdateMessage | WSChatReadUpdateMessage; /** * WebSocket messages — client → server (outgoing) */ export interface WSPongMessage { type: "pong"; } export interface WSVoteCastMessage { type: "vote_cast"; dumpId: string; } export interface WSVoteRemoveMessage { type: "vote_remove"; dumpId: string; } export interface WSCommentLikeCastMessage { type: "comment_like_cast"; commentId: string; } export interface WSCommentLikeRemoveMessage { type: "comment_like_remove"; commentId: string; } export interface WSChatSendMessage { type: "chat_send"; body: string; replyToId?: string; nonce?: string; } export interface WSChatTypingMessage { type: "chat_typing"; typing: boolean; } export interface WSChatFocusMessage { type: "chat_focus"; open: boolean; } export interface WSChatEditMessage { type: "chat_edit"; id: string; body: string; } export interface WSChatDeleteMessage { type: "chat_delete"; id: string; } export interface WSChatReadMessage { type: "chat_read"; at: string; } export type OutgoingWSMessage = | WSPongMessage | WSVoteCastMessage | WSVoteRemoveMessage | WSCommentLikeCastMessage | WSCommentLikeRemoveMessage | WSChatSendMessage | WSChatTypingMessage | WSChatFocusMessage | WSChatEditMessage | WSChatDeleteMessage | WSChatReadMessage; /** * Follows */ export interface FollowStatus { followedUserIds: string[]; followedPlaylistIds: string[]; } /** * Request DTOs */ export interface LoginRequest { username: string; password: string; } export interface RegisterRequest { username: string; password: string; inviteToken: string; email: string; } export interface CreateUrlDumpRequest { url: string; /** Overrides the title scraped from the page — the poster edited the preview. */ title?: string; comment?: string; isPrivate?: boolean; categoryIds?: string[]; } export interface UpdateDumpRequest { url?: string; title?: string; /** `null` removes the note; leaving it out keeps it. */ comment?: string | null; isPrivate?: boolean; categoryIds?: string[]; } export interface CreateCommentRequest { body: string; parentId?: string; } export interface UpdateCommentRequest { body: string; } export interface CreatePlaylistRequest { title: string; description?: string; isPublic: boolean; } export interface UpdatePlaylistRequest { title?: string; description?: string | null; isPublic?: boolean; } export interface ReorderPlaylistRequest { dumpIds: string[]; } export interface UpdateUserRequest { description?: string; email?: string; }