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
|
# 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.
|
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;
|
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
|
### 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.).
|
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
||||||
|
|
||||||
### 4. Generate Prisma Client
|
### 4. Type Generation (Optional for Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm prisma:generate
|
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
|
### 5. Run CMS Migrations
|
||||||
|
|
||||||
@@ -161,20 +171,21 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── prisma/
|
├── 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
|
│ └── 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/
|
├── 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)
|
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||||
│ ├── actions/ # Server Actions
|
│ ├── actions/ # Server Actions
|
||||||
│ ├── components/ # UI components
|
│ ├── components/ # UI components
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── 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.ts # Redis client (ioredis)
|
||||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||||
│ │ ├── cache.ts # In-memory cache fallback
|
│ │ ├── 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) |
|
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
||||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
| 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
|
## 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/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@prisma/adapter-mariadb": "^7.9.1",
|
|
||||||
"@prisma/client": "^7.9.1",
|
|
||||||
"@sentry/nextjs": "^10.69.0",
|
"@sentry/nextjs": "^10.69.0",
|
||||||
"@tanstack/react-virtual": "^3.14.9",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"isomorphic-dompurify": "^3.20.0",
|
"isomorphic-dompurify": "^3.20.0",
|
||||||
@@ -76,6 +75,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@next/bundle-analyzer": "^16.2.12",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
|
"@prisma/client": "^7.9.1",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.30.0",
|
"knip": "^6.30.0",
|
||||||
"lint-staged": "^17.3.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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { $Enums } from "@/generated/prisma/client";
|
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
type BanType = $Enums.bans_type;
|
|
||||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||||
"account",
|
"account",
|
||||||
"ip",
|
"ip",
|
||||||
@@ -45,7 +43,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
banExpire,
|
banExpire,
|
||||||
banReason: reason,
|
banReason: reason,
|
||||||
type: type as BanType,
|
type: type as any,
|
||||||
cfhTopic: -1,
|
cfhTopic: -1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -58,7 +57,7 @@ export async function updateBcPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await prisma.catalogPagesBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.CatalogPagesBcUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -104,7 +103,7 @@ export async function updateBcItem({
|
|||||||
}
|
}
|
||||||
await prisma.catalogItemsBc.update({
|
await prisma.catalogItemsBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safe as Prisma.CatalogItemsBcUpdateInput,
|
data: safe as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -252,7 +252,7 @@ export async function moveCatalogItems({
|
|||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
UPDATE catalog_items
|
UPDATE catalog_items
|
||||||
SET page_id = ${pageIdStr}
|
SET page_id = ${pageIdStr}
|
||||||
WHERE id IN (${Prisma.join(ids)})
|
WHERE id IN (${sql.join(ids, sql`, `)})
|
||||||
`;
|
`;
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
@@ -301,7 +301,7 @@ export async function updateCatalogItem({
|
|||||||
if (Object.keys(safeCatalog).length > 0) {
|
if (Object.keys(safeCatalog).length > 0) {
|
||||||
await prisma.catalogItems.update({
|
await prisma.catalogItems.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safeCatalog as Prisma.CatalogItemsUpdateInput,
|
data: safeCatalog as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (baseItem) {
|
if (baseItem) {
|
||||||
@@ -309,7 +309,7 @@ export async function updateCatalogItem({
|
|||||||
if (Object.keys(safeBase).length > 0) {
|
if (Object.keys(safeBase).length > 0) {
|
||||||
await prisma.itemsBase.update({
|
await prisma.itemsBase.update({
|
||||||
where: { id: baseItem.id },
|
where: { id: baseItem.id },
|
||||||
data: safeBase as Prisma.ItemsBaseUpdateInput,
|
data: safeBase as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -57,7 +56,7 @@ export async function updateCatalogPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPages.update({
|
await prisma.catalogPages.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.CatalogPagesUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
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.
|
// between the two operations cannot orphan the user.
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx: Prisma.TransactionClient) => {
|
async (tx) => {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { credits: { decrement: price } },
|
data: { credits: { decrement: price } },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
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[key] !== undefined) data[key] = Number(data[key]);
|
||||||
}
|
}
|
||||||
if (data.stackHeight !== undefined) {
|
if (data.stackHeight !== undefined) {
|
||||||
data.stackHeight = new Prisma.Decimal(String(data.stackHeight));
|
data.stackHeight = Number(data.stackHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.itemsBase.update({
|
await prisma.itemsBase.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.ItemsBaseUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog().catch(() => false);
|
await rcon.updateCatalog().catch(() => false);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
+74
-47
@@ -1,9 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AclModelPermission,
|
||||||
|
AclModelRole,
|
||||||
|
AclPermission,
|
||||||
|
AclRole,
|
||||||
|
db,
|
||||||
|
User,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import {
|
import {
|
||||||
@@ -22,16 +31,15 @@ const createRankSchema = z.object({
|
|||||||
export const createRank = adminAction(
|
export const createRank = adminAction(
|
||||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = await createEmulatorRank(prisma, ctx.data);
|
const id = await createEmulatorRank(db, ctx.data);
|
||||||
await prisma.aclRole.upsert({
|
await db
|
||||||
where: { slug: `rank_${id}` },
|
.insert(AclRole)
|
||||||
create: {
|
.values({
|
||||||
slug: `rank_${id}`,
|
slug: `rank_${id}`,
|
||||||
title: ctx.data.rank_name,
|
title: ctx.data.rank_name,
|
||||||
description: "CMS role synchronized from permission_ranks",
|
description: "CMS role synchronized from permission_ranks",
|
||||||
},
|
})
|
||||||
update: { title: ctx.data.rank_name },
|
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
||||||
});
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
action: "rank_create",
|
action: "rank_create",
|
||||||
@@ -50,21 +58,32 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteRank = adminAction(
|
export const deleteRank = adminAction(
|
||||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
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)
|
if (users > 0)
|
||||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||||
const role = await prisma.aclRole.findFirst({
|
const [role] = await db
|
||||||
where: { slug: `rank_${ctx.data.id}` },
|
.select({ id: AclRole.id })
|
||||||
});
|
.from(AclRole)
|
||||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||||
|
.limit(1);
|
||||||
|
await deleteEmulatorRank(db, ctx.data.id);
|
||||||
if (role) {
|
if (role) {
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.aclModelPermission.deleteMany({
|
await tx
|
||||||
where: { modelId: role.id, modelType: "Role" },
|
.delete(AclModelPermission)
|
||||||
}),
|
.where(
|
||||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
and(
|
||||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
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({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
@@ -87,12 +106,12 @@ const saveRankSchema = z.object({
|
|||||||
export const saveRank = adminAction(
|
export const saveRank = adminAction(
|
||||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
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") {
|
if (typeof ctx.data.fields.rank_name === "string") {
|
||||||
await prisma.aclRole.updateMany({
|
await db
|
||||||
where: { slug: `rank_${ctx.data.id}` },
|
.update(AclRole)
|
||||||
data: { title: ctx.data.fields.rank_name },
|
.set({ title: ctx.data.fields.rank_name })
|
||||||
});
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
||||||
}
|
}
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
@@ -115,27 +134,33 @@ const setCmsPermsSchema = z.object({
|
|||||||
export const setCmsPermissions = adminAction(
|
export const setCmsPermissions = adminAction(
|
||||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const role = await prisma.aclRole.findUnique({
|
const [role] = await db
|
||||||
where: { id: ctx.data.roleId },
|
.select({ id: AclRole.id, slug: AclRole.slug })
|
||||||
select: { id: true, slug: true },
|
.from(AclRole)
|
||||||
});
|
.where(eq(AclRole.id, ctx.data.roleId))
|
||||||
|
.limit(1);
|
||||||
if (!role) throw new ActionError("Role not found");
|
if (!role) throw new ActionError("Role not found");
|
||||||
const permissions = await prisma.aclPermission.findMany({
|
const permissions = await db
|
||||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
.select({ id: AclPermission.id })
|
||||||
select: { id: true },
|
.from(AclPermission)
|
||||||
});
|
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
||||||
await prisma.$transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.aclModelPermission.deleteMany({
|
await tx
|
||||||
where: { modelId: role.id, modelType: "Role" },
|
.delete(AclModelPermission)
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(AclModelPermission.modelId, role.id),
|
||||||
|
eq(AclModelPermission.modelType, "Role"),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (permissions.length) {
|
if (permissions.length) {
|
||||||
await tx.aclModelPermission.createMany({
|
await tx.insert(AclModelPermission).values(
|
||||||
data: permissions.map((permission) => ({
|
permissions.map((permission) => ({
|
||||||
modelId: role.id,
|
modelId: role.id,
|
||||||
modelType: "Role",
|
modelType: "Role",
|
||||||
permissionId: permission.id,
|
permissionId: permission.id,
|
||||||
})),
|
})),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -159,7 +184,7 @@ export const setCmsPermissions = adminAction(
|
|||||||
export const repairAdminNavAclGrants = adminAction(
|
export const repairAdminNavAclGrants = adminAction(
|
||||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const dashboardFill = await prisma.$executeRaw`
|
const [dashboardFillResult] = await db.execute(sql`
|
||||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
SELECT 'Role', ar.id, ap.id
|
SELECT 'Role', ar.id, ap.id
|
||||||
FROM \`acl_roles\` ar
|
FROM \`acl_roles\` ar
|
||||||
@@ -179,9 +204,9 @@ export const repairAdminNavAclGrants = adminAction(
|
|||||||
AND amp2.model_id = ar.id
|
AND amp2.model_id = ar.id
|
||||||
AND amp2.permission_id = ap.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\`)
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
SELECT 'Role', ar.id, ap.id
|
SELECT 'Role', ar.id, ap.id
|
||||||
FROM \`permission_ranks\` pr
|
FROM \`permission_ranks\` pr
|
||||||
@@ -198,9 +223,9 @@ export const repairAdminNavAclGrants = adminAction(
|
|||||||
AND amp.model_id = ar.id
|
AND amp.model_id = ar.id
|
||||||
AND amp.permission_id = ap.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\`)
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
SELECT 'Role', ar.id, ap.id
|
SELECT 'Role', ar.id, ap.id
|
||||||
FROM \`permission_ranks\` pr
|
FROM \`permission_ranks\` pr
|
||||||
@@ -226,10 +251,12 @@ export const repairAdminNavAclGrants = adminAction(
|
|||||||
AND amp.model_id = ar.id
|
AND amp.model_id = ar.id
|
||||||
AND amp.permission_id = ap.id
|
AND amp.permission_id = ap.id
|
||||||
)
|
)
|
||||||
`;
|
`);
|
||||||
|
|
||||||
const inserted =
|
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({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -17,7 +16,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
await prisma.items.update({
|
await prisma.items.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
data: data as Prisma.ItemsUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -111,7 +110,7 @@ export async function updateRoom({
|
|||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await prisma.rooms.update({
|
await prisma.rooms.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.RoomsUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
|
|||||||
+1
-2
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -116,7 +115,7 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
const badgeCodes = parseBadgeCodes(article.badges);
|
const badgeCodes = parseBadgeCodes(article.badges);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -81,10 +80,14 @@ export const createUser = adminAction(
|
|||||||
return actionOk({ id: user.id, username: user.username });
|
return actionOk({ id: user.id, username: user.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"code" in err &&
|
||||||
err.code === "P2002"
|
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"))
|
if (target.includes("username"))
|
||||||
throw new ActionError("Username already taken");
|
throw new ActionError("Username already taken");
|
||||||
if (target.includes("mail"))
|
if (target.includes("mail"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
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 { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { count, desc, eq, like } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { EventsTable } from "./events-table";
|
import { EventsTable } from "./events-table";
|
||||||
|
|
||||||
@@ -16,29 +18,44 @@ export default async function EventsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.EVENTS_VIEW,
|
permission: PERMS.EVENTS_VIEW,
|
||||||
model: "websiteEvent",
|
fetch: async ({ search, page, perPage }) => {
|
||||||
searchFields: ["title"],
|
const [rows, totals] = await Promise.all([
|
||||||
defaultSort: { startsAt: "desc" },
|
db
|
||||||
include: { type: { select: { name: true, color: true } } },
|
.select({
|
||||||
mapRow: (e: {
|
id: WebsiteEvent.id,
|
||||||
id: number;
|
title: WebsiteEvent.title,
|
||||||
title: string;
|
startsAt: WebsiteEvent.startsAt,
|
||||||
type: { name: string; color: string };
|
endsAt: WebsiteEvent.endsAt,
|
||||||
startsAt: Date;
|
status: WebsiteEvent.status,
|
||||||
endsAt: Date | null;
|
hostUserId: WebsiteEvent.hostUserId,
|
||||||
status: string;
|
typeName: WebsiteEventType.name,
|
||||||
hostUserId: number;
|
typeColor: WebsiteEventType.color,
|
||||||
_count?: { registrations: number };
|
})
|
||||||
}) => ({
|
.from(WebsiteEvent)
|
||||||
id: e.id,
|
.leftJoin(
|
||||||
title: e.title,
|
WebsiteEventType,
|
||||||
typeName: e.type.name,
|
eq(WebsiteEvent.typeId, WebsiteEventType.id),
|
||||||
typeColor: e.type.color,
|
)
|
||||||
startsAt: e.startsAt,
|
.where(search ? like(WebsiteEvent.title, `%${search}%`) : undefined)
|
||||||
endsAt: e.endsAt,
|
.orderBy(desc(WebsiteEvent.startsAt))
|
||||||
status: e.status,
|
.limit(perPage)
|
||||||
hostUserId: e.hostUserId,
|
.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,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
@@ -25,7 +24,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || "open";
|
const statusFilter = raw.filter_status || "open";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -65,7 +64,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
@@ -79,7 +78,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
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 { Package } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { canAccess, PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
||||||
|
|
||||||
@@ -15,32 +17,56 @@ export default async function ItemsBasePage({
|
|||||||
const result = await fetchAdminList<ItemsBaseRow>(
|
const result = await fetchAdminList<ItemsBaseRow>(
|
||||||
{
|
{
|
||||||
permission: PERMS.CATALOG_VIEW,
|
permission: PERMS.CATALOG_VIEW,
|
||||||
model: "itemsBase",
|
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||||
searchFields: ["itemName", "publicName"],
|
const conditions = [];
|
||||||
defaultSort: { id: "desc" },
|
if (search) {
|
||||||
select: {
|
const ors = [
|
||||||
id: true,
|
like(ItemsBase.itemName, `%${search}%`),
|
||||||
spriteId: true,
|
like(ItemsBase.publicName, `%${search}%`),
|
||||||
publicName: true,
|
];
|
||||||
itemName: true,
|
const asId = Number(search);
|
||||||
type: true,
|
if (Number.isFinite(asId) && asId > 0) {
|
||||||
interactionType: true,
|
ors.push(eq(ItemsBase.id, asId));
|
||||||
width: true,
|
}
|
||||||
length: true,
|
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,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
@@ -24,7 +23,7 @@ export default async function CfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -68,7 +67,7 @@ export default async function CfhListPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.SupportTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
@@ -82,7 +81,7 @@ export default async function CfhListPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
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 { Wifi } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { OnlineTable } from "./online-table";
|
import { OnlineTable } from "./online-table";
|
||||||
|
|
||||||
@@ -15,15 +17,34 @@ export default async function OnlineUsersPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_VIEW,
|
permission: PERMS.USERS_VIEW,
|
||||||
model: "user",
|
|
||||||
searchFields: ["username", "motto"],
|
|
||||||
baseWhere: { online: "1" },
|
|
||||||
defaultPerPage: 50,
|
defaultPerPage: 50,
|
||||||
select: {
|
fetch: async ({ search, page, perPage }) => {
|
||||||
id: true,
|
const conditions: (SQL | undefined)[] = [eq(User.online, "1")];
|
||||||
username: true,
|
if (search) {
|
||||||
motto: true,
|
conditions.push(
|
||||||
look: true,
|
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,
|
paramsPromise,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { asc, count, eq } from "drizzle-orm";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||||
|
import { AclModelPermission, AclPermission, AclRole, db, User } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks";
|
import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks";
|
||||||
import { RankEditClient } from "./rank-edit-client";
|
import { RankEditClient } from "./rank-edit-client";
|
||||||
|
|
||||||
@@ -19,23 +20,32 @@ export default async function RankEditPage({
|
|||||||
const rankId = parseInt(rawId, 10);
|
const rankId = parseInt(rawId, 10);
|
||||||
if (Number.isNaN(rankId)) notFound();
|
if (Number.isNaN(rankId)) notFound();
|
||||||
|
|
||||||
const rank = await fetchEmulatorRankForEdit(prisma, rankId);
|
const rank = await fetchEmulatorRankForEdit(db, rankId);
|
||||||
if (!rank) notFound();
|
if (!rank) notFound();
|
||||||
|
|
||||||
// Fetch users with this rank (limit 200)
|
// Fetch users with this rank (limit 200)
|
||||||
const users = await prisma.user.findMany({
|
const users = await db
|
||||||
where: { rank: rankId },
|
.select({ id: User.id, username: User.username, look: User.look })
|
||||||
select: { id: true, username: true, look: true },
|
.from(User)
|
||||||
orderBy: { username: "asc" },
|
.where(eq(User.rank, rankId))
|
||||||
take: 200,
|
.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
|
// Fetch all CMS permissions
|
||||||
const allCmsPermissions = await prisma.aclPermission.findMany({
|
const allCmsPermissions = await db
|
||||||
orderBy: { slug: "asc" },
|
.select({
|
||||||
});
|
id: AclPermission.id,
|
||||||
|
slug: AclPermission.slug,
|
||||||
|
title: AclPermission.title,
|
||||||
|
})
|
||||||
|
.from(AclPermission)
|
||||||
|
.orderBy(asc(AclPermission.slug));
|
||||||
|
|
||||||
// Auto-create CMS role for rank >= 4
|
// Auto-create CMS role for rank >= 4
|
||||||
let cmsRole: {
|
let cmsRole: {
|
||||||
@@ -46,22 +56,37 @@ export default async function RankEditPage({
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
if (rankId >= 4) {
|
if (rankId >= 4) {
|
||||||
const role = await prisma.aclRole.upsert({
|
const slug = `rank_${rankId}`;
|
||||||
where: { slug: `rank_${rankId}` },
|
const title = rank.rank_name || `Rank ${rankId}`;
|
||||||
create: {
|
const [existing] = await db
|
||||||
slug: `rank_${rankId}`,
|
.select({ id: AclRole.id })
|
||||||
title: rank.rank_name || `Rank ${rankId}`,
|
.from(AclRole)
|
||||||
},
|
.where(eq(AclRole.slug, slug))
|
||||||
update: {},
|
.limit(1);
|
||||||
include: {
|
|
||||||
rolePermissions: { include: { permission: true } },
|
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 = {
|
cmsRole = {
|
||||||
id: role.id,
|
id: roleId,
|
||||||
slug: role.slug,
|
slug,
|
||||||
title: role.title,
|
title,
|
||||||
permissionSlugs: role.rolePermissions.map((rp) => rp.permission.slug),
|
permissionSlugs: permissionRows.map((rp) => rp.slug),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { db } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||||
import { PermissionsList } from "./permissions-list";
|
import { PermissionsList } from "./permissions-list";
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ export default async function PermissionsPage({
|
|||||||
const sp = (await searchParams) ?? {};
|
const sp = (await searchParams) ?? {};
|
||||||
const fromHousekeeping = sp.from === "housekeeping";
|
const fromHousekeeping = sp.from === "housekeeping";
|
||||||
|
|
||||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
const ranks = await fetchEmulatorRankSummaries(db);
|
||||||
|
|
||||||
// User counts per rank
|
// User counts per rank
|
||||||
const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw(
|
const [rawCounts] = (await db.execute(
|
||||||
Prisma.sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
||||||
);
|
)) as unknown as [{ rank: number; cnt: number }[], unknown[]];
|
||||||
const countMap: Record<number, number> = {};
|
const countMap: Record<number, number> = {};
|
||||||
for (const row of rawCounts) {
|
for (const row of rawCounts) {
|
||||||
countMap[Number(row.rank)] = Number(row.cnt);
|
countMap[Number(row.rank)] = Number(row.cnt);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { count, desc, like } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, WebsitePoll } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { PollsTable } from "./polls-table";
|
import { PollsTable } from "./polls-table";
|
||||||
|
|
||||||
@@ -16,28 +18,31 @@ export default async function PollsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.POLLS_VIEW,
|
permission: PERMS.POLLS_VIEW,
|
||||||
model: "websitePoll",
|
fetch: async ({ search, page, perPage }) => {
|
||||||
searchFields: ["title"],
|
const where = search
|
||||||
defaultSort: { createdAt: "desc" },
|
? like(WebsitePoll.title, `%${search}%`)
|
||||||
mapRow: (p: {
|
: undefined;
|
||||||
id: number;
|
const [rows, totals] = await Promise.all([
|
||||||
title: string;
|
db
|
||||||
status: string;
|
.select({
|
||||||
showResults: number;
|
id: WebsitePoll.id,
|
||||||
multipleChoice: number;
|
title: WebsitePoll.title,
|
||||||
startsAt: Date | null;
|
status: WebsitePoll.status,
|
||||||
endsAt: Date | null;
|
showResults: WebsitePoll.showResults,
|
||||||
createdAt: Date;
|
multipleChoice: WebsitePoll.multipleChoice,
|
||||||
}) => ({
|
startsAt: WebsitePoll.startsAt,
|
||||||
id: p.id,
|
endsAt: WebsitePoll.endsAt,
|
||||||
title: p.title,
|
createdAt: WebsitePoll.createdAt,
|
||||||
status: p.status,
|
})
|
||||||
showResults: p.showResults,
|
.from(WebsitePoll)
|
||||||
multipleChoice: p.multipleChoice,
|
.where(where)
|
||||||
startsAt: p.startsAt,
|
.orderBy(desc(WebsitePoll.createdAt))
|
||||||
endsAt: p.endsAt,
|
.limit(perPage)
|
||||||
createdAt: p.createdAt,
|
.offset((page - 1) * perPage),
|
||||||
}),
|
db.select({ total: count() }).from(WebsitePoll).where(where),
|
||||||
|
]);
|
||||||
|
return { rows, total: totals[0]?.total ?? 0 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||||
import { DoorOpen } from "lucide-react";
|
import { DoorOpen } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, Rooms } from "@/lib/db";
|
||||||
import { canAccess, PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { RoomsTable } from "./rooms-table";
|
import { RoomsTable } from "./rooms-table";
|
||||||
|
|
||||||
@@ -15,33 +17,56 @@ export default async function RoomsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.ROOMS_VIEW,
|
permission: PERMS.ROOMS_VIEW,
|
||||||
// Prisma model is `Rooms` → client accessor `rooms`.
|
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||||
// Owner is denormalized on the row (ownerId / ownerName), not a relation.
|
const conditions = [];
|
||||||
model: "rooms",
|
if (search) {
|
||||||
searchFields: ["name", "ownerName"],
|
const ors = [
|
||||||
select: {
|
like(Rooms.name, `%${search}%`),
|
||||||
id: true,
|
like(Rooms.ownerName, `%${search}%`),
|
||||||
name: true,
|
];
|
||||||
ownerId: true,
|
const asId = Number(search);
|
||||||
ownerName: true,
|
if (Number.isFinite(asId) && asId > 0) {
|
||||||
state: true,
|
ors.push(eq(Rooms.id, asId));
|
||||||
users: true,
|
}
|
||||||
usersMax: true,
|
conditions.push(or(...ors));
|
||||||
description: true,
|
}
|
||||||
|
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 }>,
|
paramsPromise as Promise<{ locale: string }>,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
@@ -25,7 +24,7 @@ export default async function AdminTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || raw.status || "active";
|
const statusFilter = raw.filter_status || raw.status || "active";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "active") {
|
if (statusFilter === "active") {
|
||||||
conditions.push({ status: { not: "closed" } });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
: { AND: conditions };
|
: { AND: conditions };
|
||||||
|
|
||||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
const SORT_MAP: Record<string, any> =
|
||||||
{
|
{
|
||||||
subject: { subject: "asc" },
|
subject: { subject: "asc" },
|
||||||
status: { status: "asc" },
|
status: { status: "asc" },
|
||||||
@@ -68,7 +67,7 @@ export default async function AdminTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
@@ -36,7 +35,7 @@ function formatAmount(amount: number, currency: string): string {
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.WebsitePaypalTransactionsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
amount: { amount: "desc" },
|
amount: { amount: "desc" },
|
||||||
@@ -61,7 +60,7 @@ export default async function AdminTransactions({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status ?? "";
|
const statusFilter = raw.filter_status ?? "";
|
||||||
|
|
||||||
const where: Prisma.WebsitePaypalTransactionsWhereInput = {};
|
const where: any = {};
|
||||||
|
|
||||||
if (statusFilter === "paid") {
|
if (statusFilter === "paid") {
|
||||||
where.status = { in: [...PAID_STATUS] };
|
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 orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
let transactions: Awaited<
|
let transactions: Awaited<
|
||||||
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
|
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination } from "@/lib/admin-helpers";
|
import { calcPagination } from "@/lib/admin-helpers";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200);
|
||||||
const page = Math.max(parseInt(sp.page || "1", 10), 1);
|
const page = Math.max(parseInt(sp.page || "1", 10), 1);
|
||||||
|
|
||||||
const patternFilters: Prisma.EmulatorSettingsWhereInput[] =
|
const patternFilters: any[] =
|
||||||
TRANSLATION_KEY_PATTERNS.map((p) => ({
|
TRANSLATION_KEY_PATTERNS.map((p) => ({
|
||||||
key: { contains: p.replace(/%/g, "") },
|
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 (group) conditions.push({ key: { startsWith: `${group}.` } });
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push({
|
conditions.push({
|
||||||
OR: [{ key: { contains: search } }, { value: { contains: search } }],
|
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([
|
const [settings, total, allKeys] = await Promise.all([
|
||||||
prisma.emulatorSettings.findMany({
|
prisma.emulatorSettings.findMany({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getWatchedUserIds } from "@/lib/services/watch";
|
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 { Users } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
import { canAccess, PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { CreateUserDialog } from "./_components/create-user-dialog";
|
import { CreateUserDialog } from "./_components/create-user-dialog";
|
||||||
import { UsersTable } from "./users-table";
|
import { UsersTable } from "./users-table";
|
||||||
@@ -16,30 +18,54 @@ export default async function UsersPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_VIEW,
|
permission: PERMS.USERS_VIEW,
|
||||||
model: "user",
|
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||||
searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
|
const conditions = [];
|
||||||
select: {
|
if (search) {
|
||||||
id: true,
|
const ors = [
|
||||||
username: true,
|
like(User.username, `%${search}%`),
|
||||||
mail: true,
|
like(User.mail, `%${search}%`),
|
||||||
rank: true,
|
like(User.ipRegister, `%${search}%`),
|
||||||
credits: true,
|
like(User.ipCurrent, `%${search}%`),
|
||||||
pixels: true,
|
];
|
||||||
online: true,
|
const asId = Number(search);
|
||||||
motto: true,
|
if (Number.isFinite(asId) && asId > 0) {
|
||||||
look: true,
|
ors.push(eq(User.id, asId));
|
||||||
ipRegister: true,
|
}
|
||||||
accountCreated: true,
|
conditions.push(or(...ors));
|
||||||
lastLogin: true,
|
}
|
||||||
},
|
const rank = Number(rawParams.filter_rank);
|
||||||
filterMap: {
|
if (Number.isFinite(rank)) {
|
||||||
filter_rank: (v) => {
|
|
||||||
const n = Number(v);
|
|
||||||
if (!Number.isFinite(n)) return null;
|
|
||||||
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
||||||
return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
|
conditions.push(rank >= 7 ? gte(User.rank, 7) : eq(User.rank, rank));
|
||||||
},
|
}
|
||||||
filter_online: (v) => ({ online: v }),
|
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,
|
paramsPromise,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
|||||||
interface UserRow {
|
interface UserRow {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
mail: string;
|
mail: string | null;
|
||||||
rank: number;
|
rank: number;
|
||||||
credits: number;
|
credits: number;
|
||||||
pixels: 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 { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiError, apiOk } from "@/lib/api-response";
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
import { PERMS } from "@/lib/permissions";
|
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 { apiJson } from "@/lib/api";
|
||||||
import { bearerUserId } from "@/lib/api-auth";
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
|
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 { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
@@ -20,7 +19,7 @@ export default async function ModCfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -64,7 +63,7 @@ export default async function ModCfhListPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.SupportTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
@@ -78,7 +77,7 @@ export default async function ModCfhListPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "@/app/admin/help-tickets/help-tickets-table";
|
} from "@/app/admin/help-tickets/help-tickets-table";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -25,7 +24,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || "open";
|
const statusFilter = raw.filter_status || "open";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -65,7 +64,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
@@ -79,7 +78,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "@/app/admin/tickets/tickets-table";
|
} from "@/app/admin/tickets/tickets-table";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -25,7 +24,7 @@ export default async function ModTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || raw.status || "active";
|
const statusFilter = raw.filter_status || raw.status || "active";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "active") {
|
if (statusFilter === "active") {
|
||||||
conditions.push({ status: { not: "closed" } });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
: { AND: conditions };
|
: { AND: conditions };
|
||||||
|
|
||||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
const SORT_MAP: Record<string, any> =
|
||||||
{
|
{
|
||||||
subject: { subject: "asc" },
|
subject: { subject: "asc" },
|
||||||
status: { status: "asc" },
|
status: { status: "asc" },
|
||||||
@@ -68,7 +67,7 @@ export default async function ModTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||||
await Promise.all([
|
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 { redirect } from "next/navigation";
|
||||||
import { calcPagination, parseListParams } from "./admin-helpers";
|
import { calcPagination, parseListParams } from "./admin-helpers";
|
||||||
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
|
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> {
|
interface AdminListConfig<TRow> {
|
||||||
/** Permission slug required to view this page */
|
/** Permission slug required to view this page */
|
||||||
permission: string;
|
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 */
|
/** Default items per page. Default: 20 */
|
||||||
defaultPerPage?: number;
|
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
|
* Runs the actual data query (Drizzle). Receives parsed list params and
|
||||||
* the raw string value from the URL and returns the WHERE fragment to merge.
|
* returns the rows + total count for the current page.
|
||||||
* Returning null/undefined drops the filter.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* filterMap: {
|
|
||||||
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
|
||||||
* filter_online: (v) => ({ online: v }),
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
filterMap?: Record<
|
fetch: (args: {
|
||||||
string,
|
search: string;
|
||||||
(value: string) => Record<string, unknown> | null | undefined
|
page: number;
|
||||||
>;
|
perPage: number;
|
||||||
/** Transform raw Prisma row to the shape needed by the table component */
|
rawParams: Record<string, string>;
|
||||||
mapRow?: (row: PrismaModel) => TRow;
|
}) => Promise<{ rows: TRow[]; total: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminListResult<TRow> {
|
interface AdminListResult<TRow> {
|
||||||
@@ -53,35 +30,12 @@ interface AdminListResult<TRow> {
|
|||||||
rank: number;
|
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.
|
* Generic admin list data fetcher.
|
||||||
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
|
* Centralizes the common pattern: auth check, param parsing, pagination and
|
||||||
* Prisma query with pagination, and row mapping.
|
* 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>,
|
config: AdminListConfig<TRow>,
|
||||||
paramsPromise: Promise<{ locale: string }>,
|
paramsPromise: Promise<{ locale: string }>,
|
||||||
searchParamsPromise: Promise<Record<string, string>>,
|
searchParamsPromise: Promise<Record<string, string>>,
|
||||||
@@ -99,64 +53,15 @@ export async function fetchAdminList<TRow = PrismaModel>(
|
|||||||
const perPage = config.defaultPerPage
|
const perPage = config.defaultPerPage
|
||||||
? Number(rawParams.perPage) || config.defaultPerPage
|
? Number(rawParams.perPage) || config.defaultPerPage
|
||||||
: parsed.perPage;
|
: parsed.perPage;
|
||||||
const { search, page, sort, order } = parsed;
|
|
||||||
|
|
||||||
// Build WHERE clause
|
const { rows, total } = await config.fetch({
|
||||||
const searchOr =
|
search: parsed.search,
|
||||||
search && config.searchFields.length > 0
|
page: parsed.page,
|
||||||
? config.searchFields.map((field) => buildFieldCondition(field, search))
|
perPage,
|
||||||
: [];
|
rawParams,
|
||||||
// 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 } : {};
|
|
||||||
|
|
||||||
// Apply URL filter_* params via filterMap
|
const pagination = calcPagination(total, parsed.page, perPage);
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ describe("ACL management contract", () => {
|
|||||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||||
expect(source).toContain("adminAction");
|
expect(source).toContain("adminAction");
|
||||||
expect(source).toContain("aclModelPermission");
|
expect(source).toContain("AclModelPermission");
|
||||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||||
expect(source).not.toContain("websiteTeams");
|
expect(source).not.toContain("websiteTeams");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
export interface StaffActivityFilters {
|
export interface StaffActivityFilters {
|
||||||
q?: string;
|
q?: string;
|
||||||
staffId?: number | null;
|
staffId?: number | null;
|
||||||
@@ -9,8 +7,8 @@ export interface StaffActivityFilters {
|
|||||||
|
|
||||||
export function buildStaffActivityWhere(
|
export function buildStaffActivityWhere(
|
||||||
filters: StaffActivityFilters,
|
filters: StaffActivityFilters,
|
||||||
): Prisma.StaffActivitiesWhereInput {
|
): any {
|
||||||
const where: Prisma.StaffActivitiesWhereInput = {};
|
const where: any = {};
|
||||||
if (filters.q?.trim())
|
if (filters.q?.trim())
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ action: { contains: filters.q.trim() } },
|
{ 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.
|
* 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,
|
poolSize: number,
|
||||||
phase = process.env.NEXT_PHASE,
|
phase = process.env.NEXT_PHASE,
|
||||||
): number {
|
): 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 type { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { env } from "@/env";
|
import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { db } from "@/lib/db";
|
||||||
import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool";
|
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
export const prisma = makePrisma(db) as unknown as PrismaClient;
|
||||||
|
export { PrismaHelper as Prisma };
|
||||||
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;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { asc, sql } from "drizzle-orm";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
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
|
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||||
const pageIdStr = String(pageId);
|
const pageIdStr = String(pageId);
|
||||||
// CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int.
|
// 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
|
SELECT * FROM catalog_items
|
||||||
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
||||||
ORDER BY order_number ASC, id ASC
|
ORDER BY order_number ASC, id ASC
|
||||||
`;
|
`)) as unknown as [Record<string, unknown>[], unknown[]];
|
||||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||||
id: Number(r.id),
|
id: Number(r.id),
|
||||||
itemIds: String(r.item_ids ?? ""),
|
itemIds: String(r.item_ids ?? ""),
|
||||||
@@ -164,13 +164,18 @@ export async function loadCatalogItemsData(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||||
prisma.catalogPages.findMany({
|
db
|
||||||
orderBy: { caption: "asc" },
|
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
||||||
select: { id: true, caption: true },
|
.from(CatalogPages)
|
||||||
}),
|
.orderBy(asc(CatalogPages.caption)),
|
||||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
db
|
||||||
|
.execute(sql`
|
||||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||||
`,
|
`)
|
||||||
|
.then(
|
||||||
|
(result) =>
|
||||||
|
(result as unknown as [{ interaction_type: string }[], unknown[]])[0],
|
||||||
|
),
|
||||||
getHabboGamedataHotel(),
|
getHabboGamedataHotel(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -192,46 +197,46 @@ export async function loadCatalogItemsData(
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
// 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.
|
// baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join.
|
||||||
const baseItems: BaseItem[] =
|
const [baseItemRows] =
|
||||||
baseItemIds.length > 0
|
baseItemIds.length > 0
|
||||||
? (
|
? ((await db.execute(sql`
|
||||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
|
||||||
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
||||||
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
||||||
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
||||||
allow_inventory_stack, interaction_type, interaction_modes_count,
|
allow_inventory_stack, interaction_type, interaction_modes_count,
|
||||||
vending_ids, customparams, effect_id_male, effect_id_female,
|
vending_ids, customparams, effect_id_male, effect_id_female,
|
||||||
clothing_on_walk
|
clothing_on_walk
|
||||||
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)})
|
||||||
`
|
`)) as unknown as [Record<string, unknown>[], unknown[]])
|
||||||
).map((r) => ({
|
: [[], []];
|
||||||
id: Number(r.id),
|
|
||||||
spriteId: Number(r.sprite_id),
|
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
|
||||||
publicName: String(r.public_name ?? ""),
|
id: Number(r.id),
|
||||||
itemName: String(r.item_name ?? ""),
|
spriteId: Number(r.sprite_id),
|
||||||
type: String(r.type ?? "s"),
|
publicName: String(r.public_name ?? ""),
|
||||||
width: Number(r.width) || 1,
|
itemName: String(r.item_name ?? ""),
|
||||||
length: Number(r.length) || 1,
|
type: String(r.type ?? "s"),
|
||||||
stackHeight: Number(r.stack_height) || 0,
|
width: Number(r.width) || 1,
|
||||||
allowStack: Number(r.allow_stack ?? 0),
|
length: Number(r.length) || 1,
|
||||||
allowSit: Number(r.allow_sit ?? 0),
|
stackHeight: Number(r.stack_height) || 0,
|
||||||
allowLay: Number(r.allow_lay ?? 0),
|
allowStack: Number(r.allow_stack ?? 0),
|
||||||
allowWalk: Number(r.allow_walk ?? 0),
|
allowSit: Number(r.allow_sit ?? 0),
|
||||||
allowGift: Number(r.allow_gift ?? 1),
|
allowLay: Number(r.allow_lay ?? 0),
|
||||||
allowTrade: Number(r.allow_trade ?? 1),
|
allowWalk: Number(r.allow_walk ?? 0),
|
||||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
allowGift: Number(r.allow_gift ?? 1),
|
||||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
allowTrade: Number(r.allow_trade ?? 1),
|
||||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||||
interactionType: String(r.interaction_type ?? "default"),
|
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||||
vendingIds: String(r.vending_ids ?? "0"),
|
interactionType: String(r.interaction_type ?? "default"),
|
||||||
customparams: String(r.customparams ?? ""),
|
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||||
effectIdMale: Number(r.effect_id_male) || 0,
|
vendingIds: String(r.vending_ids ?? "0"),
|
||||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
customparams: String(r.customparams ?? ""),
|
||||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
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]));
|
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const findUnique = vi.hoisted(() => vi.fn());
|
const insertValues = vi.hoisted(() => vi.fn());
|
||||||
const findMany = vi.hoisted(() => vi.fn());
|
const selectLimit = vi.hoisted(() => vi.fn());
|
||||||
const update = vi.hoisted(() => vi.fn());
|
const updateSetData = vi.hoisted(() => vi.fn());
|
||||||
const create = vi.hoisted(() => vi.fn());
|
const updateWhere = vi.hoisted(() => vi.fn());
|
||||||
const queryRaw = vi.hoisted(() => vi.fn());
|
const deleteWhere = vi.hoisted(() => vi.fn());
|
||||||
const executeRaw = vi.hoisted(() => vi.fn());
|
const executeMock = vi.hoisted(() => vi.fn());
|
||||||
const catalogDelete = vi.hoisted(() => vi.fn());
|
|
||||||
const catalogUpdateMany = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
catalogPages: {
|
insert: () => ({ values: insertValues }),
|
||||||
findUnique,
|
select: () => ({
|
||||||
findMany,
|
from: () => ({
|
||||||
update,
|
where: () => ({
|
||||||
create,
|
limit: () => Promise.resolve(selectLimit()),
|
||||||
delete: catalogDelete,
|
}),
|
||||||
updateMany: catalogUpdateMany,
|
orderBy: () => ({
|
||||||
},
|
where: () => ({
|
||||||
$queryRaw: queryRaw,
|
limit: () => Promise.resolve(selectLimit()),
|
||||||
$executeRaw: executeRaw,
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
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: {} }));
|
vi.mock("@/env", () => ({ env: {} }));
|
||||||
@@ -121,10 +135,10 @@ describe("buildNestedTree", () => {
|
|||||||
|
|
||||||
describe("createPage", () => {
|
describe("createPage", () => {
|
||||||
it("creates page with default values", async () => {
|
it("creates page with default values", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
await createPage({ parentId: 0, caption: "Test" });
|
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.caption).toBe("Test");
|
||||||
expect(data.pageLayout).toBe("default_3x3");
|
expect(data.pageLayout).toBe("default_3x3");
|
||||||
expect(data.visible).toBe("1");
|
expect(data.visible).toBe("1");
|
||||||
@@ -134,57 +148,51 @@ describe("createPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("truncates captionSave to 25 chars", async () => {
|
it("truncates captionSave to 25 chars", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
await createPage({ parentId: 0, caption: "a".repeat(50) });
|
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 () => {
|
it("converts boolean visible/enabled to string", async () => {
|
||||||
create.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
await createPage({
|
await createPage({
|
||||||
parentId: 0,
|
parentId: 0,
|
||||||
caption: "x",
|
caption: "x",
|
||||||
visible: false,
|
visible: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
expect(create.mock.calls[0][0].data.visible).toBe("0");
|
expect(insertValues.mock.calls[0][0].visible).toBe("0");
|
||||||
expect(create.mock.calls[0][0].data.enabled).toBe("0");
|
expect(insertValues.mock.calls[0][0].enabled).toBe("0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("movePage", () => {
|
describe("movePage", () => {
|
||||||
it("throws on circular hierarchy", async () => {
|
it("throws on circular hierarchy", async () => {
|
||||||
findUnique.mockResolvedValue({ parentId: 1 });
|
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||||
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
|
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows valid move to root", async () => {
|
it("allows valid move to root", async () => {
|
||||||
update.mockResolvedValue({});
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
await movePage(1, 0);
|
await movePage(1, 0);
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
|
||||||
where: { id: 1 },
|
|
||||||
data: { parentId: 0 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows valid move to other parent", async () => {
|
it("allows valid move to other parent", async () => {
|
||||||
findUnique.mockResolvedValue({ parentId: 0 });
|
selectLimit.mockResolvedValue([{ parentId: 0 }]);
|
||||||
update.mockResolvedValue({});
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
await movePage(1, 2);
|
await movePage(1, 2);
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
||||||
where: { id: 1 },
|
|
||||||
data: { parentId: 2 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getAncestors", () => {
|
describe("getAncestors", () => {
|
||||||
it("returns breadcrumb from root to page", async () => {
|
it("returns breadcrumb from root to page", async () => {
|
||||||
findUnique
|
selectLimit
|
||||||
.mockResolvedValueOnce({ id: 3, caption: "Grandchild", parentId: 2 })
|
.mockResolvedValueOnce([{ id: 3, caption: "Grandchild", parentId: 2 }])
|
||||||
.mockResolvedValueOnce({ id: 2, caption: "Child", parentId: 1 })
|
.mockResolvedValueOnce([{ id: 2, caption: "Child", parentId: 1 }])
|
||||||
.mockResolvedValueOnce({ id: 1, caption: "Root", parentId: 0 });
|
.mockResolvedValueOnce([{ id: 1, caption: "Root", parentId: 0 }]);
|
||||||
|
|
||||||
const ancestors = await getAncestors(3);
|
const ancestors = await getAncestors(3);
|
||||||
expect(ancestors).toHaveLength(3);
|
expect(ancestors).toHaveLength(3);
|
||||||
@@ -194,22 +202,19 @@ describe("getAncestors", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stops when page not found", async () => {
|
it("stops when page not found", async () => {
|
||||||
findUnique.mockResolvedValue(null);
|
selectLimit.mockResolvedValue([]);
|
||||||
expect(await getAncestors(999)).toEqual([]);
|
expect(await getAncestors(999)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deletePage", () => {
|
describe("deletePage", () => {
|
||||||
it("reparent mode moves children to parent", async () => {
|
it("reparent mode moves children to parent", async () => {
|
||||||
findUnique.mockResolvedValue({ parentId: 1 });
|
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||||
catalogDelete.mockResolvedValue({});
|
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
||||||
catalogUpdateMany.mockResolvedValue({ count: 2 });
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
|
||||||
const result = await deletePage(5, "reparent");
|
const result = await deletePage(5, "reparent");
|
||||||
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
|
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
|
||||||
expect(catalogUpdateMany).toHaveBeenCalledWith({
|
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
|
||||||
where: { parentId: 5 },
|
|
||||||
data: { parentId: 1 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Prisma } from "@/generated/prisma/client";
|
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
|
|
||||||
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
||||||
|
|
||||||
@@ -23,25 +23,27 @@ export async function getCatalogItemCounts(
|
|||||||
|
|
||||||
if (pageIds) {
|
if (pageIds) {
|
||||||
const idStrs = pageIds.map(String);
|
const idStrs = pageIds.map(String);
|
||||||
const rows = await prisma.$queryRaw<
|
const [rows] = (await db.execute(sql`
|
||||||
Array<{ page_id: string | number; cnt: number | bigint }>
|
|
||||||
>`
|
|
||||||
SELECT page_id, COUNT(*) as cnt
|
SELECT page_id, COUNT(*) as cnt
|
||||||
FROM catalog_items
|
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
|
GROUP BY page_id
|
||||||
`;
|
`)) as unknown as [
|
||||||
|
{ page_id: string | number; cnt: number | bigint }[],
|
||||||
|
unknown[],
|
||||||
|
];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
map.set(toInt(r.page_id), toInt(r.cnt));
|
map.set(toInt(r.page_id), toInt(r.cnt));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<
|
const [rows] = (await db.execute(sql`
|
||||||
Array<{ page_id: string | number; cnt: number | bigint }>
|
|
||||||
>`
|
|
||||||
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id
|
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) {
|
for (const r of rows) {
|
||||||
map.set(toInt(r.page_id), toInt(r.cnt));
|
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.
|
* Get the full catalog tree as a flat list with depth info.
|
||||||
*/
|
*/
|
||||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||||
const allPages = await prisma.catalogPages.findMany({
|
const allPages = await db
|
||||||
orderBy: { orderNum: "asc" },
|
.select()
|
||||||
});
|
.from(CatalogPages)
|
||||||
|
.orderBy(asc(CatalogPages.orderNum));
|
||||||
const itemCountMap = await getCatalogItemCounts();
|
const itemCountMap = await getCatalogItemCounts();
|
||||||
|
|
||||||
const childCountMap = new Map<number, number>();
|
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 pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||||
const depthCache = new Map<number, number>();
|
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;
|
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
||||||
const page = pageMap.get(pageId);
|
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
||||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
if (visiting.has(pageId)) {
|
||||||
if (!page || parentId <= 0) {
|
|
||||||
depthCache.set(pageId, 0);
|
depthCache.set(pageId, 0);
|
||||||
return 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);
|
depthCache.set(pageId, d);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
@@ -154,24 +165,22 @@ export async function createPage(data: {
|
|||||||
: "0"
|
: "0"
|
||||||
: (data.enabled ?? "1");
|
: (data.enabled ?? "1");
|
||||||
|
|
||||||
return prisma.catalogPages.create({
|
return db.insert(CatalogPages).values({
|
||||||
data: {
|
caption: data.caption,
|
||||||
caption: data.caption,
|
parentId: data.parentId,
|
||||||
parentId: data.parentId,
|
pageLayout: data.pageLayout ?? "default_3x3",
|
||||||
pageLayout: data.pageLayout ?? "default_3x3",
|
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||||
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
iconColor: data.iconColor ?? 0,
|
||||||
iconColor: data.iconColor ?? 0,
|
iconImage: data.iconImage ?? 0,
|
||||||
iconImage: data.iconImage ?? 0,
|
minRank: data.minRank ?? 1,
|
||||||
minRank: data.minRank ?? 1,
|
orderNum: data.orderNum ?? 0,
|
||||||
orderNum: data.orderNum ?? 0,
|
visible,
|
||||||
visible,
|
enabled,
|
||||||
enabled,
|
clubOnly: "0",
|
||||||
clubOnly: "0",
|
vipOnly: "0",
|
||||||
vipOnly: "0",
|
pageHeadline: "",
|
||||||
pageHeadline: "",
|
pageTeaser: "",
|
||||||
pageTeaser: "",
|
includes: "",
|
||||||
includes: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,19 +198,20 @@ export async function movePage(
|
|||||||
if (currentId === pageId) {
|
if (currentId === pageId) {
|
||||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||||
}
|
}
|
||||||
const parent = await prisma.catalogPages.findUnique({
|
const [parent] = await db
|
||||||
where: { id: currentId },
|
.select({ parentId: CatalogPages.parentId })
|
||||||
select: { parentId: true },
|
.from(CatalogPages)
|
||||||
});
|
.where(eq(CatalogPages.id, currentId))
|
||||||
|
.limit(1);
|
||||||
if (!parent || parent.parentId <= 0) break;
|
if (!parent || parent.parentId <= 0) break;
|
||||||
currentId = parent.parentId;
|
currentId = parent.parentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.catalogPages.update({
|
await db
|
||||||
where: { id: pageId },
|
.update(CatalogPages)
|
||||||
data: { parentId: newParentId },
|
.set({ parentId: newParentId })
|
||||||
});
|
.where(eq(CatalogPages.id, pageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,10 +221,10 @@ export async function movePage(
|
|||||||
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
||||||
if (pageIds.length === 0) return;
|
if (pageIds.length === 0) return;
|
||||||
const idStrs = pageIds.map(String);
|
const idStrs = pageIds.map(String);
|
||||||
await prisma.$executeRaw`
|
await db.execute(sql`
|
||||||
DELETE FROM catalog_items
|
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,
|
pageId: number,
|
||||||
mode: "cascade" | "reparent" = "reparent",
|
mode: "cascade" | "reparent" = "reparent",
|
||||||
): Promise<{ deletedPages: number; movedChildren: number }> {
|
): Promise<{ deletedPages: number; movedChildren: number }> {
|
||||||
const page = await prisma.catalogPages.findUnique({
|
const [page] = await db
|
||||||
where: { id: pageId },
|
.select({ parentId: CatalogPages.parentId })
|
||||||
select: { parentId: true },
|
.from(CatalogPages)
|
||||||
});
|
.where(eq(CatalogPages.id, pageId))
|
||||||
|
.limit(1);
|
||||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||||
|
|
||||||
if (mode === "reparent") {
|
if (mode === "reparent") {
|
||||||
const result = await prisma.catalogPages.updateMany({
|
const [result] = await db
|
||||||
where: { parentId: pageId },
|
.update(CatalogPages)
|
||||||
data: { parentId: page.parentId },
|
.set({ parentId: page.parentId })
|
||||||
});
|
.where(eq(CatalogPages.parentId, pageId));
|
||||||
|
|
||||||
await deleteCatalogItemsByPageIds([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);
|
const deleted = await cascadeDelete(pageId);
|
||||||
@@ -251,10 +262,10 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
|||||||
const queue: number[] = [pageId];
|
const queue: number[] = [pageId];
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const children = await prisma.catalogPages.findMany({
|
const children = await db
|
||||||
where: { parentId: { in: queue } },
|
.select({ id: CatalogPages.id })
|
||||||
select: { id: true },
|
.from(CatalogPages)
|
||||||
});
|
.where(inArray(CatalogPages.parentId, queue));
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
toDelete.push(child.id);
|
toDelete.push(child.id);
|
||||||
@@ -264,7 +275,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
|||||||
|
|
||||||
await deleteCatalogItemsByPageIds(toDelete);
|
await deleteCatalogItemsByPageIds(toDelete);
|
||||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
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;
|
return toDelete.length;
|
||||||
@@ -279,10 +290,15 @@ export async function getAncestors(
|
|||||||
const ancestors: Array<{ id: number; caption: string }> = [];
|
const ancestors: Array<{ id: number; caption: string }> = [];
|
||||||
let currentId = pageId;
|
let currentId = pageId;
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const page = await prisma.catalogPages.findUnique({
|
const [page] = await db
|
||||||
where: { id: currentId },
|
.select({
|
||||||
select: { id: true, caption: true, parentId: true },
|
id: CatalogPages.id,
|
||||||
});
|
caption: CatalogPages.caption,
|
||||||
|
parentId: CatalogPages.parentId,
|
||||||
|
})
|
||||||
|
.from(CatalogPages)
|
||||||
|
.where(eq(CatalogPages.id, currentId))
|
||||||
|
.limit(1);
|
||||||
if (!page) break;
|
if (!page) break;
|
||||||
ancestors.unshift({ id: page.id, caption: page.caption });
|
ancestors.unshift({ id: page.id, caption: page.caption });
|
||||||
if (page.parentId <= 0) break;
|
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}$/;
|
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
||||||
|
|
||||||
@@ -41,11 +43,18 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
|
|||||||
permissionMaxValues: Record<string, number>;
|
permissionMaxValues: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawDb = {
|
/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */
|
||||||
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>;
|
type RawDb = Pick<Db, "execute" | "transaction">;
|
||||||
$executeRaw: (query: Prisma.Sql) => Promise<number>;
|
|
||||||
$transaction: <T>(queries: Promise<T>[]) => Promise<T[]>;
|
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) {
|
export function getRankPermissionColumn(rankId: number) {
|
||||||
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
|
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
|
||||||
@@ -79,8 +88,9 @@ export function splitRankUpdateFields(fields: Record<string, string | number>) {
|
|||||||
export async function fetchEmulatorRankSummaries(
|
export async function fetchEmulatorRankSummaries(
|
||||||
db: RawDb,
|
db: RawDb,
|
||||||
): Promise<EmulatorRankSummary[]> {
|
): Promise<EmulatorRankSummary[]> {
|
||||||
const rows = await db.$queryRaw<Record<string, unknown>[]>(
|
const rows = await rawRows<Record<string, unknown>[]>(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
|
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
|
||||||
FROM permission_ranks
|
FROM permission_ranks
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
@@ -102,8 +112,9 @@ export async function fetchEmulatorRankForEdit(
|
|||||||
db: RawDb,
|
db: RawDb,
|
||||||
rankId: number,
|
rankId: number,
|
||||||
): Promise<EmulatorRankForEdit | null> {
|
): Promise<EmulatorRankForEdit | null> {
|
||||||
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
|
const rankRows = await rawRows<Record<string, unknown>[]>(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
||||||
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
||||||
auto_gotw_amount, auto_points_amount
|
auto_gotw_amount, auto_points_amount
|
||||||
@@ -114,15 +125,16 @@ export async function fetchEmulatorRankForEdit(
|
|||||||
if (rankRows.length === 0) return null;
|
if (rankRows.length === 0) return null;
|
||||||
|
|
||||||
const rankColumn = getRankPermissionColumn(rankId);
|
const rankColumn = getRankPermissionColumn(rankId);
|
||||||
const permissionRows = await db.$queryRaw<
|
const permissionRows = await rawRows<
|
||||||
{
|
{
|
||||||
permission_key: string;
|
permission_key: string;
|
||||||
max_value: number;
|
max_value: number;
|
||||||
value: number | string | null;
|
value: number | string | null;
|
||||||
}[]
|
}[]
|
||||||
>(
|
>(
|
||||||
Prisma.sql`
|
db,
|
||||||
SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value
|
sql`
|
||||||
|
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
|
||||||
FROM permission_definitions
|
FROM permission_definitions
|
||||||
ORDER BY permission_key ASC
|
ORDER BY permission_key ASC
|
||||||
`,
|
`,
|
||||||
@@ -159,40 +171,41 @@ export async function createEmulatorRank(
|
|||||||
db: RawDb,
|
db: RawDb,
|
||||||
data: { rank_name: string; level: number },
|
data: { rank_name: string; level: number },
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await db.$queryRaw<{ next_id: number }[]>(
|
const rows = await rawRows<{ next_id: number }[]>(
|
||||||
Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
db,
|
||||||
|
sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
||||||
);
|
);
|
||||||
const id = Number(rows[0]?.next_id ?? 1);
|
const id = Number(rows[0]?.next_id ?? 1);
|
||||||
await ensurePermissionRankColumn(db, id);
|
await ensurePermissionRankColumn(db, id);
|
||||||
await resetPermissionRankColumn(db, id);
|
await resetPermissionRankColumn(db, id);
|
||||||
|
|
||||||
await db.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
db.$executeRaw(
|
await rawExec(
|
||||||
Prisma.sql`
|
tx,
|
||||||
|
sql`
|
||||||
INSERT INTO permission_ranks (id, rank_name, level)
|
INSERT INTO permission_ranks (id, rank_name, level)
|
||||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||||
`,
|
`,
|
||||||
),
|
);
|
||||||
db.$executeRaw(
|
await rawExec(
|
||||||
Prisma.sql`
|
tx,
|
||||||
|
sql`
|
||||||
INSERT INTO permissions (id, rank_name, level)
|
INSERT INTO permissions (id, rank_name, level)
|
||||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||||
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
|
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
|
||||||
`,
|
`,
|
||||||
),
|
);
|
||||||
]);
|
});
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEmulatorRank(db: RawDb, rankId: number) {
|
export async function deleteEmulatorRank(db: RawDb, rankId: number) {
|
||||||
await resetPermissionRankColumn(db, rankId);
|
await resetPermissionRankColumn(db, rankId);
|
||||||
await db.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
db.$executeRaw(
|
await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
|
||||||
Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`,
|
await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`);
|
||||||
),
|
});
|
||||||
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmulatorRank(
|
export async function updateEmulatorRank(
|
||||||
@@ -220,13 +233,13 @@ async function updateTableFields(
|
|||||||
fields: Record<string, string | number>,
|
fields: Record<string, string | number>,
|
||||||
) {
|
) {
|
||||||
const assignments = Object.entries(fields).map(
|
const assignments = Object.entries(fields).map(
|
||||||
([key, value]) =>
|
([key, value]) => sql`${sql.raw(quoteIdentifier(key))} = ${value}`,
|
||||||
Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
|
|
||||||
);
|
);
|
||||||
return db.$executeRaw(
|
return rawExec(
|
||||||
Prisma.sql`
|
db,
|
||||||
UPDATE ${Prisma.raw(quoteIdentifier(table))}
|
sql`
|
||||||
SET ${Prisma.join(assignments, ", ")}
|
UPDATE ${sql.raw(quoteIdentifier(table))}
|
||||||
|
SET ${sql.join(assignments, sql`, `)}
|
||||||
WHERE id = ${rankId}
|
WHERE id = ${rankId}
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
@@ -240,19 +253,20 @@ async function updatePermissionDefinitionValues(
|
|||||||
const rankColumn = getRankPermissionColumn(rankId);
|
const rankColumn = getRankPermissionColumn(rankId);
|
||||||
const entries = Object.entries(permissionFields);
|
const entries = Object.entries(permissionFields);
|
||||||
const cases = entries.map(
|
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);
|
const keys = entries.map(([key]) => key);
|
||||||
|
|
||||||
return db.$executeRaw(
|
return rawExec(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
UPDATE permission_definitions
|
UPDATE permission_definitions
|
||||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
|
SET ${sql.raw(quoteIdentifier(rankColumn))} =
|
||||||
CASE permission_key
|
CASE permission_key
|
||||||
${Prisma.join(cases, " ")}
|
${sql.join(cases, sql` `)}
|
||||||
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
|
ELSE ${sql.raw(quoteIdentifier(rankColumn))}
|
||||||
END
|
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) {
|
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
||||||
const rankColumn = getRankPermissionColumn(rankId);
|
const rankColumn = getRankPermissionColumn(rankId);
|
||||||
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
|
const exists = await rawRows<{ c: number }[]>(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
SELECT COUNT(*) AS c
|
SELECT COUNT(*) AS c
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
@@ -283,27 +298,30 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
|||||||
);
|
);
|
||||||
if (Number(exists[0]?.c ?? 0) > 0) return;
|
if (Number(exists[0]?.c ?? 0) > 0) return;
|
||||||
|
|
||||||
await db.$executeRaw(
|
await rawExec(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
ALTER TABLE permission_definitions
|
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) {
|
async function resetPermissionRankColumn(db: RawDb, rankId: number) {
|
||||||
const rankColumn = getRankPermissionColumn(rankId);
|
const rankColumn = getRankPermissionColumn(rankId);
|
||||||
await db.$executeRaw(
|
await rawExec(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
UPDATE permission_definitions
|
UPDATE permission_definitions
|
||||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0
|
SET ${sql.raw(quoteIdentifier(rankColumn))} = 0
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTableColumns(db: RawDb, table: string) {
|
async function getTableColumns(db: RawDb, table: string) {
|
||||||
const rows = await db.$queryRaw<{ column_name: string }[]>(
|
const rows = await rawRows<{ column_name: string }[]>(
|
||||||
Prisma.sql`
|
db,
|
||||||
|
sql`
|
||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE()
|
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