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

@@ -75,7 +75,7 @@ export interface UserRow {
* Type Guards
*/
export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
export function isDumpRow(obj: unknown): obj is DumpRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
@@ -102,7 +102,7 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
"is_private" in obj && typeof obj.is_private === "number";
}
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
export function isUserRow(obj: unknown): obj is UserRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
@@ -214,18 +214,21 @@ export interface CommentRow {
}
export function isCommentRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is CommentRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.dump_id === "string" &&
typeof obj.user_id === "string" &&
"id" in obj && typeof obj.id === "string" &&
"dump_id" in obj && typeof obj.dump_id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"parent_id" in obj &&
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
typeof obj.body === "string" &&
typeof obj.created_at === "string" &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
typeof obj.deleted === "number" &&
typeof obj.author_username === "string" &&
"deleted" in obj && typeof obj.deleted === "number" &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
}
@@ -259,16 +262,20 @@ export interface PlaylistRow {
}
export function isPlaylistRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is PlaylistRow {
return !!obj && typeof obj.id === "string" &&
typeof obj.user_id === "string" &&
typeof obj.title === "string" &&
(typeof obj.slug === "string" || obj.slug === null) &&
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"title" in obj && typeof obj.title === "string" &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
typeof obj.is_public === "number" &&
typeof obj.created_at === "string" &&
"is_public" in obj && typeof obj.is_public === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"image_mime" in obj &&
(typeof obj.image_mime === "string" || obj.image_mime === null);
}
@@ -300,15 +307,15 @@ export interface FollowRow {
}
export function isFollowRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is FollowRow {
return !!obj &&
typeof obj.id === "string" &&
typeof obj.follower_id === "string" &&
typeof obj.created_at === "string" &&
(obj.followed_user_id === null ||
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"follower_id" in obj && typeof obj.follower_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"followed_user_id" in obj && (obj.followed_user_id === null ||
typeof obj.followed_user_id === "string") &&
(obj.followed_playlist_id === null ||
"followed_playlist_id" in obj && (obj.followed_playlist_id === null ||
typeof obj.followed_playlist_id === "string");
}
@@ -326,15 +333,16 @@ export interface NotificationRow {
}
export function isNotificationRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is NotificationRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.user_id === "string" &&
typeof obj.type === "string" &&
typeof obj.data === "string" &&
typeof obj.read === "number" &&
typeof obj.created_at === "string" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"type" in obj && typeof obj.type === "string" &&
"data" in obj && typeof obj.data === "string" &&
"read" in obj && typeof obj.read === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"source_key" in obj &&
(typeof obj.source_key === "string" || obj.source_key === null);
}
@@ -360,11 +368,12 @@ export interface InviteRow {
}
export function isInviteRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is InviteRow {
return !!obj && typeof obj === "object" &&
typeof obj.token === "string" &&
typeof obj.inviter_id === "string" &&
typeof obj.created_at === "string" &&
"token" in obj && typeof obj.token === "string" &&
"inviter_id" in obj && typeof obj.inviter_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}

View File

@@ -310,7 +310,7 @@ export interface CreatePlaylistRequest {
export interface UpdatePlaylistRequest {
title?: string;
description?: string;
description?: string | null;
isPublic?: boolean;
}
@@ -428,40 +428,33 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
* WebSockets
*/
// ── Client → Server ──────────────────────────────────────────────────────────
export interface PingMessage {
type: "ping";
}
export interface PongMessage {
type: "pong";
}
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 type ClientToServerMessage =
| PingMessage
| PongMessage
| VoteCastMessage
| VoteRemoveMessage;
// ── Server → Client ──────────────────────────────────────────────────────────
export interface OnlineUser {
userId: string;
@@ -474,6 +467,7 @@ export interface WelcomeMessage {
type: "welcome";
users: OnlineUser[];
myVotes: string[];
unreadNotificationCount: number;
}
export interface PresenceUpdateMessage {
@@ -481,14 +475,109 @@ export interface PresenceUpdateMessage {
users: OnlineUser[];
}
export interface PingMessage {
type: "ping";
export interface VotesUpdateMessage {
type: "votes_update";
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
}
export interface PongMessage {
type: "pong";
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 type ServerToClientMessage =
| PingMessage
| WelcomeMessage
| PresenceUpdateMessage
| VotesUpdateMessage
| VoteAckMessage
| DumpCreatedMessage
| DumpUpdatedMessage
| DumpDeletedMessage
| PlaylistCreatedMessage
| PlaylistUpdatedMessage
| PlaylistDeletedMessage
| PlaylistDumpsUpdatedMessage
| UserUpdatedMessage
| CommentCreatedMessage
| CommentUpdatedMessage
| CommentDeletedMessage
| NotificationCreatedMessage
| ErrorMessage;
/**
* Follows
*/
@@ -568,3 +657,8 @@ export interface Notification {
read: boolean;
createdAt: Date;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawNotification = Omit<Notification, "createdAt"> & {
createdAt: string;
};

View File

@@ -44,9 +44,9 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
}
updateClientAvatar(authPayload.userId, mime);
const user = getUserById(authPayload.userId);
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId);
ctx.response.status = 200;
ctx.response.body = { success: true, data: user };
ctx.response.body = { success: true, data: publicUser };
});
router.get("/api/avatars/:userId", async (ctx) => {

View File

@@ -38,7 +38,7 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
// POST /api/dumps/:dumpId/comments — auth required
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId as string;
const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId);
const body = await ctx.request.body.json();
if (!isCreateCommentRequest(body)) {
@@ -62,8 +62,8 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
// PATCH /api/comments/:commentId — auth required
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId as string;
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
const userId = ctx.state.user.userId;
const isAdmin = ctx.state.user.isAdmin ?? false;
const body = await ctx.request.body.json();
if (!isUpdateCommentRequest(body)) {
throw new APIException(
@@ -85,8 +85,8 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
// DELETE /api/comments/:commentId — auth required
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId as string;
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
const userId = ctx.state.user.userId;
const isAdmin = ctx.state.user.isAdmin ?? false;
const { dumpId, isPrivate } = deleteComment(
ctx.params.commentId,
userId,

View File

@@ -6,6 +6,7 @@ import {
type FollowStatus,
type PaginatedData,
} from "../model/interfaces.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
followPlaylist,
followUser,
@@ -22,26 +23,16 @@ const router = new Router({ prefix: "/api/follows" });
// GET /api/follows/status
router.get("/status", authMiddleware, (ctx) => {
const status = getFollowStatus(ctx.state.user.userId as string);
const status = getFollowStatus(ctx.state.user.userId);
const body: APIResponse<FollowStatus> = { success: true, data: status };
ctx.response.body = body;
});
// GET /api/follows/feed/users?page=&limit=
router.get("/feed/users", authMiddleware, (ctx) => {
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersDumpFeed(
ctx.state.user.userId as string,
ctx.state.user.userId,
page,
limit,
);
@@ -56,19 +47,9 @@ router.get("/feed/users", authMiddleware, (ctx) => {
// GET /api/follows/feed/playlists?page=&limit=
router.get("/feed/playlists", authMiddleware, (ctx) => {
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsDumpFeed(
ctx.state.user.userId as string,
ctx.state.user.userId,
page,
limit,
);
@@ -83,25 +64,25 @@ router.get("/feed/playlists", authMiddleware, (ctx) => {
// POST /api/follows/users/:userId
router.post("/users/:userId", authMiddleware, (ctx) => {
followUser(ctx.state.user.userId as string, ctx.params.userId);
followUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
// DELETE /api/follows/users/:userId
router.delete("/users/:userId", authMiddleware, (ctx) => {
unfollowUser(ctx.state.user.userId as string, ctx.params.userId);
unfollowUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
// POST /api/follows/playlists/:playlistId
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
followPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});
// DELETE /api/follows/playlists/:playlistId
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
unfollowPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});

View File

@@ -19,7 +19,7 @@ router.post("/", authMiddleware, async (ctx: AuthContext) => {
router.get("/:token", async (ctx) => {
try {
await validateInvite(ctx.params.token);
ctx.response.body = { success: true };
ctx.response.body = { success: true, data: null };
} catch {
throw new APIException(
APIErrorCode.NOT_FOUND,

View File

@@ -2,9 +2,9 @@ import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type AuthPayload,
type PaginatedData,
} from "../model/interfaces.ts";
import { parsePagination } from "../lib/pagination.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import {
getNotificationsForUser,
@@ -19,17 +19,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
}
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getNotificationsForUser(
ctx.state.user.userId,
page,
@@ -56,7 +46,7 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
// PATCH /api/notifications/:id/read
router.patch("/:id/read", authMiddleware, (ctx) => {
const user = ctx.state.user as AuthPayload;
const user = ctx.state.user;
if (!user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
}

View File

@@ -48,7 +48,7 @@ router.post("/register", async (ctx) => {
// Mark invite as used only after the user row is committed
try {
await redeemInvite(body.inviteToken);
redeemInvite(body.inviteToken);
} catch (err) {
console.error("[register] redeemInvite failed (user created):", err);
}
@@ -123,11 +123,13 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
);
}
const user = getUserById(ctx.state.user.userId);
const { passwordHash: _, ...publicUser } = getUserById(
ctx.state.user.userId,
);
ctx.response.body = {
success: true,
data: user,
data: publicUser,
};
} catch (err) {
console.error(err);

View File

@@ -16,7 +16,10 @@ import {
} from "../services/vote-service.ts";
import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts";
import { APIException } from "../model/interfaces.ts";
import {
APIException,
type ClientToServerMessage,
} from "../model/interfaces.ts";
const router = new Router();
@@ -78,7 +81,7 @@ router.get("/ws", async (ctx) => {
});
socket.addEventListener("message", (event) => {
let msg: { type: string; dumpId?: string };
let msg: ClientToServerMessage;
try {
msg = JSON.parse(event.data as string);
} catch {
@@ -109,7 +112,7 @@ router.get("/ws", async (ctx) => {
function handleVote(
client: WsClient,
dumpId: string | undefined,
dumpId: string,
action: "cast" | "remove",
): void {
const { socket } = client;
@@ -121,11 +124,6 @@ function handleVote(
return;
}
if (!dumpId) {
socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" }));
return;
}
try {
const newCount = action === "cast"
? castVote(dumpId, client.userId)

View File

@@ -4,12 +4,7 @@ import {
type Comment,
} from "../model/interfaces.ts";
import { type SQLOutputValue } from "node:sqlite";
import {
type CommentRow,
commentRowToApi,
db,
isCommentRow,
} from "../model/db.ts";
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
import { notifyMentions } from "./notification-service.ts";
const SELECT_COLS =
@@ -23,7 +18,14 @@ function fetchComment(commentId: string): Comment {
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
return commentRowToApi(row as CommentRow);
if (!isCommentRow(row)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed comment data",
);
}
return commentRowToApi(row);
}
export function getComments(dumpId: string): Comment[] {
@@ -31,15 +33,14 @@ export function getComments(dumpId: string): Comment[] {
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
).all(dumpId);
const typed = rows as Parameters<typeof isCommentRow>[0][];
if (!typed.every(isCommentRow)) {
if (!rows.every(isCommentRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed comment data",
);
}
return typed.map(commentRowToApi);
return rows.map(commentRowToApi);
}
export function createComment(

View File

@@ -448,11 +448,11 @@ export function getVotedDumpsByUser(
const dumpCols = SELECT_COLS_ALIASED;
let totalRow: { count: number } | undefined;
let rawRows: unknown[];
let rows: unknown[];
if (requestingUserId === userId) {
// Own profile: include private dumps the user themselves voted on and owns.
rawRows = db.prepare(
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
@@ -465,7 +465,7 @@ export function getVotedDumpsByUser(
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
).get(userId, userId) as { count: number } | undefined;
} else {
rawRows = db.prepare(
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
@@ -479,7 +479,6 @@ export function getVotedDumpsByUser(
).get(userId) as { count: number } | undefined;
}
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!rows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,

View File

@@ -114,12 +114,12 @@ export function getFollowStatus(followerId: string): FollowStatus {
const rawUserRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
).all(followerId) as Parameters<typeof isFollowRow>[0][];
).all(followerId);
const rawPlaylistRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`,
).all(followerId) as Parameters<typeof isFollowRow>[0][];
).all(followerId);
if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) {
throw new APIException(
@@ -207,8 +207,7 @@ export function getFollowedPlaylistsDumpFeed(
AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined;
const playlistFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!playlistFeedRows.every(isDumpRow)) {
if (!rawRows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
@@ -216,7 +215,7 @@ export function getFollowedPlaylistsDumpFeed(
);
}
return {
items: playlistFeedRows.map(dumpRowToApi),
items: rawRows.map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
}
@@ -246,7 +245,7 @@ export function getFollowedPlaylistsByUser(
AND p.is_public = 1
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit, offset) as Parameters<typeof isPlaylistRow>[0][];
).all(userId, limit, offset);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(

View File

@@ -57,7 +57,7 @@ export function getNotificationsForUser(
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, limit, offset) as Parameters<typeof isNotificationRow>[0][];
).all(userId, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
@@ -195,6 +195,7 @@ export function notifyUserFollowersNewDump(
sendToUser(row.follower_id, {
type: "notification_created",
notification: {
id: crypto.randomUUID(),
userId: row.follower_id,
type: "user_dump_posted",
data,

View File

@@ -1,4 +1,3 @@
import type { SQLOutputValue } from "node:sqlite";
import {
APIErrorCode,
APIException,
@@ -29,7 +28,7 @@ import {
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
const DUMP_SELECT_COLS =
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
@@ -340,7 +339,7 @@ export function getPlaylistMembershipsForDump(
LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ?
WHERE p.user_id = ?
ORDER BY p.created_at DESC;`,
).all(dumpId, userId) as Array<Record<string, SQLOutputValue>>;
).all(dumpId, userId);
return rows.map((row) => {
if (!isPlaylistRow(row)) {

View File

@@ -3,6 +3,7 @@ import type {
Dump,
OnlineUser,
Playlist,
ServerToClientMessage,
User,
} from "../model/interfaces.ts";
@@ -51,13 +52,13 @@ export function getOnlineUsers(): OnlineUser[] {
return Array.from(seen.values());
}
function send(socket: WebSocket, data: unknown): void {
function send(socket: WebSocket, data: ServerToClientMessage): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
}
}
export function sendToUser(userId: string, data: unknown): void {
export function sendToUser(userId: string, data: ServerToClientMessage): void {
for (const client of clients) {
if (client.userId === userId) {
send(client.socket, data);
@@ -109,7 +110,7 @@ export function broadcastVoteUpdate(
function sendToPlaylistAudience(
playlist: Pick<Playlist, "isPublic" | "userId">,
data: unknown,
data: ServerToClientMessage,
): void {
for (const client of clients) {
if (playlist.isPublic || client.userId === playlist.userId) {