export interface PaginatedData { items: T[]; total: number; hasMore: boolean; } /** * 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; thumbnailUrl?: 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; 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; 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; } 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; } 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; dumpIds: string[]; } 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"; } export interface WSForceLogoutMessage { type: "force_logout"; } export interface WSChatMessageMessage { type: "chat_message"; message: RawChatMessage; } export interface WSChatMessageUpdatedMessage { type: "chat_message_updated"; message: RawChatMessage; } export interface WSChatMessageDeletedMessage { type: "chat_message_deleted"; id: string; } 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; /** * 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; } 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 type OutgoingWSMessage = | WSPongMessage | WSVoteCastMessage | WSVoteRemoveMessage | WSCommentLikeCastMessage | WSCommentLikeRemoveMessage | WSChatSendMessage | WSChatFocusMessage | WSChatEditMessage | WSChatDeleteMessage; /** * 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; comment?: string; isPrivate?: boolean; categoryIds?: string[]; } export interface UpdateDumpRequest { url?: string; title?: string; comment?: string; 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; }