/** * Backend */ // ── Validation constants (shared with frontend via src/config/api.ts) ────────── export const VALIDATION = { USERNAME_MIN: 1, USERNAME_MAX: 32, PASSWORD_MIN: 8, PASSWORD_MAX: 128, DUMP_TITLE_MAX: 200, DUMP_COMMENT_MAX: 5000, PLAYLIST_TITLE_MAX: 100, PLAYLIST_DESCRIPTION_MAX: 2000, COMMENT_BODY_MAX: 5000, USER_DESCRIPTION_MAX: 2000, } as const; 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; } /** * Authentication */ export interface User { id: string; username: string; passwordHash: string; isAdmin: boolean; createdAt: Date; updatedAt?: Date; avatarMime?: string; description?: string; invitedByUsername?: string; } export interface LoginUserRequest { username: string; password: string; } export interface RegisterUserRequest { username: string; password: string; inviteToken: string; } export interface UpdateUserRequest { username?: string; password?: string; isAdmin?: boolean; description?: string | null; } export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { return !!obj && typeof obj === "object" && "username" in obj && typeof obj.username === "string" && "password" in obj && typeof obj.password === "string"; } export function isRegisterUserRequest( obj: unknown, ): obj is RegisterUserRequest { if ( !obj || typeof obj !== "object" || !("username" in obj) || typeof obj.username !== "string" || !("password" in obj) || typeof obj.password !== "string" || !("inviteToken" in obj) || typeof obj.inviteToken !== "string" ) return false; const { username, password } = obj as RegisterUserRequest; return /^[a-zA-Z0-9_]{1,32}$/.test(username) && password.length >= VALIDATION.PASSWORD_MIN && password.length <= VALIDATION.PASSWORD_MAX; } export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; if ("username" in o) { if (typeof o.username !== "string") return false; if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false; } if ("password" in o) { if (typeof o.password !== "string") return false; const len = (o.password as string).length; if (len < VALIDATION.PASSWORD_MIN || len > VALIDATION.PASSWORD_MAX) { return false; } } if ("isAdmin" in o && typeof o.isAdmin !== "boolean") return false; if ( "description" in o && typeof o.description !== "string" && o.description !== null ) return false; if ( typeof o.description === "string" && (o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX ) return false; return true; } export interface AuthResponse { token: string; user: User; } export interface AuthPayload { userId: string; username: string; isAdmin: boolean; exp: number; } export function isAuthPayload(obj: unknown): obj is AuthPayload { return !!obj && typeof obj === "object" && "userId" in obj && typeof obj.userId === "string" && "username" in obj && typeof obj.username === "string" && "isAdmin" in obj && typeof obj.isAdmin === "boolean" && "exp" in obj && typeof obj.exp === "number"; } export interface InvitePayload { purpose: "invite"; inviterId: string; exp: number; } export function isInvitePayload(obj: unknown): obj is InvitePayload { return !!obj && typeof obj === "object" && "purpose" in obj && (obj as Record).purpose === "invite" && "inviterId" in obj && typeof (obj as Record).inviterId === "string"; } /** * API */ export enum APIErrorCode { BAD_REQUEST = "BAD_REQUEST", NOT_FOUND = "NOT_FOUND", SERVER_ERROR = "SERVER_ERROR", TIMEOUT = "TIMEOUT", UNAUTHORIZED = "UNAUTHORIZED", VALIDATION_ERROR = "VALIDATION_ERROR", } export interface APIError { code: APIErrorCode; message: string; } export interface APISuccess { success: true; data: T; error?: never; } export interface APIFailure { success: false; data?: never; error: APIError; } export type APIResponse = APISuccess | APIFailure; export interface PaginatedData { items: T[]; total: number; hasMore: boolean; } export class APIException extends Error { readonly code: APIErrorCode; readonly status: number; constructor(code: APIErrorCode, status: number, message: string) { super(message); this.code = code; this.status = status; } } /** * Comments */ export interface Comment { id: string; dumpId: string; userId: string; parentId?: string; body: string; createdAt: Date; updatedAt?: Date; deleted: boolean; authorUsername: string; authorAvatarMime?: string; } export interface CreateCommentRequest { body: string; parentId?: string; } export function isCreateCommentRequest( obj: unknown, ): obj is CreateCommentRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; return typeof o.body === "string" && (o.body as string).trim().length > 0 && (o.body as string).length <= VALIDATION.COMMENT_BODY_MAX && (!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null); } export interface UpdateCommentRequest { body: string; } export function isUpdateCommentRequest( obj: unknown, ): obj is UpdateCommentRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; return typeof o.body === "string" && (o.body as string).trim().length > 0 && (o.body as string).length <= VALIDATION.COMMENT_BODY_MAX; } /** * 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 interface CreatePlaylistRequest { title: string; description?: string; isPublic: boolean; } export interface UpdatePlaylistRequest { title?: string; description?: string; isPublic?: boolean; } export interface ReorderPlaylistRequest { dumpIds: string[]; } export function isCreatePlaylistRequest( obj: unknown, ): obj is CreatePlaylistRequest { if ( !obj || typeof obj !== "object" || !("title" in obj) || typeof obj.title !== "string" || !("isPublic" in obj) || typeof obj.isPublic !== "boolean" ) return false; const o = obj as Record; if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false; if ( "description" in o && typeof o.description !== "string" && o.description !== null ) return false; if ( typeof o.description === "string" && (o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX ) return false; return true; } export function isUpdatePlaylistRequest( obj: unknown, ): obj is UpdatePlaylistRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; if ("title" in o) { if (typeof o.title !== "string") return false; if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false; } if ( "description" in o && typeof o.description !== "string" && o.description !== null ) return false; if ( typeof o.description === "string" && (o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX ) return false; if ("isPublic" in o && typeof o.isPublic !== "boolean") return false; return true; } export function isReorderPlaylistRequest( obj: unknown, ): obj is ReorderPlaylistRequest { return !!obj && typeof obj === "object" && "dumpIds" in obj && Array.isArray(obj.dumpIds) && (obj.dumpIds as unknown[]).every((id) => typeof id === "string"); } /** * Request DTOs */ export interface CreateUrlDumpRequest { url: string; comment?: string; isPrivate?: boolean; } export function isCreateUrlDumpRequest( obj: unknown, ): obj is CreateUrlDumpRequest { if ( !obj || typeof obj !== "object" || !("url" in obj) || typeof obj.url !== "string" ) return false; const o = obj as Record; if ( "comment" in o && typeof o.comment !== "string" && o.comment !== null ) return false; if ( typeof o.comment === "string" && (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX ) return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; return true; } export interface UpdateDumpRequest { url?: string; comment?: string; isPrivate?: boolean; } export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; if ("url" in o && typeof o.url !== "string" && o.url !== null) return false; if ( "comment" in o && typeof o.comment !== "string" && o.comment !== null ) return false; if ( typeof o.comment === "string" && (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX ) return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; return true; } /** * WebSockets */ export interface VoteCastMessage { type: "vote_cast"; dumpId: string; userId: string; } export interface VoteAckMessageFailure { type: "vote_ack"; dumpId: string; success: false; error: APIError; } export interface VoteAckMessageSuccess { type: "vote_ack"; dumpId: string; action: "cast" | "remove"; success: true; voteCount: number; error?: never; } export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure; export interface VoteRemoveMessage { type: "vote_remove"; dumpId: string; } export interface VotesUpdateMessage { type: "votes_update"; dumpId: string; voteCount: number; } export interface OnlineUser { userId: string; username: string; hasAvatar: boolean; avatarVersion?: number; } export interface WelcomeMessage { type: "welcome"; users: OnlineUser[]; myVotes: string[]; } export interface PresenceUpdateMessage { type: "presence_update"; users: OnlineUser[]; } export interface PingMessage { type: "ping"; } export interface PongMessage { type: "pong"; } /** * Follows */ export interface FollowStatus { followedUserIds: string[]; followedPlaylistIds: string[]; } /** * Notifications */ export type NotificationType = | "playlist_followed" | "user_followed" | "user_dump_posted" | "playlist_dump_added" | "dump_upvoted" | "user_mentioned"; 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"; contextId: string; contextTitle: string; dumpId?: string; } export type NotificationData = | PlaylistFollowedData | UserFollowedData | UserDumpPostedData | PlaylistDumpAddedData | DumpUpvotedData | UserMentionedData; export interface Notification { id: string; userId: string; type: NotificationType; data: NotificationData; read: boolean; createdAt: Date; }