import { Router } from "@oak/oak"; import { APIErrorCode, APIException, isLoginUserRequest, isUpdateUserRequest, type PaginatedData, validateRegisterUserRequest, } from "../model/interfaces.ts"; import { createJWT, verifyPassword } from "../lib/jwt.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; import { createUser, getUserById, getUserByUsername, searchUsers, updateUser, } from "../services/user-service.ts"; import { redeemInvite, validateInvite } from "../services/invite-service.ts"; import { broadcastUserUpdated } from "../services/ws-service.ts"; import { getDumpsByUser, getVotedDumpsByUser, } from "../services/dump-service.ts"; import { listPlaylistsByUser } from "../services/playlist-service.ts"; import { getFollowedPlaylistsByUser } from "../services/follow-service.ts"; // Users router const router = new Router({ prefix: "/api/users" }); // Register a new user (requires a valid invite token) router.post("/register", async (ctx) => { const body = await ctx.request.body.json(); const registerError = validateRegisterUserRequest(body); if (registerError) { throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError); } // Validate invite — throws 404/409 if bad const inviterId = await validateInvite(body.inviteToken); const user = await createUser(body, inviterId); // Mark invite as used only after the user row is committed try { redeemInvite(body.inviteToken); } catch (err) { console.error("[register] redeemInvite failed (user created):", err); } const authToken = await createJWT({ userId: user.id, username: user.username, isAdmin: user.isAdmin, }); const { passwordHash: _, ...publicUser } = user; ctx.response.status = 201; ctx.response.body = { success: true, data: { token: authToken, user: publicUser }, }; }); // Login router.post("/login", async (ctx) => { try { const body = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } const user = getUserByUsername(body.username); const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 401, "Invalid username or password", ); } const token = await createJWT({ userId: user.id, username: user.username, isAdmin: user.isAdmin, }); const { passwordHash: _, ...publicUser } = user; ctx.response.body = { success: true, data: { token, user: publicUser, }, }; } catch (err) { console.error(err); throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login"); } }); // Get current user profile router.get("/me", authMiddleware, (ctx: AuthContext) => { try { if (!ctx.state.user) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, "Not authenticated", ); } const { passwordHash: _, ...publicUser } = getUserById( ctx.state.user.userId, ); ctx.response.body = { success: true, data: publicUser, }; } catch (err) { console.error(err); throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Failed to fetch user profile", ); } }); // Update current user profile (description, etc.) router.patch("/me", authMiddleware, async (ctx: AuthContext) => { const body = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } const updated = await updateUser(ctx.state.user.userId, body); const { passwordHash: _, ...publicUser } = updated; broadcastUserUpdated(publicUser); ctx.response.body = { success: true, data: publicUser }; }); // User search for @mention autocomplete router.get("/search", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); const results = searchUsers(q, 8); ctx.response.body = { success: true, data: results }; }); // Public user profile by internal ID (used when only userId is available, e.g. dump.userId) router.get("/by-id/:userId", (ctx) => { const user = getUserById(ctx.params.userId); const { passwordHash: _, ...publicUser } = user; ctx.response.body = { success: true, data: publicUser }; }); // Followed playlists for a user (public only) router.get("/:username/followed-playlists", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit); ctx.response.body = { success: true, data: { items, total, hasMore: page * limit < total, } satisfies PaginatedData, }; }); // Playlists by user (optional auth: include private only if requester === owner) router.get("/:username/playlists", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = listPlaylistsByUser( user.id, requestingUserId, page, limit, ); ctx.response.body = { success: true, data: { items, total, hasMore: page * limit < total, } satisfies PaginatedData, }; }); // Public user profile by username (no passwordHash) router.get("/:username", (ctx) => { const user = getUserByUsername(ctx.params.username); const { passwordHash: _, ...publicUser } = user; ctx.response.body = { success: true, data: publicUser }; }); // Dumps posted by user (optional auth: owner sees their private dumps) router.get("/:username/dumps", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); const includePrivate = requestingUserId === user.id; const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); ctx.response.body = { success: true, data: { items, total, hasMore: page * limit < total, } satisfies PaginatedData, }; }); // Dumps upvoted by user (optional auth: hide private dump entries for non-owners) router.get("/:username/votes", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getVotedDumpsByUser( user.id, page, limit, requestingUserId, ); ctx.response.body = { success: true, data: { items, total, hasMore: page * limit < total, } satisfies PaginatedData, }; }); export default router;