feat: add OpenAPI support and in-process cron scheduler
- Install @elysia/openapi to auto-generate API docs at /api/openapi - Install @elysia/cron for in-process scheduled jobs (orphan detection at 2am, storage cleanup at 3am) - Add Scalar UI docs with 14 tag groups and Bearer JWT security scheme - Add route-level detail annotations (tags, summaries) to all 30+ route files - Upgrade crud-builder to accept optional tags config - Keep HTTP cron endpoint as manual/admin fallback
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
"@aws-sdk/client-s3": "^3.1036.0",
|
"@aws-sdk/client-s3": "^3.1036.0",
|
||||||
"@better-auth/drizzle-adapter": "^1.6.9",
|
"@better-auth/drizzle-adapter": "^1.6.9",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/passkey": "^1.6.9",
|
||||||
|
"@elysia/cron": "^1.4.2",
|
||||||
"@elysia/eden": "^1.4.10",
|
"@elysia/eden": "^1.4.10",
|
||||||
|
"@elysia/openapi": "^1.4.15",
|
||||||
"@serwist/expiration": "^9.5.11",
|
"@serwist/expiration": "^9.5.11",
|
||||||
"@serwist/next": "^9.5.11",
|
"@serwist/next": "^9.5.11",
|
||||||
"@serwist/precaching": "^9.5.11",
|
"@serwist/precaching": "^9.5.11",
|
||||||
@@ -210,8 +212,12 @@
|
|||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
|
"@elysia/cron": ["@elysia/cron@1.4.2", "", { "dependencies": { "croner": "^6.0.3" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-YCaFCaT7CO7Lmfv66YGCqMYaAEjLZBpPtocaj9kS6NkhXqayhyKxqUOLSVW3qpIL9ZAE9m47GC650ZtvHpA58g=="],
|
||||||
|
|
||||||
"@elysia/eden": ["@elysia/eden@1.4.10", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-vcZXQcW6wZj6rhTxaiTkuCbVuS/yAJQ9jqCM6b83a5hu99F5Aj3HOyWXL17iod2Rz+pJpYsQGX0XaeoCsMpw2g=="],
|
"@elysia/eden": ["@elysia/eden@1.4.10", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-vcZXQcW6wZj6rhTxaiTkuCbVuS/yAJQ9jqCM6b83a5hu99F5Aj3HOyWXL17iod2Rz+pJpYsQGX0XaeoCsMpw2g=="],
|
||||||
|
|
||||||
|
"@elysia/openapi": ["@elysia/openapi@1.4.15", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-MrVGCPo5hnRF4/yWGOyH5ddjt4DlSIbeC5rrkupQtHY8TEQe+XdJa2YM9aUOrtgtRcy4UNGyiVlLl/jllPkPQA=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||||
@@ -904,6 +910,8 @@
|
|||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"croner": ["croner@6.0.7", "", {}, "sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
requireAdmin,
|
requireAdmin,
|
||||||
} from "@/lib/auth/guard"
|
} from "@/lib/auth/guard"
|
||||||
|
|
||||||
export const adminCommentRoutes = new Elysia({ prefix: "/admin" })
|
export const adminCommentRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||||
.get(
|
.get(
|
||||||
"/comments",
|
"/comments",
|
||||||
async ({ query, request, set }) => {
|
async ({ query, request, set }) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
requireAdmin,
|
requireAdmin,
|
||||||
} from "@/lib/auth/guard"
|
} from "@/lib/auth/guard"
|
||||||
|
|
||||||
export const adminPerformanceRoutes = new Elysia({ prefix: "/admin" })
|
export const adminPerformanceRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||||
.get(
|
.get(
|
||||||
"/performance",
|
"/performance",
|
||||||
async ({ query, request, set }) => {
|
async ({ query, request, set }) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { eq, desc, sql, and, inArray } from "drizzle-orm"
|
import { eq, desc, sql, and, inArray } from "drizzle-orm"
|
||||||
import { requireContributorOrAdmin } from "@/lib/auth/guard"
|
import { requireContributorOrAdmin } from "@/lib/auth/guard"
|
||||||
|
|
||||||
export const adminReportRoutes = new Elysia({ prefix: "/admin" })
|
export const adminReportRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||||
.get(
|
.get(
|
||||||
"/reports",
|
"/reports",
|
||||||
async ({ query, request, set }) => {
|
async ({ query, request, set }) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq, sql, and, ilike, desc } from "drizzle-orm"
|
|||||||
import { requireAdmin } from "@/lib/auth/guard"
|
import { requireAdmin } from "@/lib/auth/guard"
|
||||||
import { deleteObject, isR2Configured } from "@/lib/storage"
|
import { deleteObject, isR2Configured } from "@/lib/storage"
|
||||||
|
|
||||||
export const adminStorageRoutes = new Elysia({ prefix: "/admin/storage" })
|
export const adminStorageRoutes = new Elysia({ prefix: "/admin/storage", detail: { tags: ["Admin"] } })
|
||||||
|
|
||||||
// ── GET /stats ──────────────────────────────────────────────────
|
// ── GET /stats ──────────────────────────────────────────────────
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Elysia } from "elysia"
|
import { Elysia } from "elysia"
|
||||||
|
import { openapi } from "@elysia/openapi"
|
||||||
|
import { cron, Patterns } from "@elysia/cron"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { rateLimit } from "@/lib/auth/rate-limit"
|
import { rateLimit } from "@/lib/auth/rate-limit"
|
||||||
|
import { taskRegistry } from "./cron"
|
||||||
import {
|
import {
|
||||||
healthRoutes,
|
healthRoutes,
|
||||||
userRoutes,
|
userRoutes,
|
||||||
@@ -59,6 +62,76 @@ const betterAuth = new Elysia({ name: "better-auth" })
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const app = new Elysia({ prefix: "/api" })
|
export const app = new Elysia({ prefix: "/api" })
|
||||||
|
.use(
|
||||||
|
openapi({
|
||||||
|
path: "/openapi",
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "DeckyVault API",
|
||||||
|
version: "2026.0.100",
|
||||||
|
description:
|
||||||
|
"API for DeckyVault — Steam Deck game compatibility, performance reports, and community features.",
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{ name: "Health", description: "Health check endpoints" },
|
||||||
|
{ name: "Auth", description: "Authentication endpoints" },
|
||||||
|
{ name: "Users", description: "User management" },
|
||||||
|
{ name: "Games", description: "Games listing and details" },
|
||||||
|
{ name: "Hardware", description: "Hardware submission and stats" },
|
||||||
|
{ name: "Performance", description: "Performance reports and verification" },
|
||||||
|
{ name: "Comments", description: "Game comments" },
|
||||||
|
{ name: "Reports", description: "User reports" },
|
||||||
|
{ name: "Admin", description: "Admin-only endpoints" },
|
||||||
|
{ name: "Steam", description: "Steam proxy endpoints" },
|
||||||
|
{ name: "Search", description: "Search endpoints" },
|
||||||
|
{ name: "Contact", description: "Contact form" },
|
||||||
|
{ name: "Dashboard", description: "Dashboard data" },
|
||||||
|
{ name: "Cron", description: "Scheduled job triggers" },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
cron({
|
||||||
|
name: "orphan_detection",
|
||||||
|
pattern: Patterns.EVERY_DAY_AT_2AM,
|
||||||
|
async run() {
|
||||||
|
const task = taskRegistry.get("orphan_detection")
|
||||||
|
if (!task) return
|
||||||
|
try {
|
||||||
|
const result = await task()
|
||||||
|
console.log("[cron] orphan_detection:", result.status, result.details)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[cron] orphan_detection failed:", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
cron({
|
||||||
|
name: "storage_cleanup",
|
||||||
|
pattern: Patterns.EVERY_DAY_AT_3AM,
|
||||||
|
async run() {
|
||||||
|
const task = taskRegistry.get("storage_cleanup")
|
||||||
|
if (!task) return
|
||||||
|
try {
|
||||||
|
const result = await task()
|
||||||
|
console.log("[cron] storage_cleanup:", result.status, result.details)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[cron] storage_cleanup failed:", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
.onError(({ code, error, set, request }) => {
|
.onError(({ code, error, set, request }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`[API Error] ${code} ${request.url}`,
|
`[API Error] ${code} ${request.url}`,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { requireRole } from "@/lib/auth/guard"
|
|||||||
|
|
||||||
export const commentsRoutes = new Elysia({
|
export const commentsRoutes = new Elysia({
|
||||||
prefix: "/games/:gameId/comments",
|
prefix: "/games/:gameId/comments",
|
||||||
|
detail: { tags: ["Comments"] },
|
||||||
})
|
})
|
||||||
// LIST top-level comments for a game (paginated)
|
// LIST top-level comments for a game (paginated)
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const allowedFields = [
|
|||||||
|
|
||||||
export const communitySuggestionRoutes = new Elysia({
|
export const communitySuggestionRoutes = new Elysia({
|
||||||
prefix: "/community-suggestions",
|
prefix: "/community-suggestions",
|
||||||
|
detail: { tags: ["Games"] },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin list with pagination and filtering
|
// Admin list with pagination and filtering
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { db } from "@/lib/db/index"
|
|||||||
import { games, gameVersions, performanceEntries, hardware } from "@/lib/db/schema"
|
import { games, gameVersions, performanceEntries, hardware } from "@/lib/db/schema"
|
||||||
import { eq, and, inArray } from "drizzle-orm"
|
import { eq, and, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
export const compareRoutes = new Elysia({ prefix: "/compare" })
|
export const compareRoutes = new Elysia({ prefix: "/compare", detail: { tags: ["Games"] } })
|
||||||
.get(
|
.get(
|
||||||
"/games",
|
"/games",
|
||||||
async ({ query, set }) => {
|
async ({ query, set }) => {
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const VALID_CATEGORIES = ["bug", "game_data", "user_report", "feature", "feedback", "database"]
|
const VALID_CATEGORIES = ["bug", "game_data", "user_report", "feature", "feedback", "database"]
|
||||||
|
|
||||||
export const contactRoutes = new Elysia({ prefix: "/contact" }).post(
|
export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: ["Contact"] } }).post(
|
||||||
"/",
|
"/",
|
||||||
async ({ body, request, set }) => {
|
async ({ body, request, set }) => {
|
||||||
const payload = body as {
|
const payload = body as {
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ interface CronTaskResult {
|
|||||||
// ── Task Registry ───────────────────────────────────────────────────
|
// ── Task Registry ───────────────────────────────────────────────────
|
||||||
type CronTask = () => Promise<CronTaskResult>
|
type CronTask = () => Promise<CronTaskResult>
|
||||||
|
|
||||||
const taskRegistry = new Map<string, CronTask>()
|
export const taskRegistry = new Map<string, CronTask>()
|
||||||
|
|
||||||
export function registerCronTask(name: string, task: CronTask): void {
|
export function registerCronTask(name: string, task: CronTask): void {
|
||||||
taskRegistry.set(name, task)
|
taskRegistry.set(name, task)
|
||||||
@@ -111,7 +111,7 @@ registerCronTask("orphan_detection", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Cron Route ──────────────────────────────────────────────────────
|
// ── Cron Route ──────────────────────────────────────────────────────
|
||||||
export const cronRoutes = new Elysia({ prefix: "/cron" }).post(
|
export const cronRoutes = new Elysia({ prefix: "/cron", detail: { tags: ["Cron"] } }).post(
|
||||||
"/daily",
|
"/daily",
|
||||||
async ({ query, set }) => {
|
async ({ query, set }) => {
|
||||||
const cronSecret = process.env.CRON_SECRET
|
const cronSecret = process.env.CRON_SECRET
|
||||||
|
|||||||
+26
-1
@@ -60,6 +60,7 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
primaryKey?: string
|
primaryKey?: string
|
||||||
paramName?: string
|
paramName?: string
|
||||||
softDelete?: boolean
|
softDelete?: boolean
|
||||||
|
tags?: string[]
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
@@ -71,6 +72,7 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
primaryKey = "id",
|
primaryKey = "id",
|
||||||
paramName = primaryKey,
|
paramName = primaryKey,
|
||||||
softDelete = false,
|
softDelete = false,
|
||||||
|
tags,
|
||||||
} = config
|
} = config
|
||||||
|
|
||||||
const columns = getTableColumns(table) as Record<string, PgColumn>
|
const columns = getTableColumns(table) as Record<string, PgColumn>
|
||||||
@@ -80,7 +82,10 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
throw new Error(`Primary key column "${primaryKey}" not found on table`)
|
throw new Error(`Primary key column "${primaryKey}" not found on table`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = new Elysia({ prefix })
|
const routes = new Elysia({
|
||||||
|
prefix,
|
||||||
|
...(tags ? { detail: { tags } } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
// ── LIST ──────────────────────────────────────────────────────────
|
// ── LIST ──────────────────────────────────────────────────────────
|
||||||
routes.get(
|
routes.get(
|
||||||
@@ -157,6 +162,10 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
// Dynamic filter fields are too varied for static TypeBox,
|
// Dynamic filter fields are too varied for static TypeBox,
|
||||||
// so we allow any string keys with filter_ prefix
|
// so we allow any string keys with filter_ prefix
|
||||||
}),
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: `List ${name}s`,
|
||||||
|
description: `Returns a paginated list of ${name}s with optional search and filtering.`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,6 +192,10 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
params: t.Object({
|
params: t.Object({
|
||||||
[paramName]: t.String(),
|
[paramName]: t.String(),
|
||||||
}),
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: `Get ${name} by ID`,
|
||||||
|
description: `Returns a single ${name} by its unique identifier.`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,6 +224,10 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: t.Record(t.String(), t.Any()),
|
body: t.Record(t.String(), t.Any()),
|
||||||
|
detail: {
|
||||||
|
summary: `Create ${name}`,
|
||||||
|
description: `Creates a new ${name}. Requires authentication.`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,6 +273,10 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
[paramName]: t.String(),
|
[paramName]: t.String(),
|
||||||
}),
|
}),
|
||||||
body: t.Record(t.String(), t.Any()),
|
body: t.Record(t.String(), t.Any()),
|
||||||
|
detail: {
|
||||||
|
summary: `Update ${name}`,
|
||||||
|
description: `Updates an existing ${name} by ID. Requires authentication.`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,6 +324,10 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
params: t.Object({
|
params: t.Object({
|
||||||
[paramName]: t.String(),
|
[paramName]: t.String(),
|
||||||
}),
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: `Delete ${name}`,
|
||||||
|
description: `Deletes a ${name} by ID. Requires admin or contributor role.`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { sql } from "drizzle-orm"
|
|||||||
|
|
||||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard" })
|
export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard", detail: { tags: ["Dashboard"] } })
|
||||||
|
|
||||||
// ── Trending Games (7-day activity) ────────────────────────────────
|
// ── Trending Games (7-day activity) ────────────────────────────────
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { eq, count, sql, gte, and, desc } from "drizzle-orm";
|
import { eq, count, sql, gte, and, desc } from "drizzle-orm";
|
||||||
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
||||||
|
|
||||||
export const dashboardRoutes = new Elysia({ prefix: "/dashboard" }).get(
|
export const dashboardRoutes = new Elysia({ prefix: "/dashboard", detail: { tags: ["Dashboard"] } }).get(
|
||||||
"/stats",
|
"/stats",
|
||||||
async ({ request, set }) => {
|
async ({ request, set }) => {
|
||||||
const guard = await requireContributorOrAdmin(request.headers);
|
const guard = await requireContributorOrAdmin(request.headers);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { eq, and, inArray, sql } from "drizzle-orm"
|
import { eq, and, inArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
|
export const gameStatsRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } }).get(
|
||||||
"/:gameId/stats",
|
"/:gameId/stats",
|
||||||
async ({ params, set }) => {
|
async ({ params, set }) => {
|
||||||
const { gameId } = params
|
const { gameId } = params
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia, t } from "elysia"
|
||||||
import { ensureSteamGame } from "@/lib/steam/sync"
|
import { ensureSteamGame } from "@/lib/steam/sync"
|
||||||
|
|
||||||
export const gameStubRoutes = new Elysia({ prefix: "/games" }).post(
|
export const gameStubRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } }).post(
|
||||||
"/stub",
|
"/stub",
|
||||||
async ({ body, set }) => {
|
async ({ body, set }) => {
|
||||||
const result = await ensureSteamGame(body.steamAppId)
|
const result = await ensureSteamGame(body.steamAppId)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { fuzzySearchTerm } from "@/lib/db/search"
|
|||||||
const MAX_OFFSET = 10000
|
const MAX_OFFSET = 10000
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
|
|
||||||
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing", detail: { tags: ["Games"] } }).get(
|
||||||
"/",
|
"/",
|
||||||
async ({ query }) => {
|
async ({ query }) => {
|
||||||
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
|
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { games, gameVersions, gamePlatformSupport } from "@/lib/db/schema"
|
|||||||
import { ilike, eq } from "drizzle-orm"
|
import { ilike, eq } from "drizzle-orm"
|
||||||
import { requireRole } from "@/lib/auth/guard"
|
import { requireRole } from "@/lib/auth/guard"
|
||||||
|
|
||||||
export const gamesManualRoutes = new Elysia({ prefix: "/games" })
|
export const gamesManualRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } })
|
||||||
.post(
|
.post(
|
||||||
"/manual",
|
"/manual",
|
||||||
async ({ request, body, set }) => {
|
async ({ request, body, set }) => {
|
||||||
|
|||||||
+9
-2
@@ -10,6 +10,7 @@ import { requireRole } from "@/lib/auth/guard"
|
|||||||
export const gamesRoutes = createCrudRoutes(games, {
|
export const gamesRoutes = createCrudRoutes(games, {
|
||||||
prefix: "/games",
|
prefix: "/games",
|
||||||
name: "Game",
|
name: "Game",
|
||||||
|
tags: ["Games"],
|
||||||
auth: { read: "public", write: "contributor", delete: "admin" },
|
auth: { read: "public", write: "contributor", delete: "admin" },
|
||||||
search: { fields: ["title", "developer", "publisher"] },
|
search: { fields: ["title", "developer", "publisher"] },
|
||||||
filter: { fields: ["source", "onlineMultiplayerStatus", "syncStatus"] },
|
filter: { fields: ["source", "onlineMultiplayerStatus", "syncStatus"] },
|
||||||
@@ -17,7 +18,10 @@ export const gamesRoutes = createCrudRoutes(games, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Game Versions (nested under /games/:gameId/versions) ──────────
|
// ── Game Versions (nested under /games/:gameId/versions) ──────────
|
||||||
export const gameVersionsRoutes = new Elysia({ prefix: "/games/:gameId/versions" })
|
export const gameVersionsRoutes = new Elysia({
|
||||||
|
prefix: "/games/:gameId/versions",
|
||||||
|
detail: { tags: ["Games"] },
|
||||||
|
})
|
||||||
// LIST versions for a game
|
// LIST versions for a game
|
||||||
.get(
|
.get(
|
||||||
"/",
|
"/",
|
||||||
@@ -192,7 +196,10 @@ export const gameVersionsRoutes = new Elysia({ prefix: "/games/:gameId/versions"
|
|||||||
const MAX_BULK_SYNC = 1000
|
const MAX_BULK_SYNC = 1000
|
||||||
|
|
||||||
|
|
||||||
export const gameSyncRoutes = new Elysia({ prefix: "/games" })
|
export const gameSyncRoutes = new Elysia({
|
||||||
|
prefix: "/games",
|
||||||
|
detail: { tags: ["Admin"] },
|
||||||
|
})
|
||||||
// Bulk sync with streaming progress (defined before /:gameId/sync to avoid route conflict)
|
// Bulk sync with streaming progress (defined before /:gameId/sync to avoid route conflict)
|
||||||
.post(
|
.post(
|
||||||
"/sync/bulk",
|
"/sync/bulk",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { eq, and, sql, desc } from "drizzle-orm"
|
import { eq, and, sql, desc } from "drizzle-orm"
|
||||||
|
|
||||||
export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" })
|
export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware", detail: { tags: ["Hardware"] } })
|
||||||
// ── All devices with aggregated stats ──────────────────────
|
// ── All devices with aggregated stats ──────────────────────
|
||||||
.get(
|
.get(
|
||||||
"/stats",
|
"/stats",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { hardware } from "@/lib/db/schema"
|
|||||||
export const hardwareRoutes = createCrudRoutes(hardware, {
|
export const hardwareRoutes = createCrudRoutes(hardware, {
|
||||||
prefix: "/hardware",
|
prefix: "/hardware",
|
||||||
name: "Hardware",
|
name: "Hardware",
|
||||||
|
tags: ["Hardware"],
|
||||||
primaryKey: "slug",
|
primaryKey: "slug",
|
||||||
auth: { read: "public", write: "admin", delete: "admin" },
|
auth: { read: "public", write: "admin", delete: "admin" },
|
||||||
filter: { fields: ["deviceType"] },
|
filter: { fields: ["deviceType"] },
|
||||||
|
|||||||
+10
-1
@@ -1,10 +1,19 @@
|
|||||||
import { Elysia } from "elysia"
|
import { Elysia } from "elysia"
|
||||||
|
|
||||||
export const healthRoutes = new Elysia({ prefix: "/health" }).get(
|
export const healthRoutes = new Elysia({
|
||||||
|
prefix: "/health",
|
||||||
|
detail: { tags: ["Health"] },
|
||||||
|
}).get(
|
||||||
"/",
|
"/",
|
||||||
() => ({
|
() => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
service: "deckyvault-api",
|
service: "deckyvault-api",
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
summary: "Health check",
|
||||||
|
description: "Returns the current health status of the API.",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { processScreenshot, isAllowedMimeType, validateMagicBytes } from "@/lib/
|
|||||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
|
export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||||
.get(
|
.get(
|
||||||
"/autocomplete",
|
"/autocomplete",
|
||||||
async ({ query }) => {
|
async ({ query }) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
|||||||
export const performanceRoutes = createCrudRoutes(performanceEntries, {
|
export const performanceRoutes = createCrudRoutes(performanceEntries, {
|
||||||
prefix: "/performance",
|
prefix: "/performance",
|
||||||
name: "Performance Entry",
|
name: "Performance Entry",
|
||||||
|
tags: ["Performance"],
|
||||||
auth: { read: "public", write: "user", delete: "admin" },
|
auth: { read: "public", write: "user", delete: "admin" },
|
||||||
softDelete: true,
|
softDelete: true,
|
||||||
search: { fields: ["userNotes"] },
|
search: { fields: ["userNotes"] },
|
||||||
@@ -24,6 +25,7 @@ export const performanceRoutes = createCrudRoutes(performanceEntries, {
|
|||||||
// ── Verify endpoint (admin/mod) ───────────────────────────────────
|
// ── Verify endpoint (admin/mod) ───────────────────────────────────
|
||||||
export const performanceVerifyRoutes = new Elysia({
|
export const performanceVerifyRoutes = new Elysia({
|
||||||
prefix: "/performance",
|
prefix: "/performance",
|
||||||
|
detail: { tags: ["Performance"] },
|
||||||
})
|
})
|
||||||
.post(
|
.post(
|
||||||
"/:id/verify",
|
"/:id/verify",
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export async function recalculatePlayability(gameId: string): Promise<{
|
|||||||
return { gamePlayability: worstStatus, deviceResults: results };
|
return { gamePlayability: worstStatus, deviceResults: results };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
export const playabilityRoutes = new Elysia({ prefix: "/playability", detail: { tags: ["Games"] } })
|
||||||
// Manual trigger for recalculation (admin/contributor)
|
// Manual trigger for recalculation (admin/contributor)
|
||||||
.post("/calculate/:gameId", async ({ params, request, set }) => {
|
.post("/calculate/:gameId", async ({ params, request, set }) => {
|
||||||
const guard = await requireContributorOrAdmin(request.headers);
|
const guard = await requireContributorOrAdmin(request.headers);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean {
|
|||||||
return expected.every((byte, i) => buffer[i] === byte)
|
return expected.every((byte, i) => buffer[i] === byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const profilePhotoRoutes = new Elysia({ prefix: "/user" })
|
export const profilePhotoRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
|
||||||
|
|
||||||
// ── Upload Profile Photo ──────────────────────────────────────────
|
// ── Upload Profile Photo ──────────────────────────────────────────
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { reports } from "@/lib/db/schema"
|
|||||||
import { eq, and } from "drizzle-orm"
|
import { eq, and } from "drizzle-orm"
|
||||||
import { requireRole } from "@/lib/auth/guard"
|
import { requireRole } from "@/lib/auth/guard"
|
||||||
|
|
||||||
export const reportRoutes = new Elysia({ prefix: "/performance" }).post(
|
export const reportRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Reports"] } }).post(
|
||||||
"/:id/report",
|
"/:id/report",
|
||||||
async ({ params, body, request, set }) => {
|
async ({ params, body, request, set }) => {
|
||||||
const guard = await requireRole(request.headers, [
|
const guard = await requireRole(request.headers, [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { savedFilters } from "@/lib/db/schema"
|
|||||||
import { eq, and, desc } from "drizzle-orm"
|
import { eq, and, desc } from "drizzle-orm"
|
||||||
import { requireAuth } from "@/lib/auth/guard"
|
import { requireAuth } from "@/lib/auth/guard"
|
||||||
|
|
||||||
export const savedFilterRoutes = new Elysia({ prefix: "/saved-filters" })
|
export const savedFilterRoutes = new Elysia({ prefix: "/saved-filters", detail: { tags: ["Games"] } })
|
||||||
|
|
||||||
// Get user's saved filters
|
// Get user's saved filters
|
||||||
.get("/", async ({ request, set }) => {
|
.get("/", async ({ request, set }) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from "@/lib/db/index"
|
|||||||
import { savedGames, games } from "@/lib/db/schema"
|
import { savedGames, games } from "@/lib/db/schema"
|
||||||
import { eq, and, sql } from "drizzle-orm"
|
import { eq, and, sql } from "drizzle-orm"
|
||||||
|
|
||||||
export const savedGamesRoutes = new Elysia({ prefix: "/user/me/saved-games" })
|
export const savedGamesRoutes = new Elysia({ prefix: "/user/me/saved-games", detail: { tags: ["Games"] } })
|
||||||
.post(
|
.post(
|
||||||
"/",
|
"/",
|
||||||
async ({ request, body, set }) => {
|
async ({ request, body, set }) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
|
|||||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
|
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
|
||||||
|
|
||||||
export const screenshotRoutes = new Elysia({ prefix: "/performance" })
|
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||||
|
|
||||||
// ── Upload screenshots ─────────────────────────────────────────
|
// ── Upload screenshots ─────────────────────────────────────────
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface SteamSearchResponse {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
export const searchUnifiedRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } }).get(
|
||||||
"/unified",
|
"/unified",
|
||||||
async ({ query, set }) => {
|
async ({ query, set }) => {
|
||||||
if (!query.q || query.q.length < 2) {
|
if (!query.q || query.q.length < 2) {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface SteamReviewResponse {
|
|||||||
reviews: SteamReview[]
|
reviews: SteamReview[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const steamReviewRoutes = new Elysia({ prefix: "/steam-reviews" })
|
export const steamReviewRoutes = new Elysia({ prefix: "/steam-reviews", detail: { tags: ["Steam"] } })
|
||||||
|
|
||||||
// Get embedded Steam reviews for a game
|
// Get embedded Steam reviews for a game
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface SteamSearchItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const steamSearchRoutes = new Elysia({ prefix: "/search" })
|
export const steamSearchRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } })
|
||||||
.get(
|
.get(
|
||||||
"/steam",
|
"/steam",
|
||||||
async ({ query, set }) => {
|
async ({ query, set }) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Elysia, t } from "elysia"
|
|||||||
|
|
||||||
const STEAMGRIDDB_BASE = "https://www.steamgriddb.com/api/v2"
|
const STEAMGRIDDB_BASE = "https://www.steamgriddb.com/api/v2"
|
||||||
|
|
||||||
export const steamgridProxyRoutes = new Elysia({ prefix: "/steamgrid" })
|
export const steamgridProxyRoutes = new Elysia({ prefix: "/steamgrid", detail: { tags: ["Steam"] } })
|
||||||
.get(
|
.get(
|
||||||
"/search",
|
"/search",
|
||||||
async ({ query, set }) => {
|
async ({ query, set }) => {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import { user, performanceEntries, games, gameVersions, hardware, account, passk
|
|||||||
import { eq, sql, and, desc } from "drizzle-orm"
|
import { eq, sql, and, desc } from "drizzle-orm"
|
||||||
import { hashPassword } from "better-auth/crypto"
|
import { hashPassword } from "better-auth/crypto"
|
||||||
|
|
||||||
export const userRoutes = new Elysia({ prefix: "/user" })
|
export const userRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
|
||||||
.get(
|
.get(
|
||||||
"/profile/:id",
|
"/profile/:id",
|
||||||
async ({ params, set }) => {
|
async ({ params, set }) => {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ const nextConfig: NextConfig = {
|
|||||||
NEXT_PUBLIC_APP_VERSION: version,
|
NEXT_PUBLIC_APP_VERSION: version,
|
||||||
},
|
},
|
||||||
// Elysia is Bun-native and must not be bundled by Next.js
|
// Elysia is Bun-native and must not be bundled by Next.js
|
||||||
serverExternalPackages: ["elysia", "@elysiajs/eden"],
|
serverExternalPackages: ["elysia", "@elysiajs/eden", "@elysia/openapi", "@elysia/cron"],
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"@aws-sdk/client-s3": "^3.1036.0",
|
"@aws-sdk/client-s3": "^3.1036.0",
|
||||||
"@better-auth/drizzle-adapter": "^1.6.9",
|
"@better-auth/drizzle-adapter": "^1.6.9",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/passkey": "^1.6.9",
|
||||||
|
"@elysia/cron": "^1.4.2",
|
||||||
"@elysia/eden": "^1.4.10",
|
"@elysia/eden": "^1.4.10",
|
||||||
|
"@elysia/openapi": "^1.4.15",
|
||||||
"@serwist/expiration": "^9.5.11",
|
"@serwist/expiration": "^9.5.11",
|
||||||
"@serwist/next": "^9.5.11",
|
"@serwist/next": "^9.5.11",
|
||||||
"@serwist/precaching": "^9.5.11",
|
"@serwist/precaching": "^9.5.11",
|
||||||
|
|||||||
Reference in New Issue
Block a user