refactor: remove structured settings tables, use freeform JSON for presets and entries

This commit is contained in:
2026-04-25 18:51:10 +08:00
parent bc6fc493f6
commit c3a85d1336
11 changed files with 42 additions and 440 deletions
-5
View File
@@ -3,11 +3,6 @@ export { userRoutes } from "./user"
export { gamesRoutes, gameVersionsRoutes } from "./games"
export { hardwareRoutes } from "./hardware"
export { performanceRoutes, performanceVerifyRoutes } from "./performance"
export {
settingCategoriesRoutes,
settingDefinitionsRoutes,
settingsFullRoutes,
} from "./settings"
export {
presetsRoutes,
presetUpvoteRoutes,
+33 -47
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia"
import { createCrudRoutes } from "./crud-builder"
import { communityPresets, presetSettings, settingDefinitions } from "@/lib/db/schema"
import { communityPresets } from "@/lib/db/schema"
import { db } from "@/lib/db/index"
import { eq, sql } from "drizzle-orm"
import { requireRole } from "@/lib/auth/guard"
@@ -57,40 +57,33 @@ export const presetUpvoteRoutes = new Elysia({ prefix: "/presets" }).post(
)
// ── Preset Settings (nested under /presets/:presetId/settings) ────
// Settings are stored as freeform JSON on the preset itself.
export const presetSettingsRoutes = new Elysia({
prefix: "/presets/:presetId/settings",
})
// LIST all settings for a preset (with definition details)
// GET the settings JSON for a preset
.get(
"/",
async ({ params }) => {
const data = await db
.select({
id: presetSettings.id,
presetId: presetSettings.presetId,
settingDefinitionId: presetSettings.settingDefinitionId,
value: presetSettings.value,
settingName: settingDefinitions.name,
settingSlug: settingDefinitions.slug,
inputType: settingDefinitions.inputType,
options: settingDefinitions.options,
impactLevel: settingDefinitions.impactLevel,
})
.from(presetSettings)
.innerJoin(
settingDefinitions,
eq(presetSettings.settingDefinitionId, settingDefinitions.id),
)
.where(eq(presetSettings.presetId, params.presetId))
async ({ params, set }) => {
const [preset] = await db
.select({ settingsJson: communityPresets.settingsJson })
.from(communityPresets)
.where(eq(communityPresets.id, params.presetId))
.limit(1)
return data
if (!preset) {
set.status = 404
return { error: "Preset not found" }
}
return preset.settingsJson ?? []
},
{
params: t.Object({ presetId: t.String() }),
},
)
// UPSERT settings for a preset (bulk create/update)
.post(
// PUT (replace) the settings JSON for a preset
.put(
"/",
async ({ params, body, request, set }) => {
const guard = await requireRole(request.headers, [
@@ -120,36 +113,29 @@ export const presetSettingsRoutes = new Elysia({
return { error: "Not authorized to modify this preset" }
}
// Delete existing settings and re-insert
await db
.delete(presetSettings)
.where(eq(presetSettings.presetId, params.presetId))
const [updated] = await db
.update(communityPresets)
.set({
settingsJson: body.settings,
updatedAt: new Date(),
})
.where(eq(communityPresets.id, params.presetId))
.returning()
if (body.settings.length > 0) {
await db.insert(presetSettings).values(
body.settings.map((s: { settingDefinitionId: string; value: boolean | string | number }) => ({
presetId: params.presetId,
settingDefinitionId: s.settingDefinitionId,
value: s.value,
})),
)
}
// Return the updated settings
const data = await db
.select()
.from(presetSettings)
.where(eq(presetSettings.presetId, params.presetId))
return data
return updated
},
{
params: t.Object({ presetId: t.String() }),
body: t.Object({
settings: t.Array(
t.Object({
settingDefinitionId: t.String(),
value: t.Union([t.Boolean(), t.String(), t.Number()]),
category: t.String(),
settings: t.Array(
t.Object({
title: t.String(),
value: t.Union([t.String(), t.Number(), t.Boolean()]),
}),
),
}),
),
}),
-47
View File
@@ -1,47 +0,0 @@
import { Elysia } from "elysia"
import { createCrudRoutes } from "./crud-builder"
import { settingCategories, settingDefinitions } from "@/lib/db/schema"
import { db } from "@/lib/db/index"
import { asc } from "drizzle-orm"
// ── Setting Categories CRUD ───────────────────────────────────────
export const settingCategoriesRoutes = createCrudRoutes(settingCategories, {
prefix: "/settings/categories",
name: "Setting Category",
auth: { read: "public", write: "admin", delete: "admin" },
})
// ── Setting Definitions CRUD ──────────────────────────────────────
export const settingDefinitionsRoutes = createCrudRoutes(settingDefinitions, {
prefix: "/settings/definitions",
name: "Setting Definition",
auth: { read: "public", write: "admin", delete: "admin" },
filter: { fields: ["categoryId", "inputType", "impactLevel"] },
})
// ── Combined endpoint: categories with their definitions ──────────
// This is the main endpoint the frontend uses to render dynamic forms.
export const settingsFullRoutes = new Elysia({
prefix: "/settings/full",
}).get(
"/",
async () => {
const categories = await db
.select()
.from(settingCategories)
.orderBy(asc(settingCategories.sortOrder))
const definitions = await db
.select()
.from(settingDefinitions)
.orderBy(asc(settingDefinitions.sortOrder))
// Group definitions by category
const result = categories.map((cat) => ({
...cat,
definitions: definitions.filter((d) => d.categoryId === cat.id),
}))
return result
},
)
+8
View File
@@ -1,5 +1,6 @@
import {
integer,
jsonb,
pgTable,
text,
timestamp,
@@ -27,6 +28,13 @@ export const communityPresets = pgTable(
onDelete: "set null",
}),
upvotes: integer("upvotes").default(0).notNull(),
// Freeform settings JSON — same flexible structure as performanceEntries.settingsJson
settingsJson: jsonb("settings_json").$type<
{
category: string
settings: { title: string; value: string | number | boolean }[]
}[]
>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
-3
View File
@@ -5,8 +5,5 @@ export * from "./gameVersions"
export * from "./hardware"
export * from "./performanceEntries"
export * from "./gamePlatformSupport"
export * from "./settingCategories"
export * from "./settingDefinitions"
export * from "./communityPresets"
export * from "./presetSettings"
export * from "./gameComments"
-25
View File
@@ -1,25 +0,0 @@
import { jsonb, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"
import { communityPresets } from "./communityPresets"
import { settingDefinitions } from "./settingDefinitions"
export const presetSettings = pgTable(
"preset_settings",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
presetId: text("preset_id")
.notNull()
.references(() => communityPresets.id, { onDelete: "cascade" }),
settingDefinitionId: text("setting_definition_id")
.notNull()
.references(() => settingDefinitions.id, { onDelete: "cascade" }),
// Value stored as JSON — boolean for toggle, string for select,
// number for range/number
value: jsonb("value").notNull().$type<boolean | string | number>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
unique("preset_setting_unique").on(table.presetId, table.settingDefinitionId),
],
)
-11
View File
@@ -1,11 +0,0 @@
import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"
export const settingCategories = pgTable("setting_categories", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
sortOrder: integer("sort_order").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
})
-46
View File
@@ -1,46 +0,0 @@
import {
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core"
import { settingCategories } from "./settingCategories"
export const settingInputTypeEnum = pgEnum("setting_input_type", [
"toggle",
"select",
"range",
"number",
])
export const impactLevelEnum = pgEnum("impact_level", [
"minor",
"moderate",
"major",
])
export const settingDefinitions = pgTable(
"setting_definitions",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
categoryId: text("category_id")
.notNull()
.references(() => settingCategories.id, { onDelete: "cascade" }),
name: text("name").notNull(),
slug: text("slug").notNull(),
inputType: settingInputTypeEnum("input_type").notNull(),
// For `select`: string[] of option labels
// For `range`: { min: number, max: number, step: number }
// For `toggle`/`number`: null
options: jsonb("options").$type<string[] | { min: number; max: number; step: number }>(),
impactLevel: impactLevelEnum("impact_level").default("minor").notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [unique("setting_category_slug_unique").on(table.categoryId, table.slug)],
)
-210
View File
@@ -1,10 +1,7 @@
import "dotenv/config"
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import { eq } from "drizzle-orm"
import { hardware } from "./schema/hardware"
import { settingCategories } from "./schema/settingCategories"
import { settingDefinitions } from "./schema/settingDefinitions"
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
@@ -45,213 +42,6 @@ async function seed() {
}
console.log(`Seeded ${devices.length} hardware devices.`)
// ── Setting Categories ────────────────────────────────────────────
console.log("Seeding setting categories...")
const categories = [
{ name: "Graphics", slug: "graphics", sortOrder: 0 },
{ name: "Display", slug: "display", sortOrder: 1 },
{ name: "Audio", slug: "audio", sortOrder: 2 },
{ name: "Controls", slug: "controls", sortOrder: 3 },
{ name: "Gameplay", slug: "gameplay", sortOrder: 4 },
]
const insertedCategories: Record<string, string> = {} // slug → id
for (const cat of categories) {
const [inserted] = await db
.insert(settingCategories)
.values(cat)
.onConflictDoNothing({ target: settingCategories.slug })
.returning()
if (inserted) {
insertedCategories[cat.slug] = inserted.id
} else {
// Fetch existing
const existing = await db
.select()
.from(settingCategories)
.where(eq(settingCategories.slug, cat.slug))
.limit(1)
if (existing[0]) {
insertedCategories[cat.slug] = existing[0].id
}
}
}
console.log(`Seeded ${categories.length} setting categories.`)
// ── Setting Definitions ──────────────────────────────────────────
console.log("Seeding setting definitions...")
const definitions = [
// Graphics
{
categorySlug: "graphics",
name: "Resolution",
slug: "resolution",
inputType: "select" as const,
options: ["720p", "800p", "1080p", "1200p", "1440p", "4K"],
impactLevel: "major" as const,
sortOrder: 0,
},
{
categorySlug: "graphics",
name: "Texture Quality",
slug: "texture-quality",
inputType: "select" as const,
options: ["Low", "Medium", "High", "Ultra"],
impactLevel: "major" as const,
sortOrder: 1,
},
{
categorySlug: "graphics",
name: "Shadow Quality",
slug: "shadow-quality",
inputType: "select" as const,
options: ["Off", "Low", "Medium", "High", "Ultra"],
impactLevel: "major" as const,
sortOrder: 2,
},
{
categorySlug: "graphics",
name: "Anti-Aliasing",
slug: "anti-aliasing",
inputType: "select" as const,
options: ["Off", "FXAA", "TAA", "MSAA 2x", "MSAA 4x"],
impactLevel: "moderate" as const,
sortOrder: 3,
},
{
categorySlug: "graphics",
name: "Volumetric Fog",
slug: "volumetric-fog",
inputType: "toggle" as const,
options: null,
impactLevel: "moderate" as const,
sortOrder: 4,
},
{
categorySlug: "graphics",
name: "Motion Blur",
slug: "motion-blur",
inputType: "toggle" as const,
options: null,
impactLevel: "minor" as const,
sortOrder: 5,
},
// Display
{
categorySlug: "display",
name: "Refresh Rate",
slug: "refresh-rate",
inputType: "select" as const,
options: ["30Hz", "40Hz", "60Hz", "90Hz", "120Hz"],
impactLevel: "moderate" as const,
sortOrder: 0,
},
{
categorySlug: "display",
name: "V-Sync",
slug: "v-sync",
inputType: "toggle" as const,
options: null,
impactLevel: "moderate" as const,
sortOrder: 1,
},
{
categorySlug: "display",
name: "Frame Rate Limit",
slug: "frame-rate-limit",
inputType: "range" as const,
options: { min: 15, max: 120, step: 5 },
impactLevel: "minor" as const,
sortOrder: 2,
},
// Audio
{
categorySlug: "audio",
name: "Master Volume",
slug: "master-volume",
inputType: "range" as const,
options: { min: 0, max: 100, step: 5 },
impactLevel: "minor" as const,
sortOrder: 0,
},
{
categorySlug: "audio",
name: "SFX Volume",
slug: "sfx-volume",
inputType: "range" as const,
options: { min: 0, max: 100, step: 5 },
impactLevel: "minor" as const,
sortOrder: 1,
},
{
categorySlug: "audio",
name: "Music Volume",
slug: "music-volume",
inputType: "range" as const,
options: { min: 0, max: 100, step: 5 },
impactLevel: "minor" as const,
sortOrder: 2,
},
// Controls
{
categorySlug: "controls",
name: "Controller Vibration",
slug: "controller-vibration",
inputType: "toggle" as const,
options: null,
impactLevel: "minor" as const,
sortOrder: 0,
},
{
categorySlug: "controls",
name: "Aim Sensitivity",
slug: "aim-sensitivity",
inputType: "range" as const,
options: { min: 1, max: 10, step: 1 },
impactLevel: "minor" as const,
sortOrder: 1,
},
// Gameplay
{
categorySlug: "gameplay",
name: "Difficulty",
slug: "difficulty",
inputType: "select" as const,
options: ["Easy", "Normal", "Hard", "Extreme"],
impactLevel: "minor" as const,
sortOrder: 0,
},
]
let definitionCount = 0
for (const def of definitions) {
const categoryId = insertedCategories[def.categorySlug]
if (!categoryId) continue
await db
.insert(settingDefinitions)
.values({
categoryId,
name: def.name,
slug: def.slug,
inputType: def.inputType,
options: def.options,
impactLevel: def.impactLevel,
sortOrder: def.sortOrder,
})
.onConflictDoNothing({ target: settingDefinitions.slug })
definitionCount++
}
console.log(`Seeded ${definitionCount} setting definitions.`)
await pool.end()
}