import { VALIDATION } from "../config.ts"; /** * Backend */ 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; email: string; } export interface LoginUserRequest { username: string; password: string; } export interface RegisterUserRequest { username: string; password: string; inviteToken: string; email: string; } export interface UpdateUserRequest { username?: string; password?: string; isAdmin?: boolean; description?: string | null; email?: string; } 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 { return validateRegisterUserRequest(obj) === null; } /** Returns a human-readable error string, or null if the request is valid. */ export function validateRegisterUserRequest(obj: unknown): string | null { 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" || !("email" in obj) || typeof obj.email !== "string" ) return "Invalid request"; const { username, password, email } = obj as RegisterUserRequest; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return "Invalid email address"; } if ( !new RegExp( `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, ) .test(username) ) { return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`; } if (password.length < VALIDATION.PASSWORD_MIN) { return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`; } if (password.length > VALIDATION.PASSWORD_MAX) { return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`; } return null; } 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 ("email" in o) { if (typeof o.email !== "string") return false; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) 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 | null; 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 */ // ── Client → Server ────────────────────────────────────────────────────────── export interface PingMessage { type: "ping"; } export interface PongMessage { type: "pong"; } export interface VoteCastMessage { type: "vote_cast"; dumpId: string; } export interface VoteRemoveMessage { type: "vote_remove"; dumpId: string; } export type ClientToServerMessage = | PingMessage | PongMessage | VoteCastMessage | VoteRemoveMessage; // ── Server → Client ────────────────────────────────────────────────────────── export interface OnlineUser { userId: string; username: string; hasAvatar: boolean; avatarVersion?: number; } export interface WelcomeMessage { type: "welcome"; users: OnlineUser[]; myVotes: string[]; unreadNotificationCount: number; } export interface PresenceUpdateMessage { type: "presence_update"; users: OnlineUser[]; } export interface VotesUpdateMessage { type: "votes_update"; dumpId: string; voteCount: number; voterId: string; action: "cast" | "remove"; } export interface VoteAckMessage { type: "vote_ack"; dumpId: string; action: "cast" | "remove"; voteCount: number; } export interface DumpCreatedMessage { type: "dump_created"; dump: Dump; } export interface DumpUpdatedMessage { type: "dump_updated"; dump: Dump; } export interface DumpDeletedMessage { type: "dump_deleted"; dumpId: string; } export interface PlaylistCreatedMessage { type: "playlist_created"; playlist: Playlist; } export interface PlaylistUpdatedMessage { type: "playlist_updated"; playlist: Playlist; } export interface PlaylistDeletedMessage { type: "playlist_deleted"; playlistId: string; userId: string; } export interface PlaylistDumpsUpdatedMessage { type: "playlist_dumps_updated"; playlistId: string; dumpIds: string[]; } export interface UserUpdatedMessage { type: "user_updated"; user: Omit; } export interface CommentCreatedMessage { type: "comment_created"; comment: Comment; } export interface CommentUpdatedMessage { type: "comment_updated"; comment: Comment; } export interface CommentDeletedMessage { type: "comment_deleted"; commentId: string; dumpId: string; } export interface NotificationCreatedMessage { type: "notification_created"; notification: RawNotification; } export interface ErrorMessage { type: "error"; message?: string; } export interface ForceLogoutMessage { type: "force_logout"; } export type ServerToClientMessage = | PingMessage | WelcomeMessage | PresenceUpdateMessage | VotesUpdateMessage | VoteAckMessage | DumpCreatedMessage | DumpUpdatedMessage | DumpDeletedMessage | PlaylistCreatedMessage | PlaylistUpdatedMessage | PlaylistDeletedMessage | PlaylistDumpsUpdatedMessage | UserUpdatedMessage | CommentCreatedMessage | CommentUpdatedMessage | CommentDeletedMessage | NotificationCreatedMessage | ErrorMessage | ForceLogoutMessage; /** * 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; } /** Wire format — createdAt arrives as an ISO string over JSON. */ export type RawNotification = Omit & { createdAt: string; };