v3: code quality pass
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user