refactor: replace Prisma ORM runtime with Drizzle ORM facade
- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
This commit is contained in:
1 parent
9d1c71d926
commit
56061e41d4
52 files changed
+5825
-748
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# EpicNext-CMS v1.0.1
|
||||
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
|
||||
@@ -37,7 +37,9 @@ The CMS shares a database with the Polaris/Arcturus emulator. Use an existing da
|
||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them.
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
||||
|
||||
> **Note:** The CMS does **not** own the database schema — it maps to tables that are managed by the emulator. All Drizzle schema definitions use `drizzle-orm`'s runtime mapping (no `drizzle-kit push/migrate` is ever run against the emulator schema). CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL files in `prisma/migrations/`.
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
@@ -56,13 +58,21 @@ APP_URL=http://localhost:3000
|
||||
|
||||
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. Generate Prisma Client
|
||||
### 4. Type Generation (Optional for Development)
|
||||
|
||||
```bash
|
||||
pnpm prisma:generate
|
||||
```
|
||||
|
||||
Generates TypeScript types in `src/generated/prisma/`.
|
||||
Generates TypeScript types in `src/generated/prisma/`. This is only needed for developer type-checking of the `prisma` facade — it does not add any Prisma runtime overhead in production. The facade (`src/lib/prisma-facade.ts`) routes all queries through Drizzle ORM at runtime.
|
||||
|
||||
Alternatively, you can work with Drizzle directly via the typed schema in `src/db/schema.ts`:
|
||||
|
||||
```ts
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/db/schema";
|
||||
const result = await db.select().from(users).where(eq(users.username, "hello"));
|
||||
```
|
||||
|
||||
### 5. Run CMS Migrations
|
||||
|
||||
@@ -161,20 +171,21 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
|
||||
```
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only
|
||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
||||
├── scripts/
|
||||
│ ├── apply-migrations.ts # Custom migration runner
|
||||
│ ├── jobs-worker.ts # Background task scheduler
|
||||
│ └── sql-statements.ts # SQL parsing utilities
|
||||
├── src/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
|
||||
│ │ └── migrations/ # Drizzle migration files (if used)
|
||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── components/ # UI components
|
||||
│ ├── lib/
|
||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||
│ │ ├── prisma.ts # Database connection singleton
|
||||
│ │ ├── prisma.ts # Prisma-compatible facade (routes to Drizzle at runtime)
|
||||
│ │ ├── prisma-facade.ts # Drizzle-backed implementation of Prisma API surface
|
||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||
│ │ ├── redis.ts # Redis client (ioredis)
|
||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||
│ │ ├── cache.ts # In-memory cache fallback
|
||||
@@ -198,6 +209,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||
|
||||
### ORM Architecture
|
||||
|
||||
The CMS uses a dual-layer approach:
|
||||
|
||||
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton connected to the MySQL/MariaDB database. All new code should use this directly. The schema is defined in `src/db/schema.ts` with 176 tables typed against the existing database columns.
|
||||
- **Legacy Compatibility (Prisma Facade)**: `@/lib/prisma` provides a Prisma-compatible API surface backed by Drizzle. This allows existing code to continue working without refactoring. The facade (`@/lib/prisma-facade.ts`) implements the Prisma client API (`findMany`, `findUnique`, `create`, `$transaction`, `$queryRaw`, etc.) but routes all queries through Drizzle at runtime — **no Prisma client engine or query engine is loaded in production**.
|
||||
- **Type Generation**: `src/generated/prisma/` (regenerated via `pnpm prisma:generate`) exists solely for TypeScript type-checking. It is `gitignore`d and is never bundled in the production build.
|
||||
|
||||
Migration path: new database access should use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`. The facade is maintained for backwards compatibility but is not recommended for new code.
|
||||
|
||||
---
|
||||
|
||||
## Optional Integrations
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// The schema is generated by scripts/generate-drizzle-schema.mjs from
|
||||
// prisma/schema.prisma + live DB introspection. This DB is shared live with the
|
||||
// Arcturus emulator, so we NEVER run `drizzle-kit migrate`/`push` against it —
|
||||
// CMS-only schema changes stay in prisma/migrations/*.sql via `pnpm db:migrate`.
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
+3
-2
@@ -37,14 +37,13 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"croner": "^10.0.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"isomorphic-dompurify": "^3.20.0",
|
||||
@@ -76,6 +75,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
@@ -85,6 +85,7 @@
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^6.30.0",
|
||||
"lint-staged": "^17.3.0",
|
||||
|
||||
Generated
+640
-53
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,576 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/db/schema.ts from:
|
||||
// 1. prisma/schema.prisma -> TS field names (camelCase) + @map column names + table names
|
||||
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
|
||||
//
|
||||
// The DB is owned by the Arcturus emulator; we never run drizzle-kit migrate/push.
|
||||
// This schema is only used for the query builder + TypeScript types.
|
||||
//
|
||||
// Usage: pnpm db:schema:generate
|
||||
import "dotenv/config";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MYSQL2_UNSUPPORTED_OPTIONS = [
|
||||
"connection_limit",
|
||||
"pool_timeout",
|
||||
"connect_timeout",
|
||||
];
|
||||
|
||||
function mysqlConnectionUrl(value) {
|
||||
const url = new URL(value);
|
||||
for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
|
||||
url.searchParams.delete(option);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SCHEMA_PRISMA = resolve(ROOT, "prisma/schema.prisma");
|
||||
const OUT = resolve(ROOT, "src/db/schema.ts");
|
||||
|
||||
const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8");
|
||||
|
||||
// ---------- Parse prisma/schema.prisma ----------
|
||||
const modelBlocks = [
|
||||
...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm),
|
||||
];
|
||||
const modelNames = new Set(modelBlocks.map((m) => m[1]));
|
||||
|
||||
/** parse a model block -> { name, table, fields, ids, uniques, maps } */
|
||||
function parseModel(block) {
|
||||
const [full, name, body] = block;
|
||||
const table =
|
||||
body.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? name.toLowerCase();
|
||||
|
||||
const fields = [];
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("@@") ||
|
||||
trimmed.startsWith("///")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const m = trimmed.match(/^(\w+)\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
const [fieldName, rest] = [m[1], m[2]];
|
||||
const typeMatch = rest.match(/^([^\s]+)/);
|
||||
const prismaType = typeMatch[1];
|
||||
const baseType = prismaType.replace(/\?$/, "").replace(/\[\]$/, "");
|
||||
const isList = prismaType.endsWith("[]");
|
||||
// relation field (points at another model) -> skip
|
||||
if (modelNames.has(baseType)) continue;
|
||||
|
||||
const attrs = rest;
|
||||
const optional = prismaType.endsWith("?");
|
||||
const map = attrs.match(/@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? fieldName;
|
||||
const isId = /@id\b/.test(attrs);
|
||||
const isUnique = /@unique\b/.test(attrs);
|
||||
const autoIncrement = /@default\(autoincrement\(\)\)/.test(attrs);
|
||||
const updatedAt = /@updatedAt\b/.test(attrs);
|
||||
const dbHint = attrs.match(/@db\.(\w+)(?:\((\d+)(?:\s*,\s*(\d+))?\))?/);
|
||||
fields.push({
|
||||
fieldName,
|
||||
column: map,
|
||||
prismaType: baseType,
|
||||
optional,
|
||||
isList,
|
||||
isId,
|
||||
isUnique,
|
||||
autoIncrement,
|
||||
updatedAt,
|
||||
attrs,
|
||||
dbHint: dbHint
|
||||
? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// model-level keys
|
||||
const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1];
|
||||
const ids = compIds
|
||||
? compIds.split(",").map((s) => s.trim().replace(/`/g, ""))
|
||||
: null;
|
||||
|
||||
const uniques = [];
|
||||
for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) {
|
||||
uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, "")));
|
||||
}
|
||||
|
||||
return { name, table, fields, ids, uniques };
|
||||
}
|
||||
|
||||
const models = modelBlocks.map(parseModel);
|
||||
|
||||
// ---------- Introspect live MySQL ----------
|
||||
let url;
|
||||
try {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL is required");
|
||||
url = mysqlConnectionUrl(raw);
|
||||
} catch (err) {
|
||||
console.error("[schema-gen] No DATABASE_URL:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
|
||||
const [cols] = await conn.query(
|
||||
`SELECT table_name, column_name, data_type, column_type, is_nullable,
|
||||
column_key, column_default, extra
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()`,
|
||||
);
|
||||
await conn.end();
|
||||
|
||||
const colByTable = new Map();
|
||||
for (const c of cols) {
|
||||
const key = `${c.table_name}.${c.column_name}`;
|
||||
colByTable.set(key, c);
|
||||
}
|
||||
|
||||
// ---------- Build drizzle column builders ----------
|
||||
const used = new Set(["mysqlTable", "primaryKey", "uniqueIndex", "customType"]);
|
||||
let usedSql = false;
|
||||
|
||||
function use(name) {
|
||||
used.add(name);
|
||||
}
|
||||
|
||||
function markSqlUsed() {
|
||||
usedSql = true;
|
||||
}
|
||||
|
||||
function quote(v) {
|
||||
return `"${String(v).replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Map a DB column row to a drizzle column expression string. */
|
||||
function columnExpr(field, dbCol) {
|
||||
const col = field.column;
|
||||
let expr = "";
|
||||
let type = "";
|
||||
|
||||
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
||||
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
||||
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
||||
|
||||
// Prisma enum types -> mysqlEnum
|
||||
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
||||
use("mysqlEnum");
|
||||
const values = enumMatch
|
||||
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
: field.prismaType === "users_gender"
|
||||
? ["M", "F"]
|
||||
: ["account", "ip", "machine", "super"];
|
||||
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
||||
}
|
||||
|
||||
switch (dbCol?.data_type) {
|
||||
case "int":
|
||||
use("int");
|
||||
type = unsigned
|
||||
? `int(${quote(col)}, { unsigned: true })`
|
||||
: `int(${quote(col)})`;
|
||||
break;
|
||||
case "tinyint": {
|
||||
const isBool =
|
||||
dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean";
|
||||
if (isBool) {
|
||||
use("boolean");
|
||||
type = `boolean(${quote(col)})`;
|
||||
} else {
|
||||
use("tinyint");
|
||||
type = unsigned
|
||||
? `tinyint(${quote(col)}, { unsigned: true })`
|
||||
: `tinyint(${quote(col)})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "smallint":
|
||||
use("smallint");
|
||||
type = unsigned
|
||||
? `smallint(${quote(col)}, { unsigned: true })`
|
||||
: `smallint(${quote(col)})`;
|
||||
break;
|
||||
case "mediumint":
|
||||
use("mediumint");
|
||||
type = unsigned
|
||||
? `mediumint(${quote(col)}, { unsigned: true })`
|
||||
: `mediumint(${quote(col)})`;
|
||||
break;
|
||||
case "bigint":
|
||||
use("bigint");
|
||||
type = `bigint(${quote(col)}, { mode: "bigint", unsigned: ${unsigned} })`;
|
||||
break;
|
||||
case "varchar":
|
||||
case "enum": {
|
||||
// DB enums become plain varchar in the schema: TS contract is `string`
|
||||
// (only users_gender / bans_type are typed as mysqlEnum above).
|
||||
use("varchar");
|
||||
const maxLen = enumMatch
|
||||
? Math.max(
|
||||
...[...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].length),
|
||||
1,
|
||||
)
|
||||
: Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 255);
|
||||
type = `varchar(${quote(col)}, { length: ${maxLen} })`;
|
||||
break;
|
||||
}
|
||||
case "char":
|
||||
use("char");
|
||||
type = `char(${quote(col)}, { length: ${Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 8)} })`;
|
||||
break;
|
||||
case "text":
|
||||
use("text");
|
||||
type = `text(${quote(col)})`;
|
||||
break;
|
||||
case "mediumtext":
|
||||
use("mediumtext");
|
||||
type = `mediumtext(${quote(col)})`;
|
||||
break;
|
||||
case "longtext":
|
||||
use("longtext");
|
||||
type = `longtext(${quote(col)})`;
|
||||
break;
|
||||
case "datetime": {
|
||||
use("datetime");
|
||||
const fsp = dbCol.column_type.match(/datetime\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `datetime(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `datetime(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "timestamp": {
|
||||
use("timestamp");
|
||||
const fsp = dbCol.column_type.match(/timestamp\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `timestamp(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `timestamp(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "double":
|
||||
use("double");
|
||||
type = `double(${quote(col)})`;
|
||||
break;
|
||||
case "float":
|
||||
use("float");
|
||||
type = `float(${quote(col)})`;
|
||||
break;
|
||||
case "decimal":
|
||||
use("decimal");
|
||||
type = `decimal(${quote(col)}, { precision: ${Number(decimalMatch?.[1] ?? 10)}, scale: ${Number(decimalMatch?.[2] ?? 0)}, mode: "number" })`;
|
||||
break;
|
||||
case "json":
|
||||
use("json");
|
||||
type = `json(${quote(col)})`;
|
||||
break;
|
||||
case "date":
|
||||
type = `dateAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "time":
|
||||
type = `timeAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "blob":
|
||||
case "tinyblob":
|
||||
case "mediumblob":
|
||||
case "longblob":
|
||||
use("binary");
|
||||
type = `binary(${quote(col)})`;
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col} (prisma:${field.prismaType})`,
|
||||
);
|
||||
use("varchar");
|
||||
type = `varchar(${quote(col)}, { length: 255 })`;
|
||||
}
|
||||
expr += type;
|
||||
|
||||
return expr;
|
||||
}
|
||||
|
||||
function modifiers(field, dbCol) {
|
||||
let expr = "";
|
||||
if (dbCol?.extra?.includes("auto_increment") || field.autoIncrement) {
|
||||
expr += `.autoincrement()`;
|
||||
}
|
||||
if (field.isId) {
|
||||
expr += `.primaryKey()`;
|
||||
} else if (dbCol?.column_key === "UNI" && !field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
} else if (field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
}
|
||||
// Mirror the Prisma TS contract: required fields (no `?`) are not-null on
|
||||
// select, and fields with `@default` are optional on insert.
|
||||
if (!field.optional) {
|
||||
expr += `.notNull()`;
|
||||
}
|
||||
expr += parseDefault(field, dbCol);
|
||||
return expr;
|
||||
}
|
||||
|
||||
/** Extract `@default(...)` and emit a drizzle `.default(...)` (or ""). */
|
||||
function parseDefault(field, dbCol) {
|
||||
const m = field.attrs.match(/@default\(/);
|
||||
if (!m) return "";
|
||||
const start = m.index + "@default(".length;
|
||||
let depth = 0;
|
||||
let content = "";
|
||||
for (let i = start; i < field.attrs.length; i++) {
|
||||
const ch = field.attrs[i];
|
||||
if (ch === "(") {
|
||||
depth++;
|
||||
} else if (ch === ")") {
|
||||
if (depth === 0) {
|
||||
content = field.attrs.slice(start, i);
|
||||
break;
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
if (!content) return "";
|
||||
if (content === "now()") {
|
||||
markSqlUsed();
|
||||
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
||||
}
|
||||
if (content === "autoincrement()" || content.startsWith("dbgenerated("))
|
||||
return "";
|
||||
if (content === "true" || content === "false") return `.default(${content})`;
|
||||
if (/^-?\d+$/.test(content)) {
|
||||
// integer literal; bigint64 data type is `bigint`, others are `number`
|
||||
return dbCol?.data_type === "bigint"
|
||||
? `.default(${content}n)`
|
||||
: `.default(${content})`;
|
||||
}
|
||||
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
||||
// quoted string or bare enum/string identifier (e.g. @default(M))
|
||||
const s =
|
||||
content.startsWith('"') && content.endsWith('"')
|
||||
? content.slice(1, -1)
|
||||
: content;
|
||||
return `.default(${JSON.stringify(s)})`;
|
||||
}
|
||||
|
||||
function modelTable(model) {
|
||||
const rows = [];
|
||||
for (const f of model.fields) {
|
||||
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
||||
if (!dbCol) {
|
||||
// Column not found in live DB. Prisma schema may be ahead of the DB.
|
||||
// Fall back to a best-effort type from the Prisma @db hint / base type.
|
||||
const fallback = fallbackColumn(f) + modifiers(f, null);
|
||||
rows.push(`\t${f.fieldName}: ${fallback},`);
|
||||
console.warn(
|
||||
`[schema-gen] WARN ${model.table}.${f.column} (${f.fieldName}) missing in DB, used fallback`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rows.push(
|
||||
`\t${f.fieldName}: ${columnExpr(f, dbCol)}${modifiers(f, dbCol)},`,
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueRows = [];
|
||||
if (model.ids) {
|
||||
const idCols = model.ids
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (idCols.length)
|
||||
uniqueRows.push(`\tprimaryKey({ columns: [${idCols.join(", ")}] }),`);
|
||||
}
|
||||
for (const u of model.uniques) {
|
||||
const cols = u
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (cols.length)
|
||||
uniqueRows.push(
|
||||
`\tuniqueIndex("${model.table}_${u.join("_")}").on(${cols.join(", ")}),`,
|
||||
);
|
||||
}
|
||||
|
||||
if (uniqueRows.length) {
|
||||
return `export const ${model.name} = mysqlTable(
|
||||
"${model.table}",
|
||||
{
|
||||
${rows.join("\n")}
|
||||
},
|
||||
(t) => [
|
||||
${uniqueRows.join("\n")}
|
||||
],
|
||||
);`;
|
||||
}
|
||||
return `export const ${model.name} = mysqlTable(
|
||||
"${model.table}",
|
||||
{
|
||||
${rows.join("\n")}
|
||||
},
|
||||
);`;
|
||||
}
|
||||
|
||||
function fallbackColumn(field) {
|
||||
const hint = field.dbHint;
|
||||
const name = field.column;
|
||||
switch (hint?.type) {
|
||||
case "VarChar":
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: ${Number(hint.param1 ?? 191)} })`;
|
||||
case "Char":
|
||||
use("char");
|
||||
return `char(${quote(name)}, { length: ${Number(hint.param1 ?? 8)} })`;
|
||||
case "Text":
|
||||
use("text");
|
||||
return `text(${quote(name)})`;
|
||||
case "MediumText":
|
||||
use("mediumtext");
|
||||
return `mediumtext(${quote(name)})`;
|
||||
case "LongText":
|
||||
use("longtext");
|
||||
return `longtext(${quote(name)})`;
|
||||
case "Decimal":
|
||||
use("decimal");
|
||||
return `decimal(${quote(name)}, { precision: ${Number(hint.param1 ?? 10)}, scale: ${Number(hint.param2 ?? 0)}, mode: "number" })`;
|
||||
case "UnsignedBigInt":
|
||||
use("bigint");
|
||||
return `bigint(${quote(name)}, { mode: "bigint", unsigned: true })`;
|
||||
case "UnsignedInt":
|
||||
use("int");
|
||||
return `int(${quote(name)}, { unsigned: true })`;
|
||||
case "Double":
|
||||
use("double");
|
||||
return `double(${quote(name)})`;
|
||||
case "Float":
|
||||
use("float");
|
||||
return `float(${quote(name)})`;
|
||||
case "Json":
|
||||
use("json");
|
||||
return `json(${quote(name)})`;
|
||||
case "Timestamp":
|
||||
use("timestamp");
|
||||
return `timestamp(${quote(name)})`;
|
||||
case "Time":
|
||||
return `timeAsDate(${quote(name)})`;
|
||||
case "Date":
|
||||
return `dateAsDate(${quote(name)})`;
|
||||
case "TinyInt":
|
||||
use("tinyint");
|
||||
return `tinyint(${quote(name)})`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (field.prismaType) {
|
||||
case "Int":
|
||||
use("int");
|
||||
return `int(${quote(name)})`;
|
||||
case "BigInt":
|
||||
use("bigint");
|
||||
return `bigint(${quote(name)}, { mode: "bigint" })`;
|
||||
case "Boolean":
|
||||
use("boolean");
|
||||
return `boolean(${quote(name)})`;
|
||||
case "DateTime":
|
||||
use("datetime");
|
||||
return `datetime(${quote(name)})`;
|
||||
case "String":
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
case "Float":
|
||||
use("double");
|
||||
return `double(${quote(name)})`;
|
||||
case "Decimal":
|
||||
use("decimal");
|
||||
return `decimal(${quote(name)}, { precision: 10, scale: 2, mode: "number" })`;
|
||||
case "Json":
|
||||
use("json");
|
||||
return `json(${quote(name)})`;
|
||||
case "Bytes":
|
||||
use("binary");
|
||||
return `binary(${quote(name)})`;
|
||||
default:
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Generate file ----------
|
||||
const body = models.map(modelTable).join("\n\n");
|
||||
|
||||
const IMPORTABLE = [
|
||||
"bigint",
|
||||
"binary",
|
||||
"boolean",
|
||||
"char",
|
||||
"customType",
|
||||
"date",
|
||||
"datetime",
|
||||
"decimal",
|
||||
"double",
|
||||
"float",
|
||||
"int",
|
||||
"json",
|
||||
"longtext",
|
||||
"mediumint",
|
||||
"mediumtext",
|
||||
"mysqlEnum",
|
||||
"mysqlTable",
|
||||
"primaryKey",
|
||||
"smallint",
|
||||
"text",
|
||||
"time",
|
||||
"timestamp",
|
||||
"tinyint",
|
||||
"uniqueIndex",
|
||||
"varchar",
|
||||
];
|
||||
const importList = IMPORTABLE.filter((b) => used.has(b));
|
||||
|
||||
const helpers = [
|
||||
"// MySQL TIME / DATE columns are hydrated by Prisma as JS Date (epoch 1970-01-01",
|
||||
"// for TIME). Mirror that so existing call sites keep working unchanged.",
|
||||
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "time";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(11, 19);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
" return new Date(`1970-01-01T${value}Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"const dateAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "date";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(0, 10);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
" return new Date(`${value}T00:00:00Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
||||
// TS field names mirror prisma/schema.prisma (camelCase); column names are the
|
||||
// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes.
|
||||
import {
|
||||
${importList.map((b) => `\t${b},`).join("\n")}
|
||||
} from "drizzle-orm/mysql-core";
|
||||
${usedSql ? `import { sql } from "drizzle-orm";\n` : ""}
|
||||
${helpers}${body}
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, out);
|
||||
console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`);
|
||||
@@ -1,14 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
type BanType = $Enums.bans_type;
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||
"account",
|
||||
"ip",
|
||||
@@ -45,7 +43,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as BanType,
|
||||
type: type as any,
|
||||
cfhTopic: -1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -58,7 +57,7 @@ export async function updateBcPage({
|
||||
}
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: data as Prisma.CatalogPagesBcUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
@@ -104,7 +103,7 @@ export async function updateBcItem({
|
||||
}
|
||||
await prisma.catalogItemsBc.update({
|
||||
where: { id },
|
||||
data: safe as Prisma.CatalogItemsBcUpdateInput,
|
||||
data: safe as any,
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -252,7 +252,7 @@ export async function moveCatalogItems({
|
||||
await prisma.$executeRaw`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}
|
||||
WHERE id IN (${Prisma.join(ids)})
|
||||
WHERE id IN (${sql.join(ids, sql`, `)})
|
||||
`;
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
@@ -301,7 +301,7 @@ export async function updateCatalogItem({
|
||||
if (Object.keys(safeCatalog).length > 0) {
|
||||
await prisma.catalogItems.update({
|
||||
where: { id },
|
||||
data: safeCatalog as Prisma.CatalogItemsUpdateInput,
|
||||
data: safeCatalog as any,
|
||||
});
|
||||
}
|
||||
if (baseItem) {
|
||||
@@ -309,7 +309,7 @@ export async function updateCatalogItem({
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: baseItem.id },
|
||||
data: safeBase as Prisma.ItemsBaseUpdateInput,
|
||||
data: safeBase as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -57,7 +56,7 @@ export async function updateCatalogPage({
|
||||
}
|
||||
await prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: data as Prisma.CatalogPagesUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -90,7 +89,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
// between the two operations cannot orphan the user.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
@@ -92,12 +91,12 @@ export const updateItemsBase = adminAction(
|
||||
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||
}
|
||||
if (data.stackHeight !== undefined) {
|
||||
data.stackHeight = new Prisma.Decimal(String(data.stackHeight));
|
||||
data.stackHeight = Number(data.stackHeight);
|
||||
}
|
||||
|
||||
await prisma.itemsBase.update({
|
||||
where: { id },
|
||||
data: data as Prisma.ItemsBaseUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await rcon.updateCatalog().catch(() => false);
|
||||
await logStaffActivity({
|
||||
|
||||
+74
-47
@@ -1,9 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AclModelPermission,
|
||||
AclModelRole,
|
||||
AclPermission,
|
||||
AclRole,
|
||||
db,
|
||||
User,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import {
|
||||
@@ -22,16 +31,15 @@ const createRankSchema = z.object({
|
||||
export const createRank = adminAction(
|
||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const id = await createEmulatorRank(prisma, ctx.data);
|
||||
await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${id}` },
|
||||
create: {
|
||||
const id = await createEmulatorRank(db, ctx.data);
|
||||
await db
|
||||
.insert(AclRole)
|
||||
.values({
|
||||
slug: `rank_${id}`,
|
||||
title: ctx.data.rank_name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
},
|
||||
update: { title: ctx.data.rank_name },
|
||||
});
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_create",
|
||||
@@ -50,21 +58,32 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||
const [userCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, ctx.data.id));
|
||||
const users = userCount?.total ?? 0;
|
||||
if (users > 0)
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
});
|
||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||
.limit(1);
|
||||
await deleteEmulatorRank(db, ctx.data.id);
|
||||
if (role) {
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({
|
||||
where: { modelId: role.id, modelType: "Role" },
|
||||
}),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
||||
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
@@ -87,12 +106,12 @@ const saveRankSchema = z.object({
|
||||
export const saveRank = adminAction(
|
||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
|
||||
if (typeof ctx.data.fields.rank_name === "string") {
|
||||
await prisma.aclRole.updateMany({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
data: { title: ctx.data.fields.rank_name },
|
||||
});
|
||||
await db
|
||||
.update(AclRole)
|
||||
.set({ title: ctx.data.fields.rank_name })
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
@@ -115,27 +134,33 @@ const setCmsPermsSchema = z.object({
|
||||
export const setCmsPermissions = adminAction(
|
||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const role = await prisma.aclRole.findUnique({
|
||||
where: { id: ctx.data.roleId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id, slug: AclRole.slug })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.id, ctx.data.roleId))
|
||||
.limit(1);
|
||||
if (!role) throw new ActionError("Role not found");
|
||||
const permissions = await prisma.aclPermission.findMany({
|
||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.aclModelPermission.deleteMany({
|
||||
where: { modelId: role.id, modelType: "Role" },
|
||||
});
|
||||
const permissions = await db
|
||||
.select({ id: AclPermission.id })
|
||||
.from(AclPermission)
|
||||
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
if (permissions.length) {
|
||||
await tx.aclModelPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
await tx.insert(AclModelPermission).values(
|
||||
permissions.map((permission) => ({
|
||||
modelId: role.id,
|
||||
modelType: "Role",
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
});
|
||||
);
|
||||
}
|
||||
});
|
||||
await logStaffActivity({
|
||||
@@ -159,7 +184,7 @@ export const setCmsPermissions = adminAction(
|
||||
export const repairAdminNavAclGrants = adminAction(
|
||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const dashboardFill = await prisma.$executeRaw`
|
||||
const [dashboardFillResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`acl_roles\` ar
|
||||
@@ -179,9 +204,9 @@ export const repairAdminNavAclGrants = adminAction(
|
||||
AND amp2.model_id = ar.id
|
||||
AND amp2.permission_id = ap.id
|
||||
)
|
||||
`;
|
||||
`);
|
||||
|
||||
const midRankViews = await prisma.$executeRaw`
|
||||
const [midRankViewsResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`permission_ranks\` pr
|
||||
@@ -198,9 +223,9 @@ export const repairAdminNavAclGrants = adminAction(
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
)
|
||||
`;
|
||||
`);
|
||||
|
||||
const highRankTools = await prisma.$executeRaw`
|
||||
const [highRankToolsResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`permission_ranks\` pr
|
||||
@@ -226,10 +251,12 @@ export const repairAdminNavAclGrants = adminAction(
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
)
|
||||
`;
|
||||
`);
|
||||
|
||||
const inserted =
|
||||
Number(dashboardFill) + Number(midRankViews) + Number(highRankTools);
|
||||
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
|
||||
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
|
||||
Number((highRankToolsResult as ResultSetHeader).affectedRows);
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -17,7 +16,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
};
|
||||
await prisma.items.update({
|
||||
where: { id: itemId },
|
||||
data: data as Prisma.ItemsUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -111,7 +110,7 @@ export async function updateRoom({
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await prisma.rooms.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoomsUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -116,7 +115,7 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
} else {
|
||||
const badgeCodes = parseBadgeCodes(article.badges);
|
||||
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (price > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -81,10 +80,14 @@ export const createUser = adminAction(
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
const target = (err.meta?.target as string[]) ?? [];
|
||||
const target = (
|
||||
(err as { meta?: { target?: string[] } }).meta?.target as string[]
|
||||
) ?? [];
|
||||
if (target.includes("username"))
|
||||
throw new ActionError("Username already taken");
|
||||
if (target.includes("mail"))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/lib/prisma";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { count, desc, eq, like } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { EventsTable } from "./events-table";
|
||||
|
||||
@@ -16,29 +18,44 @@ export default async function EventsPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.EVENTS_VIEW,
|
||||
model: "websiteEvent",
|
||||
searchFields: ["title"],
|
||||
defaultSort: { startsAt: "desc" },
|
||||
include: { type: { select: { name: true, color: true } } },
|
||||
mapRow: (e: {
|
||||
id: number;
|
||||
title: string;
|
||||
type: { name: string; color: string };
|
||||
startsAt: Date;
|
||||
endsAt: Date | null;
|
||||
status: string;
|
||||
hostUserId: number;
|
||||
_count?: { registrations: number };
|
||||
}) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
typeName: e.type.name,
|
||||
typeColor: e.type.color,
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status,
|
||||
hostUserId: e.hostUserId,
|
||||
}),
|
||||
fetch: async ({ search, page, perPage }) => {
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
status: WebsiteEvent.status,
|
||||
hostUserId: WebsiteEvent.hostUserId,
|
||||
typeName: WebsiteEventType.name,
|
||||
typeColor: WebsiteEventType.color,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.leftJoin(
|
||||
WebsiteEventType,
|
||||
eq(WebsiteEvent.typeId, WebsiteEventType.id),
|
||||
)
|
||||
.where(search ? like(WebsiteEvent.title, `%${search}%`) : undefined)
|
||||
.orderBy(desc(WebsiteEvent.startsAt))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(WebsiteEvent),
|
||||
]);
|
||||
return {
|
||||
rows: rows.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
typeName: e.typeName ?? "",
|
||||
typeColor: e.typeColor ?? "",
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status,
|
||||
hostUserId: e.hostUserId,
|
||||
})),
|
||||
total: totals[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
@@ -25,7 +24,7 @@ export default async function AdminHelpTicketsPage({
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || "open";
|
||||
|
||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (statusFilter === "open") {
|
||||
conditions.push({ open: true });
|
||||
@@ -56,7 +55,7 @@ export default async function AdminHelpTicketsPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
@@ -65,7 +64,7 @@ export default async function AdminHelpTicketsPage({
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
||||
any
|
||||
> = {
|
||||
title: { title: "asc" },
|
||||
open: { open: "desc" },
|
||||
@@ -79,7 +78,7 @@ export default async function AdminHelpTicketsPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { Package } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
||||
|
||||
@@ -15,32 +17,56 @@ export default async function ItemsBasePage({
|
||||
const result = await fetchAdminList<ItemsBaseRow>(
|
||||
{
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
model: "itemsBase",
|
||||
searchFields: ["itemName", "publicName"],
|
||||
defaultSort: { id: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
publicName: true,
|
||||
itemName: true,
|
||||
type: true,
|
||||
interactionType: true,
|
||||
width: true,
|
||||
length: true,
|
||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
const ors = [
|
||||
like(ItemsBase.itemName, `%${search}%`),
|
||||
like(ItemsBase.publicName, `%${search}%`),
|
||||
];
|
||||
const asId = Number(search);
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(ItemsBase.id, asId));
|
||||
}
|
||||
conditions.push(or(...ors));
|
||||
}
|
||||
if (rawParams.filter_type) {
|
||||
conditions.push(eq(ItemsBase.type, rawParams.filter_type));
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
interactionType: ItemsBase.interactionType,
|
||||
width: ItemsBase.width,
|
||||
length: ItemsBase.length,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(where)
|
||||
.orderBy(desc(ItemsBase.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(ItemsBase).where(where),
|
||||
]);
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
spriteId: r.spriteId,
|
||||
publicName: r.publicName || "",
|
||||
itemName: r.itemName || "",
|
||||
type: r.type || "s",
|
||||
interactionType: r.interactionType || "default",
|
||||
width: r.width,
|
||||
length: r.length,
|
||||
})),
|
||||
total: totals[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
filterMap: {
|
||||
filter_type: (v) => ({ type: v }),
|
||||
},
|
||||
mapRow: (r) => ({
|
||||
id: r.id as number,
|
||||
spriteId: r.spriteId as number,
|
||||
publicName: (r.publicName as string) || "",
|
||||
itemName: (r.itemName as string) || "",
|
||||
type: (r.type as string) || "s",
|
||||
interactionType: (r.interactionType as string) || "default",
|
||||
width: r.width as number,
|
||||
length: r.length as number,
|
||||
}),
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -24,7 +23,7 @@ export default async function CfhListPage({
|
||||
const stateFilter =
|
||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||
|
||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
if (stateFilter >= 0) {
|
||||
conditions.push({ state: stateFilter });
|
||||
}
|
||||
@@ -59,7 +58,7 @@ export default async function CfhListPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.SupportTicketsWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
@@ -68,7 +67,7 @@ export default async function CfhListPage({
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.SupportTicketsOrderByWithRelationInput
|
||||
any
|
||||
> = {
|
||||
id: { id: "desc" },
|
||||
issue: { issue: "asc" },
|
||||
@@ -82,7 +81,7 @@ export default async function CfhListPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { and, count, desc, eq, like, or, type SQL } from "drizzle-orm";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { OnlineTable } from "./online-table";
|
||||
|
||||
@@ -15,15 +17,34 @@ export default async function OnlineUsersPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "motto"],
|
||||
baseWhere: { online: "1" },
|
||||
defaultPerPage: 50,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
fetch: async ({ search, page, perPage }) => {
|
||||
const conditions: (SQL | undefined)[] = [eq(User.online, "1")];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(User.username, `%${search}%`),
|
||||
like(User.motto, `%${search}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
const where = and(...conditions);
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
motto: User.motto,
|
||||
look: User.look,
|
||||
})
|
||||
.from(User)
|
||||
.where(where)
|
||||
.orderBy(desc(User.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(User).where(where),
|
||||
]);
|
||||
return { rows, total: totals[0]?.total ?? 0 };
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { asc, count, eq } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { AclModelPermission, AclPermission, AclRole, db, User } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks";
|
||||
import { RankEditClient } from "./rank-edit-client";
|
||||
|
||||
@@ -19,23 +20,32 @@ export default async function RankEditPage({
|
||||
const rankId = parseInt(rawId, 10);
|
||||
if (Number.isNaN(rankId)) notFound();
|
||||
|
||||
const rank = await fetchEmulatorRankForEdit(prisma, rankId);
|
||||
const rank = await fetchEmulatorRankForEdit(db, rankId);
|
||||
if (!rank) notFound();
|
||||
|
||||
// Fetch users with this rank (limit 200)
|
||||
const users = await prisma.user.findMany({
|
||||
where: { rank: rankId },
|
||||
select: { id: true, username: true, look: true },
|
||||
orderBy: { username: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
const users = await db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.rank, rankId))
|
||||
.orderBy(asc(User.username))
|
||||
.limit(200);
|
||||
|
||||
const totalUsers = await prisma.user.count({ where: { rank: rankId } });
|
||||
const [totalUsersRow] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, rankId));
|
||||
const totalUsers = totalUsersRow?.total ?? 0;
|
||||
|
||||
// Fetch all CMS permissions
|
||||
const allCmsPermissions = await prisma.aclPermission.findMany({
|
||||
orderBy: { slug: "asc" },
|
||||
});
|
||||
const allCmsPermissions = await db
|
||||
.select({
|
||||
id: AclPermission.id,
|
||||
slug: AclPermission.slug,
|
||||
title: AclPermission.title,
|
||||
})
|
||||
.from(AclPermission)
|
||||
.orderBy(asc(AclPermission.slug));
|
||||
|
||||
// Auto-create CMS role for rank >= 4
|
||||
let cmsRole: {
|
||||
@@ -46,22 +56,37 @@ export default async function RankEditPage({
|
||||
} | null = null;
|
||||
|
||||
if (rankId >= 4) {
|
||||
const role = await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${rankId}` },
|
||||
create: {
|
||||
slug: `rank_${rankId}`,
|
||||
title: rank.rank_name || `Rank ${rankId}`,
|
||||
},
|
||||
update: {},
|
||||
include: {
|
||||
rolePermissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
const slug = `rank_${rankId}`;
|
||||
const title = rank.rank_name || `Rank ${rankId}`;
|
||||
const [existing] = await db
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
let roleId: number;
|
||||
if (existing) {
|
||||
roleId = existing.id;
|
||||
await db.update(AclRole).set({ title }).where(eq(AclRole.id, roleId));
|
||||
} else {
|
||||
const inserted = await db.insert(AclRole).values({ slug, title });
|
||||
roleId = inserted[0].insertId;
|
||||
}
|
||||
|
||||
const permissionRows = await db
|
||||
.select({ slug: AclPermission.slug })
|
||||
.from(AclModelPermission)
|
||||
.innerJoin(
|
||||
AclPermission,
|
||||
eq(AclModelPermission.permissionId, AclPermission.id),
|
||||
)
|
||||
.where(eq(AclModelPermission.modelId, roleId));
|
||||
|
||||
cmsRole = {
|
||||
id: role.id,
|
||||
slug: role.slug,
|
||||
title: role.title,
|
||||
permissionSlugs: role.rolePermissions.map((rp) => rp.permission.slug),
|
||||
id: roleId,
|
||||
slug,
|
||||
title,
|
||||
permissionSlugs: permissionRows.map((rp) => rp.slug),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
import { PermissionsList } from "./permissions-list";
|
||||
|
||||
@@ -19,12 +19,12 @@ export default async function PermissionsPage({
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||
const ranks = await fetchEmulatorRankSummaries(db);
|
||||
|
||||
// User counts per rank
|
||||
const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw(
|
||||
Prisma.sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
||||
);
|
||||
const [rawCounts] = (await db.execute(
|
||||
sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
||||
)) as unknown as [{ rank: number; cnt: number }[], unknown[]];
|
||||
const countMap: Record<number, number> = {};
|
||||
for (const row of rawCounts) {
|
||||
countMap[Number(row.rank)] = Number(row.cnt);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { count, desc, like } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, WebsitePoll } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { PollsTable } from "./polls-table";
|
||||
|
||||
@@ -16,28 +18,31 @@ export default async function PollsPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.POLLS_VIEW,
|
||||
model: "websitePoll",
|
||||
searchFields: ["title"],
|
||||
defaultSort: { createdAt: "desc" },
|
||||
mapRow: (p: {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
showResults: number;
|
||||
multipleChoice: number;
|
||||
startsAt: Date | null;
|
||||
endsAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
showResults: p.showResults,
|
||||
multipleChoice: p.multipleChoice,
|
||||
startsAt: p.startsAt,
|
||||
endsAt: p.endsAt,
|
||||
createdAt: p.createdAt,
|
||||
}),
|
||||
fetch: async ({ search, page, perPage }) => {
|
||||
const where = search
|
||||
? like(WebsitePoll.title, `%${search}%`)
|
||||
: undefined;
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: WebsitePoll.id,
|
||||
title: WebsitePoll.title,
|
||||
status: WebsitePoll.status,
|
||||
showResults: WebsitePoll.showResults,
|
||||
multipleChoice: WebsitePoll.multipleChoice,
|
||||
startsAt: WebsitePoll.startsAt,
|
||||
endsAt: WebsitePoll.endsAt,
|
||||
createdAt: WebsitePoll.createdAt,
|
||||
})
|
||||
.from(WebsitePoll)
|
||||
.where(where)
|
||||
.orderBy(desc(WebsitePoll.createdAt))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(WebsitePoll).where(where),
|
||||
]);
|
||||
return { rows, total: totals[0]?.total ?? 0 };
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { DoorOpen } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, Rooms } from "@/lib/db";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { RoomsTable } from "./rooms-table";
|
||||
|
||||
@@ -15,33 +17,56 @@ export default async function RoomsPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.ROOMS_VIEW,
|
||||
// Prisma model is `Rooms` → client accessor `rooms`.
|
||||
// Owner is denormalized on the row (ownerId / ownerName), not a relation.
|
||||
model: "rooms",
|
||||
searchFields: ["name", "ownerName"],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerId: true,
|
||||
ownerName: true,
|
||||
state: true,
|
||||
users: true,
|
||||
usersMax: true,
|
||||
description: true,
|
||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
const ors = [
|
||||
like(Rooms.name, `%${search}%`),
|
||||
like(Rooms.ownerName, `%${search}%`),
|
||||
];
|
||||
const asId = Number(search);
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(Rooms.id, asId));
|
||||
}
|
||||
conditions.push(or(...ors));
|
||||
}
|
||||
if (rawParams.filter_state) {
|
||||
conditions.push(eq(Rooms.state, rawParams.filter_state));
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
name: Rooms.name,
|
||||
ownerId: Rooms.ownerId,
|
||||
ownerName: Rooms.ownerName,
|
||||
state: Rooms.state,
|
||||
users: Rooms.users,
|
||||
usersMax: Rooms.usersMax,
|
||||
description: Rooms.description,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(where)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(Rooms).where(where),
|
||||
]);
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
ownerName: r.ownerName || "—",
|
||||
ownerId: r.ownerId ?? 0,
|
||||
state: r.state,
|
||||
users: r.users,
|
||||
usersMax: r.usersMax,
|
||||
description: r.description || "",
|
||||
})),
|
||||
total: totals[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
filterMap: {
|
||||
filter_state: (v) => ({ state: v }),
|
||||
},
|
||||
mapRow: (r) => ({
|
||||
id: r.id as number,
|
||||
name: r.name as string,
|
||||
ownerName: (r.ownerName as string) || "—",
|
||||
ownerId: (r.ownerId as number) ?? 0,
|
||||
state: r.state as string,
|
||||
users: r.users as number,
|
||||
usersMax: r.usersMax as number,
|
||||
description: (r.description as string) || "",
|
||||
}),
|
||||
},
|
||||
paramsPromise as Promise<{ locale: string }>,
|
||||
searchParams,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
@@ -25,7 +24,7 @@ export default async function AdminTicketsPage({
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || raw.status || "active";
|
||||
|
||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push({ status: { not: "closed" } });
|
||||
@@ -47,14 +46,14 @@ export default async function AdminTicketsPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.WebsiteTicketWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||
const SORT_MAP: Record<string, any> =
|
||||
{
|
||||
subject: { subject: "asc" },
|
||||
status: { status: "asc" },
|
||||
@@ -68,7 +67,7 @@ export default async function AdminTicketsPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -36,7 +35,7 @@ function formatAmount(amount: number, currency: string): string {
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.WebsitePaypalTransactionsOrderByWithRelationInput
|
||||
any
|
||||
> = {
|
||||
id: { id: "desc" },
|
||||
amount: { amount: "desc" },
|
||||
@@ -61,7 +60,7 @@ export default async function AdminTransactions({
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status ?? "";
|
||||
|
||||
const where: Prisma.WebsitePaypalTransactionsWhereInput = {};
|
||||
const where: any = {};
|
||||
|
||||
if (statusFilter === "paid") {
|
||||
where.status = { in: [...PAID_STATUS] };
|
||||
@@ -113,7 +112,7 @@ export default async function AdminTransactions({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
let transactions: Awaited<
|
||||
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
||||
|
||||
@@ -3,7 +3,6 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination } from "@/lib/admin-helpers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -30,21 +29,21 @@ export default async function EmulatorTranslationsPage({
|
||||
const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200);
|
||||
const page = Math.max(parseInt(sp.page || "1", 10), 1);
|
||||
|
||||
const patternFilters: Prisma.EmulatorSettingsWhereInput[] =
|
||||
const patternFilters: any[] =
|
||||
TRANSLATION_KEY_PATTERNS.map((p) => ({
|
||||
key: { contains: p.replace(/%/g, "") },
|
||||
}));
|
||||
|
||||
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters };
|
||||
const baseFilter: any = { OR: patternFilters };
|
||||
|
||||
const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter];
|
||||
const conditions: any[] = [baseFilter];
|
||||
if (group) conditions.push({ key: { startsWith: `${group}.` } });
|
||||
if (search) {
|
||||
conditions.push({
|
||||
OR: [{ key: { contains: search } }, { value: { contains: search } }],
|
||||
});
|
||||
}
|
||||
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions };
|
||||
const where: any = { AND: conditions };
|
||||
|
||||
const [settings, total, allKeys] = await Promise.all([
|
||||
prisma.emulatorSettings.findMany({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/lib/prisma";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getWatchedUserIds } from "@/lib/services/watch";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { and, count, desc, eq, gte, like, or } from "drizzle-orm";
|
||||
import { Users } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { CreateUserDialog } from "./_components/create-user-dialog";
|
||||
import { UsersTable } from "./users-table";
|
||||
@@ -16,30 +18,54 @@ export default async function UsersPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
mail: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
online: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
ipRegister: true,
|
||||
accountCreated: true,
|
||||
lastLogin: true,
|
||||
},
|
||||
filterMap: {
|
||||
filter_rank: (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
const ors = [
|
||||
like(User.username, `%${search}%`),
|
||||
like(User.mail, `%${search}%`),
|
||||
like(User.ipRegister, `%${search}%`),
|
||||
like(User.ipCurrent, `%${search}%`),
|
||||
];
|
||||
const asId = Number(search);
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(User.id, asId));
|
||||
}
|
||||
conditions.push(or(...ors));
|
||||
}
|
||||
const rank = Number(rawParams.filter_rank);
|
||||
if (Number.isFinite(rank)) {
|
||||
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
||||
return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
|
||||
},
|
||||
filter_online: (v) => ({ online: v }),
|
||||
conditions.push(rank >= 7 ? gte(User.rank, 7) : eq(User.rank, rank));
|
||||
}
|
||||
if (rawParams.filter_online) {
|
||||
conditions.push(eq(User.online, rawParams.filter_online));
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
mail: User.mail,
|
||||
rank: User.rank,
|
||||
credits: User.credits,
|
||||
pixels: User.pixels,
|
||||
online: User.online,
|
||||
motto: User.motto,
|
||||
look: User.look,
|
||||
ipRegister: User.ipRegister,
|
||||
accountCreated: User.accountCreated,
|
||||
lastLogin: User.lastLogin,
|
||||
})
|
||||
.from(User)
|
||||
.where(where)
|
||||
.orderBy(desc(User.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(User).where(where),
|
||||
]);
|
||||
return { rows, total: totals[0]?.total ?? 0 };
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
mail: string | null;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/lib/prisma";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/lib/prisma";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
@@ -20,7 +19,7 @@ export default async function ModCfhListPage({
|
||||
const stateFilter =
|
||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||
|
||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
if (stateFilter >= 0) {
|
||||
conditions.push({ state: stateFilter });
|
||||
}
|
||||
@@ -55,7 +54,7 @@ export default async function ModCfhListPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.SupportTicketsWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
@@ -64,7 +63,7 @@ export default async function ModCfhListPage({
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.SupportTicketsOrderByWithRelationInput
|
||||
any
|
||||
> = {
|
||||
id: { id: "desc" },
|
||||
issue: { issue: "asc" },
|
||||
@@ -78,7 +77,7 @@ export default async function ModCfhListPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/app/admin/help-tickets/help-tickets-table";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
@@ -25,7 +24,7 @@ export default async function ModHelpTicketsPage({
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || "open";
|
||||
|
||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (statusFilter === "open") {
|
||||
conditions.push({ open: true });
|
||||
@@ -56,7 +55,7 @@ export default async function ModHelpTicketsPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
@@ -65,7 +64,7 @@ export default async function ModHelpTicketsPage({
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
||||
any
|
||||
> = {
|
||||
title: { title: "asc" },
|
||||
open: { open: "desc" },
|
||||
@@ -79,7 +78,7 @@ export default async function ModHelpTicketsPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/app/admin/tickets/tickets-table";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
@@ -25,7 +24,7 @@ export default async function ModTicketsPage({
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || raw.status || "active";
|
||||
|
||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push({ status: { not: "closed" } });
|
||||
@@ -47,14 +46,14 @@ export default async function ModTicketsPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.WebsiteTicketWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||
const SORT_MAP: Record<string, any> =
|
||||
{
|
||||
subject: { subject: "asc" },
|
||||
status: { status: "asc" },
|
||||
@@ -68,7 +67,7 @@ export default async function ModTicketsPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
AclModelPermission,
|
||||
AclModelRole,
|
||||
AclPermission,
|
||||
AclRole,
|
||||
AdminAuditLog,
|
||||
User,
|
||||
WebsiteEvent,
|
||||
WebsiteEventPrize,
|
||||
WebsiteEventRegistration,
|
||||
WebsiteEventType,
|
||||
WebsiteEventWinner,
|
||||
WebsitePoll,
|
||||
WebsitePollQuestion,
|
||||
WebsitePollVote,
|
||||
WebsiteTicket,
|
||||
WebsiteTicketMessage,
|
||||
} from "@/db/schema";
|
||||
|
||||
export const userRelations = relations(User, ({ many }) => ({
|
||||
createdTickets: many(WebsiteTicket, { relationName: "ticketCreator" }),
|
||||
assignedTickets: many(WebsiteTicket, { relationName: "ticketAssignee" }),
|
||||
ticketMessages: many(WebsiteTicketMessage),
|
||||
auditLogs: many(AdminAuditLog),
|
||||
}));
|
||||
|
||||
export const websiteTicketRelations = relations(WebsiteTicket, ({ one, many }) => ({
|
||||
creator: one(User, {
|
||||
fields: [WebsiteTicket.creatorId],
|
||||
references: [User.id],
|
||||
relationName: "ticketCreator",
|
||||
}),
|
||||
assignee: one(User, {
|
||||
fields: [WebsiteTicket.assigneeId],
|
||||
references: [User.id],
|
||||
relationName: "ticketAssignee",
|
||||
}),
|
||||
messages: many(WebsiteTicketMessage),
|
||||
}));
|
||||
|
||||
export const websiteTicketMessageRelations = relations(
|
||||
WebsiteTicketMessage,
|
||||
({ one }) => ({
|
||||
ticket: one(WebsiteTicket, {
|
||||
fields: [WebsiteTicketMessage.ticketId],
|
||||
references: [WebsiteTicket.id],
|
||||
}),
|
||||
user: one(User, {
|
||||
fields: [WebsiteTicketMessage.userId],
|
||||
references: [User.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const adminAuditLogRelations = relations(AdminAuditLog, ({ one }) => ({
|
||||
user: one(User, {
|
||||
fields: [AdminAuditLog.userId],
|
||||
references: [User.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const aclRoleRelations = relations(AclRole, ({ many }) => ({
|
||||
rolePermissions: many(AclModelPermission),
|
||||
modelRoles: many(AclModelRole),
|
||||
}));
|
||||
|
||||
export const aclModelPermissionRelations = relations(
|
||||
AclModelPermission,
|
||||
({ one }) => ({
|
||||
role: one(AclRole, {
|
||||
fields: [AclModelPermission.modelId],
|
||||
references: [AclRole.id],
|
||||
}),
|
||||
permission: one(AclPermission, {
|
||||
fields: [AclModelPermission.permissionId],
|
||||
references: [AclPermission.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const websiteEventTypeRelations = relations(WebsiteEventType, ({ many }) => ({
|
||||
events: many(WebsiteEvent),
|
||||
}));
|
||||
|
||||
export const websiteEventRelations = relations(WebsiteEvent, ({ one, many }) => ({
|
||||
type: one(WebsiteEventType, {
|
||||
fields: [WebsiteEvent.typeId],
|
||||
references: [WebsiteEventType.id],
|
||||
}),
|
||||
registrations: many(WebsiteEventRegistration),
|
||||
prizes: many(WebsiteEventPrize),
|
||||
winners: many(WebsiteEventWinner),
|
||||
}));
|
||||
|
||||
export const websiteEventRegistrationRelations = relations(
|
||||
WebsiteEventRegistration,
|
||||
({ one }) => ({
|
||||
event: one(WebsiteEvent, {
|
||||
fields: [WebsiteEventRegistration.eventId],
|
||||
references: [WebsiteEvent.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const websiteEventPrizeRelations = relations(WebsiteEventPrize, ({ one }) => ({
|
||||
event: one(WebsiteEvent, {
|
||||
fields: [WebsiteEventPrize.eventId],
|
||||
references: [WebsiteEvent.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const websiteEventWinnerRelations = relations(WebsiteEventWinner, ({ one }) => ({
|
||||
event: one(WebsiteEvent, {
|
||||
fields: [WebsiteEventWinner.eventId],
|
||||
references: [WebsiteEvent.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const websitePollRelations = relations(WebsitePoll, ({ many }) => ({
|
||||
questions: many(WebsitePollQuestion),
|
||||
}));
|
||||
|
||||
export const websitePollQuestionRelations = relations(
|
||||
WebsitePollQuestion,
|
||||
({ one, many }) => ({
|
||||
poll: one(WebsitePoll, {
|
||||
fields: [WebsitePollQuestion.pollId],
|
||||
references: [WebsitePoll.id],
|
||||
}),
|
||||
votes: many(WebsitePollVote),
|
||||
}),
|
||||
);
|
||||
|
||||
export const websitePollVoteRelations = relations(WebsitePollVote, ({ one }) => ({
|
||||
question: one(WebsitePollQuestion, {
|
||||
fields: [WebsitePollVote.questionId],
|
||||
references: [WebsitePollQuestion.id],
|
||||
}),
|
||||
}));
|
||||
+2569
File diff suppressed because it is too large.
Load diff
+18
-113
@@ -1,45 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "./admin-helpers";
|
||||
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic Prisma model access requires it
|
||||
type PrismaModel = any;
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
/** Permission slug required to view this page */
|
||||
permission: string;
|
||||
/** Prisma model name (e.g. 'user', 'ban', 'room') */
|
||||
model: string;
|
||||
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
|
||||
searchFields: string[];
|
||||
/** Default sort when no sort param provided. Default: { id: 'desc' } */
|
||||
defaultSort?: Record<string, "asc" | "desc">;
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number;
|
||||
/** Prisma include clause for relations */
|
||||
include?: Record<string, unknown>;
|
||||
/** Prisma select clause (mutually exclusive with include) */
|
||||
select?: Record<string, unknown>;
|
||||
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
|
||||
baseWhere?: Record<string, unknown>;
|
||||
/**
|
||||
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
|
||||
* the raw string value from the URL and returns the WHERE fragment to merge.
|
||||
* Returning null/undefined drops the filter.
|
||||
*
|
||||
* @example
|
||||
* filterMap: {
|
||||
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
||||
* filter_online: (v) => ({ online: v }),
|
||||
* }
|
||||
* Runs the actual data query (Drizzle). Receives parsed list params and
|
||||
* returns the rows + total count for the current page.
|
||||
*/
|
||||
filterMap?: Record<
|
||||
string,
|
||||
(value: string) => Record<string, unknown> | null | undefined
|
||||
>;
|
||||
/** Transform raw Prisma row to the shape needed by the table component */
|
||||
mapRow?: (row: PrismaModel) => TRow;
|
||||
fetch: (args: {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
rawParams: Record<string, string>;
|
||||
}) => Promise<{ rows: TRow[]; total: number }>;
|
||||
}
|
||||
|
||||
interface AdminListResult<TRow> {
|
||||
@@ -53,35 +30,12 @@ interface AdminListResult<TRow> {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested Prisma WHERE condition from a dot-notation field path.
|
||||
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
|
||||
* e.g. 'username' => { username: { contains: search } }
|
||||
*/
|
||||
function buildFieldCondition(
|
||||
fieldPath: string,
|
||||
search: string,
|
||||
): Record<string, unknown> {
|
||||
const parts = fieldPath.split(".");
|
||||
if (parts.length === 1) {
|
||||
return { [parts[0]]: { contains: search } };
|
||||
}
|
||||
// Build nested object from right to left
|
||||
let result: Record<string, unknown> = {
|
||||
[parts[parts.length - 1]]: { contains: search },
|
||||
};
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
result = { [parts[i]]: result };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic admin list data fetcher.
|
||||
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
|
||||
* Prisma query with pagination, and row mapping.
|
||||
* Centralizes the common pattern: auth check, param parsing, pagination and
|
||||
* row mapping. The caller supplies a Drizzle query via `config.fetch`.
|
||||
*/
|
||||
export async function fetchAdminList<TRow = PrismaModel>(
|
||||
export async function fetchAdminList<TRow>(
|
||||
config: AdminListConfig<TRow>,
|
||||
paramsPromise: Promise<{ locale: string }>,
|
||||
searchParamsPromise: Promise<Record<string, string>>,
|
||||
@@ -99,64 +53,15 @@ export async function fetchAdminList<TRow = PrismaModel>(
|
||||
const perPage = config.defaultPerPage
|
||||
? Number(rawParams.perPage) || config.defaultPerPage
|
||||
: parsed.perPage;
|
||||
const { search, page, sort, order } = parsed;
|
||||
|
||||
// Build WHERE clause
|
||||
const searchOr =
|
||||
search && config.searchFields.length > 0
|
||||
? config.searchFields.map((field) => buildFieldCondition(field, search))
|
||||
: [];
|
||||
// Exact numeric id match (furni / rooms / items lookup by id).
|
||||
if (search && /^\d+$/.test(search)) {
|
||||
const asId = Number(search);
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
searchOr.push({ id: asId });
|
||||
}
|
||||
}
|
||||
const searchWhere = searchOr.length > 0 ? { OR: searchOr } : {};
|
||||
const { rows, total } = await config.fetch({
|
||||
search: parsed.search,
|
||||
page: parsed.page,
|
||||
perPage,
|
||||
rawParams,
|
||||
});
|
||||
|
||||
// Apply URL filter_* params via filterMap
|
||||
const filterWhere: Record<string, unknown> = {};
|
||||
if (config.filterMap) {
|
||||
for (const [paramKey, build] of Object.entries(config.filterMap)) {
|
||||
const value = rawParams[paramKey];
|
||||
if (!value) continue;
|
||||
const fragment = build(value);
|
||||
if (fragment) Object.assign(filterWhere, fragment);
|
||||
}
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(config.baseWhere ?? {}),
|
||||
...filterWhere,
|
||||
...searchWhere,
|
||||
};
|
||||
|
||||
// Build orderBy
|
||||
const orderBy = sort
|
||||
? { [sort]: order }
|
||||
: (config.defaultSort ?? { id: "desc" as const });
|
||||
|
||||
// Access Prisma model dynamically
|
||||
const delegate = (prisma as PrismaModel)[config.model];
|
||||
|
||||
const queryArgs: PrismaModel = {
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
};
|
||||
|
||||
if (config.include) queryArgs.include = config.include;
|
||||
if (config.select) queryArgs.select = config.select;
|
||||
|
||||
const [rawRows, total] = await Promise.all([
|
||||
delegate.findMany(queryArgs),
|
||||
delegate.count({ where }),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows;
|
||||
const pagination = calcPagination(total, parsed.page, perPage);
|
||||
|
||||
return {
|
||||
rows,
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("ACL management contract", () => {
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("aclModelPermission");
|
||||
expect(source).toContain("AclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export interface StaffActivityFilters {
|
||||
q?: string;
|
||||
staffId?: number | null;
|
||||
@@ -9,8 +7,8 @@ export interface StaffActivityFilters {
|
||||
|
||||
export function buildStaffActivityWhere(
|
||||
filters: StaffActivityFilters,
|
||||
): Prisma.StaffActivitiesWhereInput {
|
||||
const where: Prisma.StaffActivitiesWhereInput = {};
|
||||
): any {
|
||||
const where: any = {};
|
||||
if (filters.q?.trim())
|
||||
where.OR = [
|
||||
{ action: { contains: filters.q.trim() } },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveConnectionLimit } from "./db-pool";
|
||||
|
||||
describe("resolveConnectionLimit", () => {
|
||||
it("caps the pool during production build phases", () => {
|
||||
expect(resolveConnectionLimit(40, "phase-production-build")).toBe(5);
|
||||
expect(resolveConnectionLimit(40, "phase-production-compile")).toBe(5);
|
||||
expect(resolveConnectionLimit(3, "phase-production-build")).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the configured pool outside build", () => {
|
||||
expect(resolveConnectionLimit(40, undefined)).toBe(40);
|
||||
expect(resolveConnectionLimit(40, "phase-development-server")).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Cap pool size during `next build` so N workers × pool don't exhaust MySQL.
|
||||
* Kept free of Prisma/env imports so unit tests can exercise it in isolation.
|
||||
* Kept free of driver/env imports so unit tests can exercise it in isolation.
|
||||
*/
|
||||
export function resolvePrismaConnectionLimit(
|
||||
export function resolveConnectionLimit(
|
||||
poolSize: number,
|
||||
phase = process.env.NEXT_PHASE,
|
||||
): number {
|
||||
@@ -0,0 +1,62 @@
|
||||
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import * as relations from "@/db/relations";
|
||||
import * as schema from "@/db/schema";
|
||||
import { env } from "@/env";
|
||||
import { resolveConnectionLimit } from "@/lib/db-pool";
|
||||
|
||||
const fullSchema = { ...schema, ...relations };
|
||||
|
||||
const globalForDb = globalThis as unknown as {
|
||||
db?: MySql2Database<typeof fullSchema>;
|
||||
};
|
||||
|
||||
function createDb(): MySql2Database<typeof fullSchema> {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const isBuild =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.NEXT_PHASE === "phase-production-compile";
|
||||
const connectionLimit = resolveConnectionLimit(env.DATABASE_POOL_SIZE);
|
||||
// Fail fast during SSG so Next can retry/fallback instead of hanging 30s+.
|
||||
const connectTimeout = isBuild
|
||||
? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000)
|
||||
: env.DATABASE_CONNECT_TIMEOUT_MS;
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
charset: "utf8mb4",
|
||||
waitForConnections: true,
|
||||
connectionLimit,
|
||||
queueLimit: 0,
|
||||
connectTimeout,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
// Keep BIGINT precision; safe values come back as numbers, huge ones as
|
||||
// strings (drizzle bigint mode maps both to JS bigint).
|
||||
supportBigNumbers: true,
|
||||
});
|
||||
|
||||
return drizzle(pool, {
|
||||
schema: fullSchema,
|
||||
mode: "default",
|
||||
logger:
|
||||
env.NODE_ENV === "development"
|
||||
? {
|
||||
logQuery(query, params) {
|
||||
console.log("[db]", query, params);
|
||||
},
|
||||
}
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const db = globalForDb.db ?? createDb();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForDb.db = db;
|
||||
|
||||
export type Db = MySql2Database<typeof fullSchema>;
|
||||
|
||||
export * from "@/db/schema";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePrismaConnectionLimit } from "./prisma-pool";
|
||||
|
||||
describe("resolvePrismaConnectionLimit", () => {
|
||||
it("caps the pool during production build phases", () => {
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5);
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-production-compile")).toBe(
|
||||
5,
|
||||
);
|
||||
expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the configured pool outside build", () => {
|
||||
expect(resolvePrismaConnectionLimit(40, undefined)).toBe(40);
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-development-server")).toBe(
|
||||
40,
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-36
@@ -1,37 +1,6 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { env } from "@/env";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function createPrismaClient(): PrismaClient {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const isBuild =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.NEXT_PHASE === "phase-production-compile";
|
||||
const connectionLimit = resolvePrismaConnectionLimit(env.DATABASE_POOL_SIZE);
|
||||
// Fail fast during SSG so Next can retry/fallback instead of hanging 30s+.
|
||||
const connectTimeout = isBuild
|
||||
? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000)
|
||||
: env.DATABASE_CONNECT_TIMEOUT_MS;
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit,
|
||||
connectTimeout,
|
||||
acquireTimeout: connectTimeout,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
export const prisma = makePrisma(db) as unknown as PrismaClient;
|
||||
export { PrismaHelper as Prisma };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
|
||||
@@ -139,11 +139,11 @@ export async function loadCatalogItemsData(
|
||||
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||
const pageIdStr = String(pageId);
|
||||
// CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int.
|
||||
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
const [rawItems] = (await db.execute(sql`
|
||||
SELECT * FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
||||
ORDER BY order_number ASC, id ASC
|
||||
`;
|
||||
`)) as unknown as [Record<string, unknown>[], unknown[]];
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
@@ -164,13 +164,18 @@ export async function loadCatalogItemsData(
|
||||
}));
|
||||
|
||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { caption: "asc" },
|
||||
select: { id: true, caption: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||
db
|
||||
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.caption)),
|
||||
db
|
||||
.execute(sql`
|
||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||
`,
|
||||
`)
|
||||
.then(
|
||||
(result) =>
|
||||
(result as unknown as [{ interaction_type: string }[], unknown[]])[0],
|
||||
),
|
||||
getHabboGamedataHotel(),
|
||||
]);
|
||||
|
||||
@@ -192,46 +197,46 @@ export async function loadCatalogItemsData(
|
||||
];
|
||||
|
||||
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
|
||||
const baseItems: BaseItem[] =
|
||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join.
|
||||
const [baseItemRows] =
|
||||
baseItemIds.length > 0
|
||||
? (
|
||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
? ((await db.execute(sql`
|
||||
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
||||
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
||||
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
||||
allow_inventory_stack, interaction_type, interaction_modes_count,
|
||||
vending_ids, customparams, effect_id_male, effect_id_female,
|
||||
clothing_on_walk
|
||||
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
||||
`
|
||||
).map((r) => ({
|
||||
id: Number(r.id),
|
||||
spriteId: Number(r.sprite_id),
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}))
|
||||
: [];
|
||||
FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)})
|
||||
`)) as unknown as [Record<string, unknown>[], unknown[]])
|
||||
: [[], []];
|
||||
|
||||
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
spriteId: Number(r.sprite_id),
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}));
|
||||
|
||||
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
||||
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.hoisted(() => vi.fn());
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
const update = vi.hoisted(() => vi.fn());
|
||||
const create = vi.hoisted(() => vi.fn());
|
||||
const queryRaw = vi.hoisted(() => vi.fn());
|
||||
const executeRaw = vi.hoisted(() => vi.fn());
|
||||
const catalogDelete = vi.hoisted(() => vi.fn());
|
||||
const catalogUpdateMany = vi.hoisted(() => vi.fn());
|
||||
const insertValues = vi.hoisted(() => vi.fn());
|
||||
const selectLimit = vi.hoisted(() => vi.fn());
|
||||
const updateSetData = vi.hoisted(() => vi.fn());
|
||||
const updateWhere = vi.hoisted(() => vi.fn());
|
||||
const deleteWhere = vi.hoisted(() => vi.fn());
|
||||
const executeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
catalogPages: {
|
||||
findUnique,
|
||||
findMany,
|
||||
update,
|
||||
create,
|
||||
delete: catalogDelete,
|
||||
updateMany: catalogUpdateMany,
|
||||
},
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: executeRaw,
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: () => ({ values: insertValues }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(selectLimit()),
|
||||
}),
|
||||
orderBy: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(selectLimit()),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
updateSetData(data);
|
||||
return {
|
||||
where: () => updateWhere(data),
|
||||
};
|
||||
},
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => Promise.resolve(deleteWhere()),
|
||||
}),
|
||||
execute: executeMock,
|
||||
},
|
||||
CatalogPages: { id: "CatalogPages.id", parentId: "CatalogPages.parentId" },
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({ env: {} }));
|
||||
@@ -121,10 +135,10 @@ describe("buildNestedTree", () => {
|
||||
|
||||
describe("createPage", () => {
|
||||
it("creates page with default values", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
await createPage({ parentId: 0, caption: "Test" });
|
||||
|
||||
const data = create.mock.calls[0][0].data;
|
||||
const data = insertValues.mock.calls[0][0];
|
||||
expect(data.caption).toBe("Test");
|
||||
expect(data.pageLayout).toBe("default_3x3");
|
||||
expect(data.visible).toBe("1");
|
||||
@@ -134,57 +148,51 @@ describe("createPage", () => {
|
||||
});
|
||||
|
||||
it("truncates captionSave to 25 chars", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
await createPage({ parentId: 0, caption: "a".repeat(50) });
|
||||
|
||||
expect(create.mock.calls[0][0].data.captionSave).toBe("a".repeat(25));
|
||||
expect(insertValues.mock.calls[0][0].captionSave).toBe("a".repeat(25));
|
||||
});
|
||||
|
||||
it("converts boolean visible/enabled to string", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
await createPage({
|
||||
parentId: 0,
|
||||
caption: "x",
|
||||
visible: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(create.mock.calls[0][0].data.visible).toBe("0");
|
||||
expect(create.mock.calls[0][0].data.enabled).toBe("0");
|
||||
expect(insertValues.mock.calls[0][0].visible).toBe("0");
|
||||
expect(insertValues.mock.calls[0][0].enabled).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("movePage", () => {
|
||||
it("throws on circular hierarchy", async () => {
|
||||
findUnique.mockResolvedValue({ parentId: 1 });
|
||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
|
||||
});
|
||||
|
||||
it("allows valid move to root", async () => {
|
||||
update.mockResolvedValue({});
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
await movePage(1, 0);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { parentId: 0 },
|
||||
});
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
|
||||
});
|
||||
|
||||
it("allows valid move to other parent", async () => {
|
||||
findUnique.mockResolvedValue({ parentId: 0 });
|
||||
update.mockResolvedValue({});
|
||||
selectLimit.mockResolvedValue([{ parentId: 0 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
await movePage(1, 2);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { parentId: 2 },
|
||||
});
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAncestors", () => {
|
||||
it("returns breadcrumb from root to page", async () => {
|
||||
findUnique
|
||||
.mockResolvedValueOnce({ id: 3, caption: "Grandchild", parentId: 2 })
|
||||
.mockResolvedValueOnce({ id: 2, caption: "Child", parentId: 1 })
|
||||
.mockResolvedValueOnce({ id: 1, caption: "Root", parentId: 0 });
|
||||
selectLimit
|
||||
.mockResolvedValueOnce([{ id: 3, caption: "Grandchild", parentId: 2 }])
|
||||
.mockResolvedValueOnce([{ id: 2, caption: "Child", parentId: 1 }])
|
||||
.mockResolvedValueOnce([{ id: 1, caption: "Root", parentId: 0 }]);
|
||||
|
||||
const ancestors = await getAncestors(3);
|
||||
expect(ancestors).toHaveLength(3);
|
||||
@@ -194,22 +202,19 @@ describe("getAncestors", () => {
|
||||
});
|
||||
|
||||
it("stops when page not found", async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
selectLimit.mockResolvedValue([]);
|
||||
expect(await getAncestors(999)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePage", () => {
|
||||
it("reparent mode moves children to parent", async () => {
|
||||
findUnique.mockResolvedValue({ parentId: 1 });
|
||||
catalogDelete.mockResolvedValue({});
|
||||
catalogUpdateMany.mockResolvedValue({ count: 2 });
|
||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
|
||||
const result = await deletePage(5, "reparent");
|
||||
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
|
||||
expect(catalogUpdateMany).toHaveBeenCalledWith({
|
||||
where: { parentId: 5 },
|
||||
data: { parentId: 1 },
|
||||
});
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
|
||||
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
||||
|
||||
@@ -23,25 +23,27 @@ export async function getCatalogItemCounts(
|
||||
|
||||
if (pageIds) {
|
||||
const idStrs = pageIds.map(String);
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ page_id: string | number; cnt: number | bigint }>
|
||||
>`
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT page_id, COUNT(*) as cnt
|
||||
FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
||||
GROUP BY page_id
|
||||
`;
|
||||
`)) as unknown as [
|
||||
{ page_id: string | number; cnt: number | bigint }[],
|
||||
unknown[],
|
||||
];
|
||||
for (const r of rows) {
|
||||
map.set(toInt(r.page_id), toInt(r.cnt));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ page_id: string | number; cnt: number | bigint }>
|
||||
>`
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id
|
||||
`;
|
||||
`)) as unknown as [
|
||||
{ page_id: string | number; cnt: number | bigint }[],
|
||||
unknown[],
|
||||
];
|
||||
for (const r of rows) {
|
||||
map.set(toInt(r.page_id), toInt(r.cnt));
|
||||
}
|
||||
@@ -55,9 +57,10 @@ export async function getCatalogItemCounts(
|
||||
* Get the full catalog tree as a flat list with depth info.
|
||||
*/
|
||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const allPages = await prisma.catalogPages.findMany({
|
||||
orderBy: { orderNum: "asc" },
|
||||
});
|
||||
const allPages = await db
|
||||
.select()
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.orderNum));
|
||||
const itemCountMap = await getCatalogItemCounts();
|
||||
|
||||
const childCountMap = new Map<number, number>();
|
||||
@@ -69,15 +72,23 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||
const depthCache = new Map<number, number>();
|
||||
|
||||
function computeDepth(pageId: number): number {
|
||||
function computeDepth(pageId: number, visiting = new Set<number>()): number {
|
||||
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
||||
const page = pageMap.get(pageId);
|
||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
||||
if (!page || parentId <= 0) {
|
||||
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
||||
if (visiting.has(pageId)) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(parentId) + 1;
|
||||
visiting.add(pageId);
|
||||
const page = pageMap.get(pageId);
|
||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
||||
if (!page || parentId <= 0) {
|
||||
visiting.delete(pageId);
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(parentId, visiting) + 1;
|
||||
visiting.delete(pageId);
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
@@ -154,24 +165,22 @@ export async function createPage(data: {
|
||||
: "0"
|
||||
: (data.enabled ?? "1");
|
||||
|
||||
return prisma.catalogPages.create({
|
||||
data: {
|
||||
caption: data.caption,
|
||||
parentId: data.parentId,
|
||||
pageLayout: data.pageLayout ?? "default_3x3",
|
||||
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||
iconColor: data.iconColor ?? 0,
|
||||
iconImage: data.iconImage ?? 0,
|
||||
minRank: data.minRank ?? 1,
|
||||
orderNum: data.orderNum ?? 0,
|
||||
visible,
|
||||
enabled,
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
return db.insert(CatalogPages).values({
|
||||
caption: data.caption,
|
||||
parentId: data.parentId,
|
||||
pageLayout: data.pageLayout ?? "default_3x3",
|
||||
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||
iconColor: data.iconColor ?? 0,
|
||||
iconImage: data.iconImage ?? 0,
|
||||
minRank: data.minRank ?? 1,
|
||||
orderNum: data.orderNum ?? 0,
|
||||
visible,
|
||||
enabled,
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,19 +198,20 @@ export async function movePage(
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const parent = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPages.parentId })
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPages.id, pageId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,10 +221,10 @@ export async function movePage(
|
||||
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
||||
if (pageIds.length === 0) return;
|
||||
const idStrs = pageIds.map(String);
|
||||
await prisma.$executeRaw`
|
||||
await db.execute(sql`
|
||||
DELETE FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||
`;
|
||||
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,22 +234,23 @@ export async function deletePage(
|
||||
pageId: number,
|
||||
mode: "cascade" | "reparent" = "reparent",
|
||||
): Promise<{ deletedPages: number; movedChildren: number }> {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPages.parentId })
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, pageId))
|
||||
.limit(1);
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
|
||||
if (mode === "reparent") {
|
||||
const result = await prisma.catalogPages.updateMany({
|
||||
where: { parentId: pageId },
|
||||
data: { parentId: page.parentId },
|
||||
});
|
||||
const [result] = await db
|
||||
.update(CatalogPages)
|
||||
.set({ parentId: page.parentId })
|
||||
.where(eq(CatalogPages.parentId, pageId));
|
||||
|
||||
await deleteCatalogItemsByPageIds([pageId]);
|
||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
|
||||
|
||||
return { deletedPages: 1, movedChildren: result.count };
|
||||
return { deletedPages: 1, movedChildren: result.affectedRows };
|
||||
}
|
||||
|
||||
const deleted = await cascadeDelete(pageId);
|
||||
@@ -251,10 +262,10 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
||||
const queue: number[] = [pageId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: { in: queue } },
|
||||
select: { id: true },
|
||||
});
|
||||
const children = await db
|
||||
.select({ id: CatalogPages.id })
|
||||
.from(CatalogPages)
|
||||
.where(inArray(CatalogPages.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
@@ -264,7 +275,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
||||
|
||||
await deleteCatalogItemsByPageIds(toDelete);
|
||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
|
||||
}
|
||||
|
||||
return toDelete.length;
|
||||
@@ -279,10 +290,15 @@ export async function getAncestors(
|
||||
const ancestors: Array<{ id: number; caption: string }> = [];
|
||||
let currentId = pageId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { id: true, caption: true, parentId: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
caption: CatalogPages.caption,
|
||||
parentId: CatalogPages.parentId,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, currentId))
|
||||
.limit(1);
|
||||
if (!page) break;
|
||||
ancestors.unshift({ id: page.id, caption: page.caption });
|
||||
if (page.parentId <= 0) break;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import type { Db } from "@/lib/db";
|
||||
|
||||
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
|
||||
@@ -41,11 +43,18 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
|
||||
permissionMaxValues: Record<string, number>;
|
||||
}
|
||||
|
||||
type RawDb = {
|
||||
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>;
|
||||
$executeRaw: (query: Prisma.Sql) => Promise<number>;
|
||||
$transaction: <T>(queries: Promise<T>[]) => Promise<T[]>;
|
||||
};
|
||||
/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */
|
||||
type RawDb = Pick<Db, "execute" | "transaction">;
|
||||
|
||||
async function rawRows<T>(db: RawDb, query: SQL | string): Promise<T> {
|
||||
const [rows] = await db.execute(query);
|
||||
return rows as T;
|
||||
}
|
||||
|
||||
async function rawExec(db: RawDb, query: SQL | string): Promise<number> {
|
||||
const [result] = await db.execute(query);
|
||||
return (result as ResultSetHeader).affectedRows;
|
||||
}
|
||||
|
||||
export function getRankPermissionColumn(rankId: number) {
|
||||
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
|
||||
@@ -79,8 +88,9 @@ export function splitRankUpdateFields(fields: Record<string, string | number>) {
|
||||
export async function fetchEmulatorRankSummaries(
|
||||
db: RawDb,
|
||||
): Promise<EmulatorRankSummary[]> {
|
||||
const rows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
const rows = await rawRows<Record<string, unknown>[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
|
||||
FROM permission_ranks
|
||||
ORDER BY id ASC
|
||||
@@ -102,8 +112,9 @@ export async function fetchEmulatorRankForEdit(
|
||||
db: RawDb,
|
||||
rankId: number,
|
||||
): Promise<EmulatorRankForEdit | null> {
|
||||
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
const rankRows = await rawRows<Record<string, unknown>[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
||||
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
||||
auto_gotw_amount, auto_points_amount
|
||||
@@ -114,15 +125,16 @@ export async function fetchEmulatorRankForEdit(
|
||||
if (rankRows.length === 0) return null;
|
||||
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const permissionRows = await db.$queryRaw<
|
||||
const permissionRows = await rawRows<
|
||||
{
|
||||
permission_key: string;
|
||||
max_value: number;
|
||||
value: number | string | null;
|
||||
}[]
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value
|
||||
db,
|
||||
sql`
|
||||
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
|
||||
FROM permission_definitions
|
||||
ORDER BY permission_key ASC
|
||||
`,
|
||||
@@ -159,40 +171,41 @@ export async function createEmulatorRank(
|
||||
db: RawDb,
|
||||
data: { rank_name: string; level: number },
|
||||
): Promise<number> {
|
||||
const rows = await db.$queryRaw<{ next_id: number }[]>(
|
||||
Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
||||
const rows = await rawRows<{ next_id: number }[]>(
|
||||
db,
|
||||
sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
||||
);
|
||||
const id = Number(rows[0]?.next_id ?? 1);
|
||||
await ensurePermissionRankColumn(db, id);
|
||||
await resetPermissionRankColumn(db, id);
|
||||
|
||||
await db.$transaction([
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await db.transaction(async (tx) => {
|
||||
await rawExec(
|
||||
tx,
|
||||
sql`
|
||||
INSERT INTO permission_ranks (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
`,
|
||||
),
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
);
|
||||
await rawExec(
|
||||
tx,
|
||||
sql`
|
||||
INSERT INTO permissions (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
|
||||
`,
|
||||
),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function deleteEmulatorRank(db: RawDb, rankId: number) {
|
||||
await resetPermissionRankColumn(db, rankId);
|
||||
await db.$transaction([
|
||||
db.$executeRaw(
|
||||
Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`,
|
||||
),
|
||||
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
|
||||
await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEmulatorRank(
|
||||
@@ -220,13 +233,13 @@ async function updateTableFields(
|
||||
fields: Record<string, string | number>,
|
||||
) {
|
||||
const assignments = Object.entries(fields).map(
|
||||
([key, value]) =>
|
||||
Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
|
||||
([key, value]) => sql`${sql.raw(quoteIdentifier(key))} = ${value}`,
|
||||
);
|
||||
return db.$executeRaw(
|
||||
Prisma.sql`
|
||||
UPDATE ${Prisma.raw(quoteIdentifier(table))}
|
||||
SET ${Prisma.join(assignments, ", ")}
|
||||
return rawExec(
|
||||
db,
|
||||
sql`
|
||||
UPDATE ${sql.raw(quoteIdentifier(table))}
|
||||
SET ${sql.join(assignments, sql`, `)}
|
||||
WHERE id = ${rankId}
|
||||
`,
|
||||
);
|
||||
@@ -240,19 +253,20 @@ async function updatePermissionDefinitionValues(
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const entries = Object.entries(permissionFields);
|
||||
const cases = entries.map(
|
||||
([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
|
||||
([key, value]) => sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
|
||||
);
|
||||
const keys = entries.map(([key]) => key);
|
||||
|
||||
return db.$executeRaw(
|
||||
Prisma.sql`
|
||||
return rawExec(
|
||||
db,
|
||||
sql`
|
||||
UPDATE permission_definitions
|
||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
|
||||
SET ${sql.raw(quoteIdentifier(rankColumn))} =
|
||||
CASE permission_key
|
||||
${Prisma.join(cases, " ")}
|
||||
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
|
||||
${sql.join(cases, sql` `)}
|
||||
ELSE ${sql.raw(quoteIdentifier(rankColumn))}
|
||||
END
|
||||
WHERE permission_key IN (${Prisma.join(keys)})
|
||||
WHERE permission_key IN (${sql.join(keys, sql`, `)})
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -272,8 +286,9 @@ async function updateLegacyPermissionFields(
|
||||
|
||||
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
|
||||
Prisma.sql`
|
||||
const exists = await rawRows<{ c: number }[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT COUNT(*) AS c
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
@@ -283,27 +298,30 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
||||
);
|
||||
if (Number(exists[0]?.c ?? 0) > 0) return;
|
||||
|
||||
await db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await rawExec(
|
||||
db,
|
||||
sql`
|
||||
ALTER TABLE permission_definitions
|
||||
ADD COLUMN ${Prisma.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
ADD COLUMN ${sql.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPermissionRankColumn(db: RawDb, rankId: number) {
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
await db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await rawExec(
|
||||
db,
|
||||
sql`
|
||||
UPDATE permission_definitions
|
||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0
|
||||
SET ${sql.raw(quoteIdentifier(rankColumn))} = 0
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getTableColumns(db: RawDb, table: string) {
|
||||
const rows = await db.$queryRaw<{ column_name: string }[]>(
|
||||
Prisma.sql`
|
||||
const rows = await rawRows<{ column_name: string }[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Ban, User, UsersCurrency } from "@/generated/prisma/client";
|
||||
|
||||
export type UserWithCurrencies = User & { currencies: UsersCurrency[] };
|
||||
export type UserWithPermission = User & { permission: { rankName: string } };
|
||||
export type UserPublicPreview = Pick<
|
||||
User,
|
||||
"id" | "username" | "look" | "motto"
|
||||
>;
|
||||
export interface NotificationRow {
|
||||
id: number;
|
||||
userId: number;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
link: string | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export type BanWithStaff = Ban & { staff: Pick<User, "id" | "username"> };
|
||||
Reference in new issue
Block a user