Files
gerbeur/src/model.ts
khannurien 6c4f410d9b
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
v3: added a chatbox
2026-06-29 20:25:10 +00:00

691 lines
14 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;
categoryIds?: 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);
}
/**
* Categories
*/
export interface Category {
id: string;
slug: string;
name: string;
position: number;
createdAt: Date;
updatedAt?: Date;
}
export type RawCategory = WithStringDate<Category>;
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<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,
};
}
/**
* Chat
*/
export interface ChatMessage {
id: string;
userId: string;
body: string;
createdAt: Date;
updatedAt?: Date;
authorUsername: string;
authorAvatarMime?: string;
}
export type RawChatMessage = WithStringDate<ChatMessage>;
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<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" | "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<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;
// 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;
}