v3: code quality pass
This commit is contained in:
488
src/model.ts
488
src/model.ts
@@ -5,7 +5,34 @@ export interface PaginatedData<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend
|
||||
* 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 {
|
||||
@@ -38,43 +65,8 @@ export interface Dump {
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
avatarMime?: string;
|
||||
description?: string;
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
// Public user profile (no passwordHash)
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
avatarMime?: string;
|
||||
description?: string;
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
// Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage
|
||||
type WithStringDate<T extends { createdAt: Date }> =
|
||||
& Omit<T, "createdAt" | "updatedAt">
|
||||
& { createdAt: string; updatedAt?: string };
|
||||
export type RawDump = WithStringDate<Dump>;
|
||||
export type RawUser = WithStringDate<User>;
|
||||
export type RawPublicUser = WithStringDate<PublicUser>;
|
||||
export type RawAuthResponse = Omit<AuthResponse, "user"> & { user: RawUser };
|
||||
|
||||
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
|
||||
export function deserializeDump(raw: RawDump): Dump {
|
||||
return {
|
||||
...raw,
|
||||
@@ -87,17 +79,28 @@ export function hydrateDump(raw: unknown): Dump {
|
||||
return deserializeDump(raw as RawDump);
|
||||
}
|
||||
|
||||
export function hydratePlaylist(raw: unknown): Playlist {
|
||||
return deserializePlaylist(raw as RawPlaylist);
|
||||
/**
|
||||
* Users
|
||||
*/
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
avatarMime?: string;
|
||||
description?: string;
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
export function deserializeUser(raw: RawUser): User {
|
||||
return {
|
||||
...raw,
|
||||
createdAt: new Date(raw.createdAt),
|
||||
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||
};
|
||||
}
|
||||
// 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 {
|
||||
@@ -107,32 +110,26 @@ export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
|
||||
return { ...raw, user: deserializeUser(raw.user) };
|
||||
}
|
||||
// Alias so call sites using deserializeUser continue to work.
|
||||
export const deserializeUser = deserializePublicUser;
|
||||
|
||||
export interface LoginUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
username?: string;
|
||||
password?: string;
|
||||
isAdmin?: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -189,11 +186,8 @@ export interface PlaylistMembership {
|
||||
|
||||
export type RawPlaylist = WithStringDate<Playlist>;
|
||||
export type RawPlaylistWithDumps =
|
||||
& Omit<PlaylistWithDumps, "createdAt" | "dumps">
|
||||
& {
|
||||
createdAt: string;
|
||||
dumps: RawDump[];
|
||||
};
|
||||
& Omit<WithStringDate<PlaylistWithDumps>, "dumps">
|
||||
& { dumps: RawDump[] };
|
||||
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
|
||||
|
||||
export function deserializePlaylist(raw: RawPlaylist): Playlist {
|
||||
@@ -221,154 +215,8 @@ export function deserializePlaylistMembership(
|
||||
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
|
||||
}
|
||||
|
||||
export interface CreatePlaylistRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePlaylistRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API
|
||||
*/
|
||||
|
||||
export const APIErrorCode = {
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
SERVER_ERROR: "SERVER_ERROR",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
} as const;
|
||||
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Request DTOs
|
||||
*/
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
url?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend
|
||||
*/
|
||||
|
||||
export interface ActionResultSuccess {
|
||||
success: true;
|
||||
}
|
||||
|
||||
export interface ActionResultFailure {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type ActionResult = ActionResultSuccess | ActionResultFailure;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
*/
|
||||
|
||||
export interface FollowStatus {
|
||||
followedUserIds: string[];
|
||||
followedPlaylistIds: string[];
|
||||
export function hydratePlaylist(raw: unknown): Playlist {
|
||||
return deserializePlaylist(raw as RawPlaylist);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,3 +295,209 @@ 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[];
|
||||
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 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 type IncomingWSMessage =
|
||||
| WSPingMessage
|
||||
| WSWelcomeMessage
|
||||
| WSPresenceUpdateMessage
|
||||
| WSVotesUpdateMessage
|
||||
| WSVoteAckMessage
|
||||
| WSDumpCreatedMessage
|
||||
| WSDumpUpdatedMessage
|
||||
| WSDumpDeletedMessage
|
||||
| WSPlaylistCreatedMessage
|
||||
| WSPlaylistUpdatedMessage
|
||||
| WSPlaylistDeletedMessage
|
||||
| WSPlaylistDumpsUpdatedMessage
|
||||
| WSUserUpdatedMessage
|
||||
| WSCommentCreatedMessage
|
||||
| WSCommentUpdatedMessage
|
||||
| WSCommentDeletedMessage
|
||||
| WSNotificationCreatedMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
/**
|
||||
* 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 type OutgoingWSMessage =
|
||||
| WSPongMessage
|
||||
| WSVoteCastMessage
|
||||
| WSVoteRemoveMessage;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
url?: 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user