All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
581 lines
12 KiB
TypeScript
581 lines
12 KiB
TypeScript
export interface PaginatedData<T> {
|
|
items: T[];
|
|
total: number;
|
|
hasMore: boolean;
|
|
}
|
|
|
|
/**
|
|
* API response envelope — every endpoint returns this shape.
|
|
*/
|
|
export type APIResponse<T> =
|
|
| { success: true; data: T }
|
|
| { success: false; error: { message: string } };
|
|
|
|
/**
|
|
* Parses an unknown JSON payload into a typed APIResponse<T>.
|
|
* 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<T>(raw: unknown): APIResponse<T> {
|
|
if (raw !== null && typeof raw === "object" && "success" in raw) {
|
|
return raw as APIResponse<T>;
|
|
}
|
|
throw new Error("Unexpected response format");
|
|
}
|
|
|
|
/**
|
|
* Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage.
|
|
* WithStringDate<T> replaces Date fields with string so we can type raw API responses.
|
|
*/
|
|
type WithStringDate<T extends { createdAt: Date }> =
|
|
& Omit<T, "createdAt" | "updatedAt">
|
|
& { 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;
|
|
}
|
|
|
|
export type RawDump = WithStringDate<Dump>;
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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<PublicUser>;
|
|
// 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<InviteTreeEntry, "createdAt"> & {
|
|
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<AuthResponse, "user"> & {
|
|
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<Comment>;
|
|
|
|
export function deserializeComment(raw: RawComment): Comment {
|
|
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<Playlist>;
|
|
export type RawPlaylistWithDumps =
|
|
& Omit<WithStringDate<PlaylistWithDumps>, "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";
|
|
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<Notification>;
|
|
|
|
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;
|
|
}
|
|
|
|
export interface WSForceLogoutMessage {
|
|
type: "force_logout";
|
|
}
|
|
|
|
export type IncomingWSMessage =
|
|
| WSPingMessage
|
|
| WSWelcomeMessage
|
|
| WSPresenceUpdateMessage
|
|
| WSVotesUpdateMessage
|
|
| WSVoteAckMessage
|
|
| WSCommentLikesUpdateMessage
|
|
| WSCommentLikeAckMessage
|
|
| WSDumpCreatedMessage
|
|
| WSDumpUpdatedMessage
|
|
| WSDumpDeletedMessage
|
|
| WSPlaylistCreatedMessage
|
|
| WSPlaylistUpdatedMessage
|
|
| WSPlaylistDeletedMessage
|
|
| WSPlaylistDumpsUpdatedMessage
|
|
| WSUserUpdatedMessage
|
|
| WSCommentCreatedMessage
|
|
| WSCommentUpdatedMessage
|
|
| WSCommentDeletedMessage
|
|
| WSNotificationCreatedMessage
|
|
| WSErrorMessage
|
|
| WSForceLogoutMessage;
|
|
|
|
/**
|
|
* 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 type OutgoingWSMessage =
|
|
| WSPongMessage
|
|
| WSVoteCastMessage
|
|
| WSVoteRemoveMessage
|
|
| WSCommentLikeCastMessage
|
|
| WSCommentLikeRemoveMessage;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export interface UpdateDumpRequest {
|
|
url?: string;
|
|
title?: string;
|
|
comment?: string;
|
|
isPrivate?: boolean;
|
|
}
|
|
|
|
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;
|
|
}
|