v3: code quality pass

This commit is contained in:
khannurien
2026-03-24 18:47:05 +00:00
parent cd4076343b
commit c293f3e706
39 changed files with 1464 additions and 1555 deletions

View File

@@ -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;
}