670 lines
15 KiB
TypeScript
670 lines
15 KiB
TypeScript
/**
|
||
* 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 {
|
||
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"
|
||
) return "Invalid request";
|
||
const { username, password } = obj as RegisterUserRequest;
|
||
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<string, unknown>;
|
||
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<string, unknown>).purpose === "invite" &&
|
||
"inviterId" in obj &&
|
||
typeof (obj as Record<string, unknown>).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<T> {
|
||
success: true;
|
||
data: T;
|
||
error?: never;
|
||
}
|
||
|
||
export interface APIFailure {
|
||
success: false;
|
||
data?: never;
|
||
error: APIError;
|
||
}
|
||
|
||
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
||
|
||
export interface PaginatedData<T> {
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<User, "passwordHash">;
|
||
}
|
||
|
||
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<Notification, "createdAt"> & {
|
||
createdAt: string;
|
||
};
|