Files
adrianbonpin cd72b7a948 refactor: convert to bun workspaces monorepo
- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
2026-06-28 05:20:28 +08:00

328 lines
9.0 KiB
TypeScript

import { Elysia, t } from "elysia"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db/index"
import { user, performanceEntries, games, gameVersions, hardware, account, passkey } from "@/lib/db/schema"
import { eq, sql, and, desc } from "drizzle-orm"
import { hashPassword } from "better-auth/crypto"
export const userRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
.get(
"/profile/:id",
async ({ params, set }) => {
const [profile] = await db
.select({
id: user.id,
name: user.name,
image: user.image,
role: user.role,
createdAt: user.createdAt,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, params.id))
.limit(1)
if (!profile) {
set.status = 404
return { error: "User not found" }
}
// Count contributions (performance entries)
const [{ count: contributions }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(eq(performanceEntries.userId, params.id))
// Count verified entries
const [{ count: verifiedEntries }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(
sql`${performanceEntries.userId} = ${params.id} AND ${performanceEntries.verifiedAt} IS NOT NULL`
)
// Reputation = contributions * 10 (simple formula for now)
const reputation = contributions * 10
const { emailVerified, ...publicProfile } = profile
return {
...publicProfile,
createdAt: publicProfile.createdAt.toISOString(),
contributions,
verifiedEntries,
reputation,
verified: !!emailVerified,
}
},
{
params: t.Object({
id: t.String(),
}),
},
)
.get(
"/me",
async ({ request, set }) => {
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session) {
set.status = 401
return { error: "Unauthorized" }
}
const [profile] = await db
.select({
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role,
createdAt: user.createdAt,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (!profile) {
set.status = 404
return { error: "User not found" }
}
const [{ count: contributions }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(eq(performanceEntries.userId, session.user.id))
// Count verified entries
const [{ count: verifiedEntries }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(
sql`${performanceEntries.userId} = ${session.user.id} AND ${performanceEntries.verifiedAt} IS NOT NULL`
)
return {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.image,
role: profile.role,
createdAt: profile.createdAt.toISOString(),
contributions,
verifiedEntries,
reputation: contributions * 10,
verified: !!profile.emailVerified,
}
},
)
.get(
"/me/sessions",
async ({ request, set }) => {
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session) {
set.status = 401
return { error: "Unauthorized" }
}
const sessions = await auth.api.listSessions({
headers: request.headers,
})
return sessions
},
)
.get(
"/me/auth-methods",
async ({ request, set }) => {
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session) {
set.status = 401
return { error: "Unauthorized" }
}
// Count accounts by provider
const accounts = await db
.select({ providerId: account.providerId, id: account.id })
.from(account)
.where(eq(account.userId, session.user.id))
// Count passkeys via direct DB query (avoids auth.api.listPasskeys hanging)
const passkeys = await db
.select({ id: passkey.id })
.from(passkey)
.where(eq(passkey.userId, session.user.id))
// Check if user has a password (from accounts where providerId is "credential")
const hasPassword = accounts.some((a) => a.providerId === "credential")
// OAuth providers
const oauthProviders = accounts
.filter((a) => a.providerId !== "credential")
.map((a) => ({
providerId: a.providerId,
id: a.id,
}))
// Total auth methods = passwords + passkeys + oauth accounts
const passkeyCount = passkeys.length
const totalAuthMethods =
(hasPassword ? 1 : 0) + passkeyCount + oauthProviders.length
return {
hasPassword,
passkeyCount,
oauthProviders,
totalAuthMethods,
}
},
)
.post(
"/me/set-password",
async ({ request, body, set }) => {
// Reject non-JSON content types
const contentType = request.headers.get("content-type") || ""
if (!contentType.includes("application/json")) {
set.status = 415
return { error: "Content-Type must be application/json" }
}
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session) {
set.status = 401
return { error: "Unauthorized" }
}
// Check if user already has a password
const existing = await db
.select({ id: account.id })
.from(account)
.where(
and(
eq(account.userId, session.user.id),
eq(account.providerId, "credential")
)
)
.limit(1)
if (existing.length > 0) {
set.status = 400
return { error: "Password already set" }
}
const hashed = await hashPassword(body.newPassword)
await db.insert(account).values({
id: crypto.randomUUID(),
userId: session.user.id,
providerId: "credential",
accountId: session.user.id,
password: hashed,
})
return { success: true }
},
{
body: t.Object({
newPassword: t.String({ minLength: 10 }),
}),
detail: {
description: "Set a password for the authenticated user. Requires JSON body.",
tags: ["Users"],
},
},
)
.get(
"/profile/:id/contributions",
async ({ params, query, set }) => {
const [profile] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, params.id))
.limit(1)
if (!profile) {
set.status = 404
return { error: "User not found" }
}
const limit = Math.min(Number(query.limit) || 10, 50)
const offset = Number(query.offset) || 0
const entries = await db
.select({
id: performanceEntries.id,
fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh,
hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name,
upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod,
verifiedAt: performanceEntries.verifiedAt,
createdAt: performanceEntries.createdAt,
gameTitle: games.title,
gameId: games.id,
gameHeaderImage: games.headerImage,
})
.from(performanceEntries)
.innerJoin(
gameVersions,
eq(performanceEntries.versionId, gameVersions.id),
)
.innerJoin(games, eq(gameVersions.gameId, games.id))
.innerJoin(
hardware,
eq(performanceEntries.hardwareSlug, hardware.slug),
)
.where(and(
eq(performanceEntries.userId, params.id),
eq(performanceEntries.isRemoved, false)
))
.orderBy(desc(performanceEntries.createdAt))
.limit(limit)
.offset(offset)
const [{ count: total }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, params.id),
eq(performanceEntries.isRemoved, false)
))
return {
data: entries.map((e) => ({
...e,
createdAt: e.createdAt.toISOString(),
verifiedAt: e.verifiedAt?.toISOString() ?? null,
})),
total,
limit,
offset,
}
},
{
params: t.Object({
id: t.String(),
}),
query: t.Object({
limit: t.Optional(t.String()),
offset: t.Optional(t.String()),
}),
},
)