- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
328 lines
9.0 KiB
TypeScript
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()),
|
|
}),
|
|
},
|
|
)
|
|
|