Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd46094256 |
No files matched your search
@@ -43,9 +43,7 @@ jobs:
|
|||||||
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Generate Prisma type stubs for facade type-checking (dev only).
|
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
# Run lint + typecheck + tests on the Drizzle-backed codebase.
|
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
@@ -141,8 +139,9 @@ jobs:
|
|||||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Generate Prisma type stubs (for facade type-checking) — no DB connection needed.
|
# prisma generate only needs DATABASE_URL to resolve the schema — no live DB connection.
|
||||||
# Drizzle schema (src/db/schema.ts) is committed and does not require generation.
|
# Use a placeholder so it doesn't fail if .env isn't loaded yet; db:migrate later
|
||||||
|
# will use the real DATABASE_URL from the live .env symlink.
|
||||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
unset DATABASE_URL
|
unset DATABASE_URL
|
||||||
@@ -205,7 +204,7 @@ jobs:
|
|||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
mv "${STAGE}/node_modules" node_modules
|
mv "${STAGE}/node_modules" node_modules
|
||||||
|
|
||||||
# Regenerate Prisma type stubs into live src/generated/ (gitignored build artifact).
|
# Prisma client is gitignored — regenerate into live src/generated.
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
|
||||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
@@ -338,7 +337,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "# EpicNext-CMS ${VERSION}"
|
echo "# EpicNext-CMS ${VERSION}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Drizzle ORM. Integrates with Polaris / Arcturus Morningstar databases."
|
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Prisma 7. Integrates with Polaris / Arcturus Morningstar databases."
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Menu"
|
echo "## Menu"
|
||||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||||
@@ -397,13 +396,11 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 4. Generate Prisma Type Stubs (Dev Only)"
|
echo "### 4. Generate Prisma Client"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "pnpm prisma:generate"
|
echo "pnpm prisma:generate"
|
||||||
echo '```'
|
echo '```'
|
||||||
echo ""
|
echo ""
|
||||||
echo "Generates TypeScript types in src/generated/prisma/ for the Prisma compatibility facade (types only — no runtime Prisma engine in production). New code should use Drizzle ORM directly via '@/lib/db'."
|
|
||||||
echo ""
|
|
||||||
echo "### 5. Run CMS Migrations"
|
echo "### 5. Run CMS Migrations"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "pnpm db:migrate"
|
echo "pnpm db:migrate"
|
||||||
|
|||||||
@@ -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 **Drizzle ORM** 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 **Prisma 7** 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,9 +37,7 @@ 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 Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them.
|
||||||
|
|
||||||
> **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
|
||||||
|
|
||||||
@@ -58,45 +56,13 @@ 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. ORM Setup & Type Generation
|
### 4. Generate Prisma Client
|
||||||
|
|
||||||
#### Drizzle ORM (Primary Data Layer)
|
|
||||||
|
|
||||||
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { users } from "@/db/schema";
|
|
||||||
import { eq } from "drizzle-orm/expressions";
|
|
||||||
|
|
||||||
const found = await db.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.username, "hello"));
|
|
||||||
```
|
|
||||||
|
|
||||||
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
| ------- | ------------ |
|
|
||||||
| `npx drizzle-kit generate --dialect mysql --schema src/db/schema.ts --out src/db/migrations` | Inspect the Drizzle schema and emit migration SQL |
|
|
||||||
| `npx drizzle-kit studio` | Open a local DB browser (dev only) |
|
|
||||||
| `npx drizzle-kit introspect` | Reverse-engineer an existing DB into a Drizzle schema |
|
|
||||||
|
|
||||||
> The CMS does **not** use `drizzle-kit push` — the database is owned by the emulator and is never auto-migrated. CMS-owned tables are created via the SQL migration runner (step 5).
|
|
||||||
|
|
||||||
#### Prisma Compatibility Facade (Backwards Compatibility)
|
|
||||||
|
|
||||||
A Prisma-compatible facade at `@/lib/prisma` allows existing code to keep calling `prisma.users.findMany()` without refactoring. At runtime, the facade routes every query through Drizzle. **There is zero Prisma client or query-engine overhead in production.**
|
|
||||||
|
|
||||||
Generate the Prisma type stubs used for type-checking the facade:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates `src/generated/prisma/` (a local, `gitignore`d build artifact) with TypeScript types only. It is never shipped in the production bundle.
|
Generates TypeScript types in `src/generated/prisma/`.
|
||||||
|
|
||||||
> **Legacy CLI command removed:** The `prisma` CLI is now a devDependency used **only** for type generation. Old commands such as `prisma migrate dev`, `prisma studio`, or `prisma db push` are no longer applicable — use the SQL migration runner (`pnpm db:migrate`) or Drizzle CLI instead.
|
|
||||||
|
|
||||||
### 5. Run CMS Migrations
|
### 5. Run CMS Migrations
|
||||||
|
|
||||||
@@ -164,18 +130,10 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `pnpm db:migrate:status` | Show migration status |
|
||||||
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
|
||||||
| `pnpm analyze` | Build + open bundle analyzer |
|
| `pnpm analyze` | Build + open bundle analyzer |
|
||||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||||
| `pnpm biome:check` | Lint and format code |
|
| `pnpm biome:check` | Lint and format code |
|
||||||
|
|
||||||
**Drizzle CLI (dev only, run with `npx`):**
|
|
||||||
| Command | Description |
|
|
||||||
| ------- | ----------- |
|
|
||||||
| `drizzle-kit generate` | Generate migration SQL from Drizzle schema |
|
|
||||||
| `drizzle-kit studio` | Local Drizzle Studio database browser |
|
|
||||||
| `drizzle-kit introspect` | Reverse-engineer DB → Drizzle schema |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Performance Features
|
## Performance Features
|
||||||
@@ -203,26 +161,20 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only (legacy)
|
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
||||||
│ └── migrations/ # 19 SQL migrations for CMS tables (idempotent, never re-run)
|
│ └── migrations/ # 16 SQL migrations for CMS tables
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
│ ├── apply-migrations.ts # Custom migration runner
|
||||||
│ ├── jobs-worker.ts # Background task scheduler
|
│ ├── jobs-worker.ts # Background task scheduler
|
||||||
│ ├── merge-config.cjs # Utility: merge split config files
|
│ └── sql-statements.ts # SQL parsing utilities
|
||||||
│ └── generate-drizzle-schema.mjs # One-off: generate src/db/schema.ts from schema.prisma
|
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
|
|
||||||
│ │ └── migrations/ # Drizzle migration files (local dev only)
|
|
||||||
│ ├── 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 # Prisma-compatible facade (routes to Drizzle at runtime)
|
│ │ ├── prisma.ts # Database connection singleton
|
||||||
│ │ ├── 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
|
||||||
@@ -243,19 +195,9 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| Component | Type | Migrations |
|
| Component | Type | Migrations |
|
||||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
||||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (19 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
|
||||||
@@ -319,8 +261,6 @@ pnpm biome:check # Lint and format
|
|||||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
||||||
4. SQL migrations in `prisma/migrations/` must be idempotent
|
4. SQL migrations in `prisma/migrations/` must be idempotent
|
||||||
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
|
|
||||||
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable (e.g., Prisma facade compatibility)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
+9
-1
@@ -21,7 +21,15 @@ const securityHeaders = [
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {},
|
turbopack: {},
|
||||||
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
serverExternalPackages: [
|
||||||
|
"@prisma/adapter-mariadb",
|
||||||
|
"mariadb",
|
||||||
|
"@prisma/client",
|
||||||
|
"lzma",
|
||||||
|
"sharp",
|
||||||
|
"pino",
|
||||||
|
"pino-pretty",
|
||||||
|
],
|
||||||
|
|
||||||
// Enable React Compiler for automatic memoization
|
// Enable React Compiler for automatic memoization
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|||||||
+3
-4
@@ -37,13 +37,14 @@
|
|||||||
"@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.21.0",
|
"isomorphic-dompurify": "^3.21.0",
|
||||||
@@ -75,7 +76,6 @@
|
|||||||
"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,9 +85,8 @@
|
|||||||
"@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.31.0",
|
"knip": "^6.30.0",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.25",
|
||||||
|
|||||||
Generated
+58
-645
File diff suppressed because it is too large.
Load diff
@@ -1,578 +0,0 @@
|
|||||||
#!/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) {",
|
|
||||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
|
||||||
" 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) {",
|
|
||||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
|
||||||
" 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)`);
|
|
||||||
+25
-31
@@ -1,53 +1,47 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require('fs');
|
||||||
const JSON5 = require("json5");
|
const JSON5 = require('json5');
|
||||||
|
|
||||||
const exampleFile = process.argv[2];
|
const exampleFile = process.argv[2];
|
||||||
const targetFile = process.argv[3];
|
const targetFile = process.argv[3];
|
||||||
|
|
||||||
if (!exampleFile || !targetFile) {
|
if (!exampleFile || !targetFile) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deepMerge(target, source) {
|
function deepMerge(target, source) {
|
||||||
const result = { ...target };
|
const result = { ...target };
|
||||||
for (const key of Object.keys(source)) {
|
for (const key of Object.keys(source)) {
|
||||||
if (
|
if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
source[key] !== null &&
|
result[key] = deepMerge(target[key] || {}, source[key]);
|
||||||
typeof source[key] === "object" &&
|
} else {
|
||||||
!Array.isArray(source[key])
|
if (!(key in result)) {
|
||||||
) {
|
result[key] = source[key];
|
||||||
result[key] = deepMerge(target[key] || {}, source[key]);
|
}
|
||||||
} else {
|
}
|
||||||
if (!(key in result)) {
|
}
|
||||||
result[key] = source[key];
|
return result;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let example;
|
let example;
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(exampleFile, "utf-8");
|
const content = fs.readFileSync(exampleFile, 'utf-8');
|
||||||
example = JSON5.parse(content);
|
example = JSON5.parse(content);
|
||||||
} catch {
|
} catch {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = {};
|
let current = {};
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(targetFile)) {
|
if (fs.existsSync(targetFile)) {
|
||||||
const content = fs.readFileSync(targetFile, "utf-8");
|
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||||
current = JSON5.parse(content);
|
current = JSON5.parse(content);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
current = {};
|
current = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = deepMerge(current, example);
|
const merged = deepMerge(current, example);
|
||||||
|
|
||||||
const isJson5 = targetFile.endsWith(".json5");
|
const isJson5 = targetFile.endsWith('.json5');
|
||||||
const output = isJson5
|
const output = isJson5 ? JSON5.stringify(merged, null, 4) : JSON.stringify(merged, null, 4);
|
||||||
? JSON5.stringify(merged, null, 4)
|
fs.writeFileSync(targetFile, output + '\n');
|
||||||
: JSON.stringify(merged, null, 4);
|
|
||||||
fs.writeFileSync(targetFile, `${output}\n`);
|
|
||||||
@@ -16,7 +16,7 @@ vi.mock("@/lib/prisma", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
adminAction: vi.fn((_o: unknown, f: Function) => f),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
ActionError: class extends Error {},
|
ActionError: class extends Error {},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { sendHotelAlert } from "./admin-alerts";
|
import { sendHotelAlert } from "./admin-alerts";
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
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 { createBan, liftBan } from "./admin-bans";
|
import { createBan, liftBan } from "./admin-bans";
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"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",
|
||||||
@@ -43,7 +45,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
banExpire,
|
banExpire,
|
||||||
banReason: reason,
|
banReason: reason,
|
||||||
type: type as any,
|
type: type as BanType,
|
||||||
cfhTopic: -1,
|
cfhTopic: -1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { disbandGuild } from "./admin-guilds";
|
import { disbandGuild } from "./admin-guilds";
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import {
|
import {
|
||||||
createHelpQuestion,
|
createHelpQuestion,
|
||||||
deleteHelpQuestion,
|
deleteHelpQuestion,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
addBlacklist,
|
addBlacklist,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { deletePhoto } from "./admin-photos";
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { createTeam, deleteTeam } from "./admin-teams";
|
import { createTeam, deleteTeam } from "./admin-teams";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { saveVpn } from "./admin-vpn";
|
import { saveVpn } from "./admin-vpn";
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { env } from "@/env";
|
||||||
import { checkLogin } from "@/lib/auth/password";
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
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 {
|
import {
|
||||||
bulkBan,
|
bulkBan,
|
||||||
bulkGiveBadge,
|
bulkGiveBadge,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"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 +58,7 @@ export async function updateBcPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await prisma.catalogPagesBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as any,
|
data: data as Prisma.CatalogPagesBcUpdateInput,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -103,7 +104,7 @@ export async function updateBcItem({
|
|||||||
}
|
}
|
||||||
await prisma.catalogItemsBc.update({
|
await prisma.catalogItemsBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safe as any,
|
data: safe as Prisma.CatalogItemsBcUpdateInput,
|
||||||
});
|
});
|
||||||
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 (${sql.join(ids, sql`, `)})
|
WHERE id IN (${Prisma.join(ids)})
|
||||||
`;
|
`;
|
||||||
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 any,
|
data: safeCatalog as Prisma.CatalogItemsUpdateInput,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 any,
|
data: safeBase as Prisma.ItemsBaseUpdateInput,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"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";
|
||||||
@@ -56,7 +57,7 @@ export async function updateCatalogPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPages.update({
|
await prisma.catalogPages.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as any,
|
data: data as Prisma.CatalogPagesUpdateInput,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
+22
-19
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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";
|
||||||
@@ -88,27 +89,29 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
// Atomically deduct credits and persist the badge so a failure
|
// Atomically deduct credits and persist the badge so a failure
|
||||||
// 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(async (tx) => {
|
await prisma.$transaction(
|
||||||
await tx.user.update({
|
async (tx: Prisma.TransactionClient) => {
|
||||||
where: { id: userId },
|
await tx.user.update({
|
||||||
data: { credits: { decrement: price } },
|
where: { id: userId },
|
||||||
});
|
data: { credits: { decrement: price } },
|
||||||
|
});
|
||||||
|
|
||||||
const existing = await tx.usersBadges.findFirst({
|
const existing = await tx.usersBadges.findFirst({
|
||||||
where: { userId, badgeCode: code },
|
where: { userId, badgeCode: code },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
const max = await tx.usersBadges.aggregate({
|
|
||||||
where: { userId },
|
|
||||||
_max: { slotId: true },
|
|
||||||
});
|
});
|
||||||
const slotId = (max._max.slotId ?? 0) + 1;
|
if (!existing) {
|
||||||
await tx.usersBadges.create({
|
const max = await tx.usersBadges.aggregate({
|
||||||
data: { userId, slotId, badgeCode: code },
|
where: { userId },
|
||||||
});
|
_max: { slotId: true },
|
||||||
}
|
});
|
||||||
});
|
const slotId = (max._max.slotId ?? 0) + 1;
|
||||||
|
await tx.usersBadges.create({
|
||||||
|
data: { userId, slotId, badgeCode: code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant the badge live so it appears immediately for online users.
|
// Grant the badge live so it appears immediately for online users.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
@@ -10,7 +14,7 @@ vi.mock("@/lib/prisma", () => ({
|
|||||||
prisma: { emulatorSettings: { upsert: vi.fn() } },
|
prisma: { emulatorSettings: { upsert: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
adminAction: vi.fn((_opts: unknown, fn: (...args: unknown[]) => unknown) => fn),
|
adminAction: vi.fn((_opts: unknown, fn: Function) => fn),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
||||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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";
|
||||||
@@ -91,12 +92,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 = Number(data.stackHeight);
|
data.stackHeight = new Prisma.Decimal(String(data.stackHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.itemsBase.update({
|
await prisma.itemsBase.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as any,
|
data: data as Prisma.ItemsBaseUpdateInput,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog().catch(() => false);
|
await rcon.updateCatalog().catch(() => false);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
+47
-74
@@ -1,18 +1,9 @@
|
|||||||
"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 {
|
||||||
@@ -31,15 +22,16 @@ 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(db, ctx.data);
|
const id = await createEmulatorRank(prisma, ctx.data);
|
||||||
await db
|
await prisma.aclRole.upsert({
|
||||||
.insert(AclRole)
|
where: { slug: `rank_${id}` },
|
||||||
.values({
|
create: {
|
||||||
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",
|
||||||
})
|
},
|
||||||
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
update: { 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",
|
||||||
@@ -58,32 +50,21 @@ 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 [userCount] = await db
|
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||||
.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 db
|
const role = await prisma.aclRole.findFirst({
|
||||||
.select({ id: AclRole.id })
|
where: { slug: `rank_${ctx.data.id}` },
|
||||||
.from(AclRole)
|
});
|
||||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||||
.limit(1);
|
|
||||||
await deleteEmulatorRank(db, ctx.data.id);
|
|
||||||
if (role) {
|
if (role) {
|
||||||
await db.transaction(async (tx) => {
|
await prisma.$transaction([
|
||||||
await tx
|
prisma.aclModelPermission.deleteMany({
|
||||||
.delete(AclModelPermission)
|
where: { modelId: role.id, modelType: "Role" },
|
||||||
.where(
|
}),
|
||||||
and(
|
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||||
eq(AclModelPermission.modelId, role.id),
|
prisma.aclRole.delete({ where: { id: 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,
|
||||||
@@ -106,12 +87,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(db, ctx.data.id, ctx.data.fields);
|
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||||
if (typeof ctx.data.fields.rank_name === "string") {
|
if (typeof ctx.data.fields.rank_name === "string") {
|
||||||
await db
|
await prisma.aclRole.updateMany({
|
||||||
.update(AclRole)
|
where: { slug: `rank_${ctx.data.id}` },
|
||||||
.set({ title: ctx.data.fields.rank_name })
|
data: { 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,
|
||||||
@@ -134,33 +115,27 @@ 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 db
|
const role = await prisma.aclRole.findUnique({
|
||||||
.select({ id: AclRole.id, slug: AclRole.slug })
|
where: { id: ctx.data.roleId },
|
||||||
.from(AclRole)
|
select: { id: true, slug: true },
|
||||||
.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 db
|
const permissions = await prisma.aclPermission.findMany({
|
||||||
.select({ id: AclPermission.id })
|
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||||
.from(AclPermission)
|
select: { id: true },
|
||||||
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
});
|
||||||
await db.transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx
|
await tx.aclModelPermission.deleteMany({
|
||||||
.delete(AclModelPermission)
|
where: { modelId: role.id, modelType: "Role" },
|
||||||
.where(
|
});
|
||||||
and(
|
|
||||||
eq(AclModelPermission.modelId, role.id),
|
|
||||||
eq(AclModelPermission.modelType, "Role"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (permissions.length) {
|
if (permissions.length) {
|
||||||
await tx.insert(AclModelPermission).values(
|
await tx.aclModelPermission.createMany({
|
||||||
permissions.map((permission) => ({
|
data: permissions.map((permission) => ({
|
||||||
modelId: role.id,
|
modelId: role.id,
|
||||||
modelType: "Role",
|
modelType: "Role",
|
||||||
permissionId: permission.id,
|
permissionId: permission.id,
|
||||||
})),
|
})),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -184,7 +159,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 [dashboardFillResult] = await db.execute(sql`
|
const dashboardFill = await prisma.$executeRaw`
|
||||||
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
|
||||||
@@ -204,9 +179,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 [midRankViewsResult] = await db.execute(sql`
|
const midRankViews = await prisma.$executeRaw`
|
||||||
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
|
||||||
@@ -223,9 +198,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 [highRankToolsResult] = await db.execute(sql`
|
const highRankTools = await prisma.$executeRaw`
|
||||||
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
|
||||||
@@ -251,12 +226,10 @@ 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((dashboardFillResult as ResultSetHeader).affectedRows) +
|
Number(dashboardFill) + Number(midRankViews) + Number(highRankTools);
|
||||||
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,6 +1,7 @@
|
|||||||
"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";
|
||||||
@@ -16,7 +17,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 any,
|
data: data as Prisma.ItemsUpdateInput,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -110,7 +111,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 any,
|
data: data as Prisma.RoomsUpdateInput,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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";
|
||||||
@@ -115,7 +116,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) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { invalidateLoginCache } from "@/lib/auth";
|
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,14 +81,10 @@ 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 &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
typeof err === "object" &&
|
|
||||||
"code" in err &&
|
|
||||||
err.code === "P2002"
|
err.code === "P2002"
|
||||||
) {
|
) {
|
||||||
const target =
|
const target = (err.meta?.target as string[]) ?? [];
|
||||||
((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"))
|
||||||
@@ -120,7 +116,6 @@ export const updateUser = adminAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({ where: { id }, data: userData });
|
await prisma.user.update({ where: { id }, data: userData });
|
||||||
invalidateLoginCache(targetUser.username);
|
|
||||||
|
|
||||||
if (diamonds !== undefined) {
|
if (diamonds !== undefined) {
|
||||||
await prisma.usersCurrency.upsert({
|
await prisma.usersCurrency.upsert({
|
||||||
@@ -315,7 +310,6 @@ export const resetPassword = adminAction(
|
|||||||
where: { id: ctx.data.userId },
|
where: { id: ctx.data.userId },
|
||||||
data: { password: hashed },
|
data: { password: hashed },
|
||||||
});
|
});
|
||||||
invalidateLoginCache(target.username);
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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 { 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, prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
|
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
|
||||||
import { CatalogActions } from "./catalog-actions";
|
import { CatalogActions } from "./catalog-actions";
|
||||||
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
|
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -18,44 +16,29 @@ export default async function EventsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.EVENTS_VIEW,
|
permission: PERMS.EVENTS_VIEW,
|
||||||
fetch: async ({ search, page, perPage }) => {
|
model: "websiteEvent",
|
||||||
const [rows, totals] = await Promise.all([
|
searchFields: ["title"],
|
||||||
db
|
defaultSort: { startsAt: "desc" },
|
||||||
.select({
|
include: { type: { select: { name: true, color: true } } },
|
||||||
id: WebsiteEvent.id,
|
mapRow: (e: {
|
||||||
title: WebsiteEvent.title,
|
id: number;
|
||||||
startsAt: WebsiteEvent.startsAt,
|
title: string;
|
||||||
endsAt: WebsiteEvent.endsAt,
|
type: { name: string; color: string };
|
||||||
status: WebsiteEvent.status,
|
startsAt: Date;
|
||||||
hostUserId: WebsiteEvent.hostUserId,
|
endsAt: Date | null;
|
||||||
typeName: WebsiteEventType.name,
|
status: string;
|
||||||
typeColor: WebsiteEventType.color,
|
hostUserId: number;
|
||||||
})
|
_count?: { registrations: number };
|
||||||
.from(WebsiteEvent)
|
}) => ({
|
||||||
.leftJoin(
|
id: e.id,
|
||||||
WebsiteEventType,
|
title: e.title,
|
||||||
eq(WebsiteEvent.typeId, WebsiteEventType.id),
|
typeName: e.type.name,
|
||||||
)
|
typeColor: e.type.color,
|
||||||
.where(search ? like(WebsiteEvent.title, `%${search}%`) : undefined)
|
startsAt: e.startsAt,
|
||||||
.orderBy(desc(WebsiteEvent.startsAt))
|
endsAt: e.endsAt,
|
||||||
.limit(perPage)
|
status: e.status,
|
||||||
.offset((page - 1) * perPage),
|
hostUserId: e.hostUserId,
|
||||||
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,6 +2,7 @@ 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";
|
||||||
@@ -24,7 +25,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: any[] = [];
|
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
conditions.push({ open: true });
|
||||||
@@ -55,14 +56,17 @@ export default async function AdminHelpTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: any =
|
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
||||||
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, any> = {
|
const SORT_MAP: Record<
|
||||||
|
string,
|
||||||
|
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
||||||
|
> = {
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
user: { userId: "asc" },
|
user: { userId: "asc" },
|
||||||
@@ -75,7 +79,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 any;
|
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||||
|
|
||||||
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,9 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -17,56 +15,32 @@ export default async function ItemsBasePage({
|
|||||||
const result = await fetchAdminList<ItemsBaseRow>(
|
const result = await fetchAdminList<ItemsBaseRow>(
|
||||||
{
|
{
|
||||||
permission: PERMS.CATALOG_VIEW,
|
permission: PERMS.CATALOG_VIEW,
|
||||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
model: "itemsBase",
|
||||||
const conditions = [];
|
searchFields: ["itemName", "publicName"],
|
||||||
if (search) {
|
defaultSort: { id: "desc" },
|
||||||
const ors = [
|
select: {
|
||||||
like(ItemsBase.itemName, `%${search}%`),
|
id: true,
|
||||||
like(ItemsBase.publicName, `%${search}%`),
|
spriteId: true,
|
||||||
];
|
publicName: true,
|
||||||
const asId = Number(search);
|
itemName: true,
|
||||||
if (Number.isFinite(asId) && asId > 0) {
|
type: true,
|
||||||
ors.push(eq(ItemsBase.id, asId));
|
interactionType: true,
|
||||||
}
|
width: true,
|
||||||
conditions.push(or(...ors));
|
length: true,
|
||||||
}
|
|
||||||
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,5 +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 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";
|
||||||
@@ -23,7 +24,7 @@ export default async function CfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: any[] = [];
|
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
conditions.push({ state: stateFilter });
|
||||||
}
|
}
|
||||||
@@ -58,14 +59,17 @@ export default async function CfhListPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: any =
|
const where: Prisma.SupportTicketsWhereInput =
|
||||||
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, any> = {
|
const SORT_MAP: Record<
|
||||||
|
string,
|
||||||
|
Prisma.SupportTicketsOrderByWithRelationInput
|
||||||
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
sender: { senderId: "asc" },
|
sender: { senderId: "asc" },
|
||||||
@@ -78,7 +82,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 any;
|
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
||||||
|
|
||||||
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,9 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -17,34 +15,15 @@ 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,
|
||||||
fetch: async ({ search, page, perPage }) => {
|
select: {
|
||||||
const conditions: (SQL | undefined)[] = [eq(User.online, "1")];
|
id: true,
|
||||||
if (search) {
|
username: true,
|
||||||
conditions.push(
|
motto: true,
|
||||||
or(
|
look: true,
|
||||||
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,8 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -20,32 +19,23 @@ 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(db, rankId);
|
const rank = await fetchEmulatorRankForEdit(prisma, rankId);
|
||||||
if (!rank) notFound();
|
if (!rank) notFound();
|
||||||
|
|
||||||
// Fetch users with this rank (limit 200)
|
// Fetch users with this rank (limit 200)
|
||||||
const users = await db
|
const users = await prisma.user.findMany({
|
||||||
.select({ id: User.id, username: User.username, look: User.look })
|
where: { rank: rankId },
|
||||||
.from(User)
|
select: { id: true, username: true, look: true },
|
||||||
.where(eq(User.rank, rankId))
|
orderBy: { username: "asc" },
|
||||||
.orderBy(asc(User.username))
|
take: 200,
|
||||||
.limit(200);
|
});
|
||||||
|
|
||||||
const [totalUsersRow] = await db
|
const totalUsers = await prisma.user.count({ where: { rank: rankId } });
|
||||||
.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 db
|
const allCmsPermissions = await prisma.aclPermission.findMany({
|
||||||
.select({
|
orderBy: { slug: "asc" },
|
||||||
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: {
|
||||||
@@ -56,37 +46,22 @@ export default async function RankEditPage({
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
if (rankId >= 4) {
|
if (rankId >= 4) {
|
||||||
const slug = `rank_${rankId}`;
|
const role = await prisma.aclRole.upsert({
|
||||||
const title = rank.rank_name || `Rank ${rankId}`;
|
where: { slug: `rank_${rankId}` },
|
||||||
const [existing] = await db
|
create: {
|
||||||
.select({ id: AclRole.id })
|
slug: `rank_${rankId}`,
|
||||||
.from(AclRole)
|
title: rank.rank_name || `Rank ${rankId}`,
|
||||||
.where(eq(AclRole.slug, slug))
|
},
|
||||||
.limit(1);
|
update: {},
|
||||||
|
include: {
|
||||||
let roleId: number;
|
rolePermissions: { include: { permission: true } },
|
||||||
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: roleId,
|
id: role.id,
|
||||||
slug,
|
slug: role.slug,
|
||||||
title,
|
title: role.title,
|
||||||
permissionSlugs: permissionRows.map((rp) => rp.slug),
|
permissionSlugs: role.rolePermissions.map((rp) => rp.permission.slug),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { db } from "@/lib/db";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
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(db);
|
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||||
|
|
||||||
// User counts per rank
|
// User counts per rank
|
||||||
const [rawCounts] = (await db.execute(
|
const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw(
|
||||||
sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
Prisma.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,9 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -18,31 +16,28 @@ export default async function PollsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.POLLS_VIEW,
|
permission: PERMS.POLLS_VIEW,
|
||||||
fetch: async ({ search, page, perPage }) => {
|
model: "websitePoll",
|
||||||
const where = search
|
searchFields: ["title"],
|
||||||
? like(WebsitePoll.title, `%${search}%`)
|
defaultSort: { createdAt: "desc" },
|
||||||
: undefined;
|
mapRow: (p: {
|
||||||
const [rows, totals] = await Promise.all([
|
id: number;
|
||||||
db
|
title: string;
|
||||||
.select({
|
status: string;
|
||||||
id: WebsitePoll.id,
|
showResults: number;
|
||||||
title: WebsitePoll.title,
|
multipleChoice: number;
|
||||||
status: WebsitePoll.status,
|
startsAt: Date | null;
|
||||||
showResults: WebsitePoll.showResults,
|
endsAt: Date | null;
|
||||||
multipleChoice: WebsitePoll.multipleChoice,
|
createdAt: Date;
|
||||||
startsAt: WebsitePoll.startsAt,
|
}) => ({
|
||||||
endsAt: WebsitePoll.endsAt,
|
id: p.id,
|
||||||
createdAt: WebsitePoll.createdAt,
|
title: p.title,
|
||||||
})
|
status: p.status,
|
||||||
.from(WebsitePoll)
|
showResults: p.showResults,
|
||||||
.where(where)
|
multipleChoice: p.multipleChoice,
|
||||||
.orderBy(desc(WebsitePoll.createdAt))
|
startsAt: p.startsAt,
|
||||||
.limit(perPage)
|
endsAt: p.endsAt,
|
||||||
.offset((page - 1) * perPage),
|
createdAt: p.createdAt,
|
||||||
db.select({ total: count() }).from(WebsitePoll).where(where),
|
}),
|
||||||
]);
|
|
||||||
return { rows, total: totals[0]?.total ?? 0 };
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -17,56 +15,33 @@ export default async function RoomsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.ROOMS_VIEW,
|
permission: PERMS.ROOMS_VIEW,
|
||||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
// Prisma model is `Rooms` → client accessor `rooms`.
|
||||||
const conditions = [];
|
// Owner is denormalized on the row (ownerId / ownerName), not a relation.
|
||||||
if (search) {
|
model: "rooms",
|
||||||
const ors = [
|
searchFields: ["name", "ownerName"],
|
||||||
like(Rooms.name, `%${search}%`),
|
select: {
|
||||||
like(Rooms.ownerName, `%${search}%`),
|
id: true,
|
||||||
];
|
name: true,
|
||||||
const asId = Number(search);
|
ownerId: true,
|
||||||
if (Number.isFinite(asId) && asId > 0) {
|
ownerName: true,
|
||||||
ors.push(eq(Rooms.id, asId));
|
state: true,
|
||||||
}
|
users: true,
|
||||||
conditions.push(or(...ors));
|
usersMax: true,
|
||||||
}
|
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,6 +2,7 @@ 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";
|
||||||
@@ -24,7 +25,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: any[] = [];
|
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
||||||
|
|
||||||
if (statusFilter === "active") {
|
if (statusFilter === "active") {
|
||||||
conditions.push({ status: { not: "closed" } });
|
conditions.push({ status: { not: "closed" } });
|
||||||
@@ -46,27 +47,28 @@ export default async function AdminTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: any =
|
const where: Prisma.WebsiteTicketWhereInput =
|
||||||
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, any> = {
|
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||||
subject: { subject: "asc" },
|
{
|
||||||
status: { status: "asc" },
|
subject: { subject: "asc" },
|
||||||
priority: { priority: "asc" },
|
status: { status: "asc" },
|
||||||
assignee: { assigneeId: "asc" },
|
priority: { priority: "asc" },
|
||||||
date: { createdAt: "desc" },
|
assignee: { assigneeId: "asc" },
|
||||||
id: { id: "desc" },
|
date: { createdAt: "desc" },
|
||||||
};
|
id: { id: "desc" },
|
||||||
|
};
|
||||||
|
|
||||||
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
|
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
|
||||||
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 any;
|
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||||
|
|
||||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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";
|
||||||
@@ -33,7 +34,10 @@ function formatAmount(amount: number, currency: string): string {
|
|||||||
return `${value} ${currency}`;
|
return `${value} ${currency}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT_MAP: Record<string, any> = {
|
const SORT_MAP: Record<
|
||||||
|
string,
|
||||||
|
Prisma.WebsitePaypalTransactionsOrderByWithRelationInput
|
||||||
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
amount: { amount: "desc" },
|
amount: { amount: "desc" },
|
||||||
date: { createdAt: "desc" },
|
date: { createdAt: "desc" },
|
||||||
@@ -57,7 +61,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: any = {};
|
const where: Prisma.WebsitePaypalTransactionsWhereInput = {};
|
||||||
|
|
||||||
if (statusFilter === "paid") {
|
if (statusFilter === "paid") {
|
||||||
where.status = { in: [...PAID_STATUS] };
|
where.status = { in: [...PAID_STATUS] };
|
||||||
@@ -109,7 +113,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 any;
|
} as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput;
|
||||||
|
|
||||||
let transactions: Awaited<
|
let transactions: Awaited<
|
||||||
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -29,20 +30,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: any[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
|
const patternFilters: Prisma.EmulatorSettingsWhereInput[] =
|
||||||
key: { contains: p.replace(/%/g, "") },
|
TRANSLATION_KEY_PATTERNS.map((p) => ({
|
||||||
}));
|
key: { contains: p.replace(/%/g, "") },
|
||||||
|
}));
|
||||||
|
|
||||||
const baseFilter: any = { OR: patternFilters };
|
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters };
|
||||||
|
|
||||||
const conditions: any[] = [baseFilter];
|
const conditions: Prisma.EmulatorSettingsWhereInput[] = [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: any = { AND: conditions };
|
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions };
|
||||||
|
|
||||||
const [settings, total, allKeys] = await Promise.all([
|
const [settings, total, allKeys] = await Promise.all([
|
||||||
prisma.emulatorSettings.findMany({
|
prisma.emulatorSettings.findMany({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { Prisma, prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getWatchedUserIds } from "@/lib/services/watch";
|
import { getWatchedUserIds } from "@/lib/services/watch";
|
||||||
import { UserAccountInfo } from "../../_components/user-account-info";
|
import { UserAccountInfo } from "../../_components/user-account-info";
|
||||||
import { UserHero } from "../../_components/user-hero";
|
import { UserHero } from "../../_components/user-hero";
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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";
|
||||||
@@ -18,54 +16,30 @@ export default async function UsersPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_VIEW,
|
permission: PERMS.USERS_VIEW,
|
||||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
model: "user",
|
||||||
const conditions = [];
|
searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
|
||||||
if (search) {
|
select: {
|
||||||
const ors = [
|
id: true,
|
||||||
like(User.username, `%${search}%`),
|
username: true,
|
||||||
like(User.mail, `%${search}%`),
|
mail: true,
|
||||||
like(User.ipRegister, `%${search}%`),
|
rank: true,
|
||||||
like(User.ipCurrent, `%${search}%`),
|
credits: true,
|
||||||
];
|
pixels: true,
|
||||||
const asId = Number(search);
|
online: true,
|
||||||
if (Number.isFinite(asId) && asId > 0) {
|
motto: true,
|
||||||
ors.push(eq(User.id, asId));
|
look: true,
|
||||||
}
|
ipRegister: true,
|
||||||
conditions.push(or(...ors));
|
accountCreated: true,
|
||||||
}
|
lastLogin: true,
|
||||||
const rank = Number(rawParams.filter_rank);
|
},
|
||||||
if (Number.isFinite(rank)) {
|
filterMap: {
|
||||||
|
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.
|
||||||
conditions.push(rank >= 7 ? gte(User.rank, 7) : eq(User.rank, rank));
|
return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
|
||||||
}
|
},
|
||||||
if (rawParams.filter_online) {
|
filter_online: (v) => ({ online: v }),
|
||||||
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 | null;
|
mail: string;
|
||||||
rank: number;
|
rank: number;
|
||||||
credits: number;
|
credits: number;
|
||||||
pixels: number;
|
pixels: number;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
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";
|
||||||
import { Prisma, prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
interface UserPrefix {
|
interface UserPrefix {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
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";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Prisma, prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,17 @@
|
|||||||
// DB failure.
|
// DB failure.
|
||||||
|
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { cached } from "@/lib/cache";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(_req: Request) {
|
export async function GET(_req: Request) {
|
||||||
try {
|
try {
|
||||||
const users = await cached("online_users", 10_000, async () =>
|
const users = await prisma.user.findMany({
|
||||||
prisma.user.findMany({
|
where: { online: "1" },
|
||||||
where: { online: "1" },
|
select: { username: true, look: true },
|
||||||
select: { username: true, look: true },
|
take: 100,
|
||||||
take: 100,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return apiJson({ users });
|
return apiJson({ users });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,12 +4,6 @@
|
|||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
|
||||||
--font-nunito:
|
|
||||||
"Nunito", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
||||||
--font-pixel: "Pixelify Sans", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
var(--font-nunito), "Nunito", ui-sans-serif, system-ui, -apple-system,
|
var(--font-nunito), "Nunito", ui-sans-serif, system-ui, -apple-system,
|
||||||
|
|||||||
+19
-15
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Nunito, Pixelify_Sans } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
@@ -14,6 +15,20 @@ import { resolveHotelName } from "@/lib/hotel-name";
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const nunito = Nunito({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "600", "700", "800"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-nunito",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pixelFont = Pixelify_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "700"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-pixel",
|
||||||
|
});
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||||
const hotel = await resolveHotelName();
|
const hotel = await resolveHotelName();
|
||||||
@@ -52,23 +67,12 @@ export default async function RootLayout({
|
|||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="app">
|
<html
|
||||||
|
lang={locale}
|
||||||
|
className={`app ${nunito.variable} ${pixelFont.variable}`}
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta name="theme-default-dark" content={String(defaultDark)} />
|
<meta name="theme-default-dark" content={String(defaultDark)} />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link
|
|
||||||
rel="preconnect"
|
|
||||||
href="https://fonts.gstatic.com"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;700&display=swap"
|
|
||||||
/>
|
|
||||||
<Script
|
<Script
|
||||||
src="/scripts/theme-init.js"
|
src="/scripts/theme-init.js"
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@@ -19,7 +20,7 @@ export default async function ModCfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: any[] = [];
|
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
conditions.push({ state: stateFilter });
|
||||||
}
|
}
|
||||||
@@ -54,14 +55,17 @@ export default async function ModCfhListPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: any =
|
const where: Prisma.SupportTicketsWhereInput =
|
||||||
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, any> = {
|
const SORT_MAP: Record<
|
||||||
|
string,
|
||||||
|
Prisma.SupportTicketsOrderByWithRelationInput
|
||||||
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
sender: { senderId: "asc" },
|
sender: { senderId: "asc" },
|
||||||
@@ -74,7 +78,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 any;
|
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
||||||
|
|
||||||
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,6 +5,7 @@ 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";
|
||||||
@@ -24,7 +25,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: any[] = [];
|
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
conditions.push({ open: true });
|
||||||
@@ -55,14 +56,17 @@ export default async function ModHelpTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: any =
|
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
||||||
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, any> = {
|
const SORT_MAP: Record<
|
||||||
|
string,
|
||||||
|
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
||||||
|
> = {
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
user: { userId: "asc" },
|
user: { userId: "asc" },
|
||||||
@@ -75,7 +79,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 any;
|
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||||
|
|
||||||
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,6 +5,7 @@ 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";
|
||||||
@@ -24,7 +25,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: any[] = [];
|
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
||||||
|
|
||||||
if (statusFilter === "active") {
|
if (statusFilter === "active") {
|
||||||
conditions.push({ status: { not: "closed" } });
|
conditions.push({ status: { not: "closed" } });
|
||||||
@@ -46,27 +47,28 @@ export default async function ModTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: any =
|
const where: Prisma.WebsiteTicketWhereInput =
|
||||||
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, any> = {
|
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||||
subject: { subject: "asc" },
|
{
|
||||||
status: { status: "asc" },
|
subject: { subject: "asc" },
|
||||||
priority: { priority: "asc" },
|
status: { status: "asc" },
|
||||||
assignee: { assigneeId: "asc" },
|
priority: { priority: "asc" },
|
||||||
date: { createdAt: "desc" },
|
assignee: { assigneeId: "asc" },
|
||||||
id: { id: "desc" },
|
date: { createdAt: "desc" },
|
||||||
};
|
id: { id: "desc" },
|
||||||
|
};
|
||||||
|
|
||||||
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
|
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
|
||||||
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 any;
|
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||||
|
|
||||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
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
+114
-19
@@ -1,22 +1,45 @@
|
|||||||
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>;
|
||||||
/**
|
/**
|
||||||
* Runs the actual data query (Drizzle). Receives parsed list params and
|
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
|
||||||
* returns the rows + total count for the current page.
|
* the raw string value from the URL and returns the WHERE fragment to merge.
|
||||||
|
* Returning null/undefined drops the filter.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* filterMap: {
|
||||||
|
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
||||||
|
* filter_online: (v) => ({ online: v }),
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
fetch: (args: {
|
filterMap?: Record<
|
||||||
search: string;
|
string,
|
||||||
page: number;
|
(value: string) => Record<string, unknown> | null | undefined
|
||||||
perPage: number;
|
>;
|
||||||
rawParams: Record<string, string>;
|
/** Transform raw Prisma row to the shape needed by the table component */
|
||||||
}) => Promise<{ rows: TRow[]; total: number }>;
|
mapRow?: (row: PrismaModel) => TRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminListResult<TRow> {
|
interface AdminListResult<TRow> {
|
||||||
@@ -31,11 +54,34 @@ interface AdminListResult<TRow> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic admin list data fetcher.
|
* Build a nested Prisma WHERE condition from a dot-notation field path.
|
||||||
* Centralizes the common pattern: auth check, param parsing, pagination and
|
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
|
||||||
* row mapping. The caller supplies a Drizzle query via `config.fetch`.
|
* e.g. 'username' => { username: { contains: search } }
|
||||||
*/
|
*/
|
||||||
export async function fetchAdminList<TRow>(
|
function buildFieldCondition(
|
||||||
|
fieldPath: string,
|
||||||
|
search: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const parts = fieldPath.split(".");
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { [parts[0]]: { contains: search } };
|
||||||
|
}
|
||||||
|
// Build nested object from right to left
|
||||||
|
let result: Record<string, unknown> = {
|
||||||
|
[parts[parts.length - 1]]: { contains: search },
|
||||||
|
};
|
||||||
|
for (let i = parts.length - 2; i >= 0; i--) {
|
||||||
|
result = { [parts[i]]: result };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic admin list data fetcher.
|
||||||
|
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
|
||||||
|
* Prisma query with pagination, and row mapping.
|
||||||
|
*/
|
||||||
|
export async function fetchAdminList<TRow = PrismaModel>(
|
||||||
config: AdminListConfig<TRow>,
|
config: AdminListConfig<TRow>,
|
||||||
paramsPromise: Promise<{ locale: string }>,
|
paramsPromise: Promise<{ locale: string }>,
|
||||||
searchParamsPromise: Promise<Record<string, string>>,
|
searchParamsPromise: Promise<Record<string, string>>,
|
||||||
@@ -53,15 +99,64 @@ export async function fetchAdminList<TRow>(
|
|||||||
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;
|
||||||
|
|
||||||
const { rows, total } = await config.fetch({
|
// Build WHERE clause
|
||||||
search: parsed.search,
|
const searchOr =
|
||||||
page: parsed.page,
|
search && config.searchFields.length > 0
|
||||||
perPage,
|
? config.searchFields.map((field) => buildFieldCondition(field, search))
|
||||||
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 } : {};
|
||||||
|
|
||||||
const pagination = calcPagination(total, parsed.page, perPage);
|
// Apply URL filter_* params via filterMap
|
||||||
|
const filterWhere: Record<string, unknown> = {};
|
||||||
|
if (config.filterMap) {
|
||||||
|
for (const [paramKey, build] of Object.entries(config.filterMap)) {
|
||||||
|
const value = rawParams[paramKey];
|
||||||
|
if (!value) continue;
|
||||||
|
const fragment = build(value);
|
||||||
|
if (fragment) Object.assign(filterWhere, fragment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(config.baseWhere ?? {}),
|
||||||
|
...filterWhere,
|
||||||
|
...searchWhere,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build orderBy
|
||||||
|
const orderBy = sort
|
||||||
|
? { [sort]: order }
|
||||||
|
: (config.defaultSort ?? { id: "desc" as const });
|
||||||
|
|
||||||
|
// Access Prisma model dynamically
|
||||||
|
const delegate = (prisma as PrismaModel)[config.model];
|
||||||
|
|
||||||
|
const queryArgs: PrismaModel = {
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: (page - 1) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.include) queryArgs.include = config.include;
|
||||||
|
if (config.select) queryArgs.select = config.select;
|
||||||
|
|
||||||
|
const [rawRows, total] = await Promise.all([
|
||||||
|
delegate.findMany(queryArgs),
|
||||||
|
delegate.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pagination = calcPagination(total, page, perPage);
|
||||||
|
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows;
|
||||||
|
|
||||||
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,7 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { redirectSafe } from "@/lib/foundation/security";
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import {
|
import {
|
||||||
requireMod,
|
requireMod,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
export interface StaffActivityFilters {
|
export interface StaffActivityFilters {
|
||||||
q?: string;
|
q?: string;
|
||||||
staffId?: number | null;
|
staffId?: number | null;
|
||||||
@@ -5,8 +7,10 @@ export interface StaffActivityFilters {
|
|||||||
authorizationOnly?: boolean;
|
authorizationOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStaffActivityWhere(filters: StaffActivityFilters): any {
|
export function buildStaffActivityWhere(
|
||||||
const where: any = {};
|
filters: StaffActivityFilters,
|
||||||
|
): Prisma.StaffActivitiesWhereInput {
|
||||||
|
const where: Prisma.StaffActivitiesWhereInput = {};
|
||||||
if (filters.q?.trim())
|
if (filters.q?.trim())
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ action: { contains: filters.q.trim() } },
|
{ action: { contains: filters.q.trim() } },
|
||||||
|
|||||||
+2
-82
@@ -1,4 +1,3 @@
|
|||||||
import { sql } from "drizzle-orm";
|
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@@ -6,87 +5,11 @@ import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
|
|||||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||||
import { checkLogin } from "@/lib/auth/password";
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
import { verifyTotp } from "@/lib/auth/totp";
|
import { verifyTotp } from "@/lib/auth/totp";
|
||||||
import { cachedQuery, invalidateKey } from "@/lib/cached-db";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
interface LoginUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
password: string | null;
|
|
||||||
rank: number;
|
|
||||||
mail: string | null;
|
|
||||||
mailVerified: string | null;
|
|
||||||
twoFactorConfirmedAt: string | null;
|
|
||||||
twoFactorSecret: string | null;
|
|
||||||
accountBlocked: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cached login user lookup — short TTL to survive brute-force attempts
|
|
||||||
* while still reflecting recent password/account changes reasonably fast.
|
|
||||||
*/
|
|
||||||
async function getLoginUser(username: string): Promise<LoginUser | null> {
|
|
||||||
return cachedQuery<LoginUser | null>(
|
|
||||||
`login:user:${username}`,
|
|
||||||
async () => {
|
|
||||||
const [result] = await db.execute<{
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
password: string | null;
|
|
||||||
rank: number;
|
|
||||||
mail: string | null;
|
|
||||||
mail_verified: string | null;
|
|
||||||
two_factor_confirmed_at: string | null;
|
|
||||||
two_factor_secret: string | null;
|
|
||||||
account_blocked: string | null;
|
|
||||||
}>(sql`
|
|
||||||
SELECT id, username, password, rank, mail,
|
|
||||||
mail_verified,
|
|
||||||
two_factor_confirmed_at,
|
|
||||||
two_factor_secret,
|
|
||||||
account_blocked
|
|
||||||
FROM users
|
|
||||||
WHERE username = ${username}
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
const rows = result as unknown as Array<{
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
password: string | null;
|
|
||||||
rank: number;
|
|
||||||
mail: string | null;
|
|
||||||
mail_verified: string | null;
|
|
||||||
two_factor_confirmed_at: string | null;
|
|
||||||
two_factor_secret: string | null;
|
|
||||||
account_blocked: string | null;
|
|
||||||
}>;
|
|
||||||
return rows.length > 0
|
|
||||||
? {
|
|
||||||
id: rows[0].id,
|
|
||||||
username: rows[0].username,
|
|
||||||
password: rows[0].password,
|
|
||||||
rank: rows[0].rank,
|
|
||||||
mail: rows[0].mail,
|
|
||||||
mailVerified: rows[0].mail_verified,
|
|
||||||
twoFactorConfirmedAt: rows[0].two_factor_confirmed_at,
|
|
||||||
twoFactorSecret: rows[0].two_factor_secret,
|
|
||||||
accountBlocked: rows[0].account_blocked,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
15, // 15s TTL — brute-force protection without blocking legit changes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call after password reset / rank change to invalidate the cached login row. */
|
|
||||||
export async function invalidateLoginCache(username: string): Promise<void> {
|
|
||||||
await invalidateKey(`login:user:${username}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -160,7 +83,7 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
||||||
if (!(await rateLimit(`login:${ip}`, 10, 5 * 60_000)).ok) return null;
|
if (!(await rateLimit(`login:${ip}`, 10, 5 * 60_000)).ok) return null;
|
||||||
|
|
||||||
const user = await getLoginUser(username);
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||||
await checkLogin(
|
await checkLogin(
|
||||||
@@ -174,7 +97,6 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade).
|
// Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade).
|
||||||
if (!user.password) return null;
|
|
||||||
const res = await checkLogin(password, user.password, {
|
const res = await checkLogin(password, user.password, {
|
||||||
convertPasswords: env.CONVERT_PASSWORDS,
|
convertPasswords: env.CONVERT_PASSWORDS,
|
||||||
});
|
});
|
||||||
@@ -193,7 +115,6 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { password: res.upgradedHash },
|
data: { password: res.upgradedHash },
|
||||||
});
|
});
|
||||||
invalidateLoginCache(username);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-factor: if enabled, a valid TOTP or recovery code is required.
|
// Two-factor: if enabled, a valid TOTP or recovery code is required.
|
||||||
@@ -227,12 +148,11 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwtVersion = await getCachedJwtVersion(user.id);
|
|
||||||
return {
|
return {
|
||||||
id: String(user.id),
|
id: String(user.id),
|
||||||
name: user.username,
|
name: user.username,
|
||||||
rank: user.rank,
|
rank: user.rank,
|
||||||
jwtVersion,
|
jwtVersion: user.websiteJwtVersion,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { bcrypt, bcryptVerify, md5 } from "hash-wasm";
|
import {
|
||||||
|
bcrypt,
|
||||||
|
bcryptVerify,
|
||||||
|
md5,
|
||||||
|
} from "hash-wasm";
|
||||||
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
|||||||
+8
-52
@@ -1,61 +1,17 @@
|
|||||||
import "server-only";
|
|
||||||
|
|
||||||
import { redis } from "@/lib/redis";
|
|
||||||
|
|
||||||
type CacheEntry<T> = { data: T; expiresAt: number };
|
type CacheEntry<T> = { data: T; expiresAt: number };
|
||||||
const memory = new Map<string, CacheEntry<unknown>>();
|
const store = new Map<string, CacheEntry<unknown>>();
|
||||||
|
|
||||||
/**
|
export function cached<T>(
|
||||||
* Redis-first cached query with an in-memory fallback.
|
|
||||||
* Use for read-heavy endpoints polled by the browser (online count, etc.).
|
|
||||||
*/
|
|
||||||
export async function cached<T>(
|
|
||||||
key: string,
|
key: string,
|
||||||
ttlMs: number,
|
ttlMs: number,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const ttlSec = Math.ceil(ttlMs / 1000);
|
const existing = store.get(key);
|
||||||
|
|
||||||
// Redis path (shared across instances).
|
|
||||||
if (redis && redis.status !== "end") {
|
|
||||||
try {
|
|
||||||
const cached = await redis.get(key);
|
|
||||||
if (cached !== null && cached !== undefined) {
|
|
||||||
return JSON.parse(cached) as T;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through to DB / memory */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory fallback (single-instance fast path).
|
|
||||||
const existing = memory.get(key);
|
|
||||||
if (existing && existing.expiresAt > Date.now()) {
|
if (existing && existing.expiresAt > Date.now()) {
|
||||||
return existing.data as T;
|
return Promise.resolve(existing.data as T);
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fn();
|
|
||||||
|
|
||||||
if (redis && redis.status !== "end") {
|
|
||||||
try {
|
|
||||||
await redis.setex(key, ttlSec, JSON.stringify(data));
|
|
||||||
} catch {
|
|
||||||
/* non-critical: memory cache still works */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
memory.set(key, { data, expiresAt: Date.now() + ttlMs });
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Invalidate a single cache key across Redis + memory (call after writes). */
|
|
||||||
export async function uncached(key: string): Promise<void> {
|
|
||||||
memory.delete(key);
|
|
||||||
if (redis && redis.status !== "end") {
|
|
||||||
try {
|
|
||||||
await redis.del(key);
|
|
||||||
} catch {
|
|
||||||
/* non-critical */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return fn().then((data) => {
|
||||||
|
store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||||
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { redis } from "@/lib/redis";
|
|
||||||
|
|
||||||
const DEFAULT_CACHE_TTL = 60;
|
|
||||||
|
|
||||||
function isRedisAvailable(): boolean {
|
|
||||||
return !!redis && redis.status !== "end";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cachedQuery<T>(
|
|
||||||
cacheKey: string,
|
|
||||||
queryFn: () => Promise<T>,
|
|
||||||
ttl: number = DEFAULT_CACHE_TTL,
|
|
||||||
): Promise<T> {
|
|
||||||
if (!isRedisAvailable()) {
|
|
||||||
return queryFn();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = await redis?.get(cacheKey);
|
|
||||||
if (cached !== null && cached !== undefined) {
|
|
||||||
return JSON.parse(cached) as T;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("[cache] read failed, falling back to DB", {
|
|
||||||
key: cacheKey,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await queryFn();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await redis?.setex(cacheKey, ttl, JSON.stringify(result));
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("[cache] write failed, continuing without cache", {
|
|
||||||
key: cacheKey,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Invalidate cache keys matching a glob pattern. */
|
|
||||||
export async function invalidateCache(pattern: string): Promise<number> {
|
|
||||||
if (!isRedisAvailable()) return 0;
|
|
||||||
const r = redis;
|
|
||||||
if (!r) return 0;
|
|
||||||
const keys = await r.keys(pattern);
|
|
||||||
return keys.length > 0 ? r.del(...keys) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Invalidate a single cache key immediately. */
|
|
||||||
export async function invalidateKey(key: string): Promise<number> {
|
|
||||||
if (!isRedisAvailable()) return 0;
|
|
||||||
if (!redis) return 0;
|
|
||||||
return redis.del(key);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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,70 +0,0 @@
|
|||||||
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,
|
|
||||||
enableKeepAlive: true,
|
|
||||||
// Prepared-statement caching via mysql2's native support (Node 22+).
|
|
||||||
// Saves query-parse per request on hot paths.
|
|
||||||
...("cache" in mysql.createPool && {
|
|
||||||
cache: { type: "prepared" },
|
|
||||||
}),
|
|
||||||
// Allow :placeholder syntax for raw SQL helpers.
|
|
||||||
namedPlaceholders: true,
|
|
||||||
// Reject multiple statements (SQL injection hardening).
|
|
||||||
multipleStatements: false,
|
|
||||||
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
@@ -0,0 +1,19 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 driver/env imports so unit tests can exercise it in isolation.
|
* Kept free of Prisma/env imports so unit tests can exercise it in isolation.
|
||||||
*/
|
*/
|
||||||
export function resolveConnectionLimit(
|
export function resolvePrismaConnectionLimit(
|
||||||
poolSize: number,
|
poolSize: number,
|
||||||
phase = process.env.NEXT_PHASE,
|
phase = process.env.NEXT_PHASE,
|
||||||
): number {
|
): number {
|
||||||
+36
-5
@@ -1,6 +1,37 @@
|
|||||||
import type { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||||
import { db } from "@/lib/db";
|
import { env } from "@/env";
|
||||||
import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade";
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool";
|
||||||
|
|
||||||
export const prisma = makePrisma(db) as unknown as PrismaClient;
|
const globalForPrisma = globalThis as unknown as { prisma?: 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 { asc, sql } from "drizzle-orm";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { CatalogPages, db } from "@/lib/db";
|
import { prisma } from "@/lib/prisma";
|
||||||
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 db.execute(sql`
|
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||||
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,18 +164,13 @@ export async function loadCatalogItemsData(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||||
db
|
prisma.catalogPages.findMany({
|
||||||
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
orderBy: { caption: "asc" },
|
||||||
.from(CatalogPages)
|
select: { id: true, caption: true },
|
||||||
.orderBy(asc(CatalogPages.caption)),
|
}),
|
||||||
db
|
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||||
.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(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -197,46 +192,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 sql.join.
|
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
|
||||||
const [baseItemRows] =
|
const baseItems: BaseItem[] =
|
||||||
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 (${sql.join(baseItemIds, sql`, `)})
|
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
||||||
`)) as unknown as [Record<string, unknown>[], unknown[]])
|
`
|
||||||
: [[], []];
|
).map((r) => ({
|
||||||
|
id: Number(r.id),
|
||||||
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
|
spriteId: Number(r.sprite_id),
|
||||||
id: Number(r.id),
|
publicName: String(r.public_name ?? ""),
|
||||||
spriteId: Number(r.sprite_id),
|
itemName: String(r.item_name ?? ""),
|
||||||
publicName: String(r.public_name ?? ""),
|
type: String(r.type ?? "s"),
|
||||||
itemName: String(r.item_name ?? ""),
|
width: Number(r.width) || 1,
|
||||||
type: String(r.type ?? "s"),
|
length: Number(r.length) || 1,
|
||||||
width: Number(r.width) || 1,
|
stackHeight: Number(r.stack_height) || 0,
|
||||||
length: Number(r.length) || 1,
|
allowStack: Number(r.allow_stack ?? 0),
|
||||||
stackHeight: Number(r.stack_height) || 0,
|
allowSit: Number(r.allow_sit ?? 0),
|
||||||
allowStack: Number(r.allow_stack ?? 0),
|
allowLay: Number(r.allow_lay ?? 0),
|
||||||
allowSit: Number(r.allow_sit ?? 0),
|
allowWalk: Number(r.allow_walk ?? 0),
|
||||||
allowLay: Number(r.allow_lay ?? 0),
|
allowGift: Number(r.allow_gift ?? 1),
|
||||||
allowWalk: Number(r.allow_walk ?? 0),
|
allowTrade: Number(r.allow_trade ?? 1),
|
||||||
allowGift: Number(r.allow_gift ?? 1),
|
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||||
allowTrade: Number(r.allow_trade ?? 1),
|
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
interactionType: String(r.interaction_type ?? "default"),
|
||||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||||
interactionType: String(r.interaction_type ?? "default"),
|
vendingIds: String(r.vending_ids ?? "0"),
|
||||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
customparams: String(r.customparams ?? ""),
|
||||||
vendingIds: String(r.vending_ids ?? "0"),
|
effectIdMale: Number(r.effect_id_male) || 0,
|
||||||
customparams: String(r.customparams ?? ""),
|
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||||
effectIdMale: Number(r.effect_id_male) || 0,
|
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||||
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,41 +1,27 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const insertValues = vi.hoisted(() => vi.fn());
|
const findUnique = vi.hoisted(() => vi.fn());
|
||||||
const selectLimit = vi.hoisted(() => vi.fn());
|
const findMany = vi.hoisted(() => vi.fn());
|
||||||
const updateSetData = vi.hoisted(() => vi.fn());
|
const update = vi.hoisted(() => vi.fn());
|
||||||
const updateWhere = vi.hoisted(() => vi.fn());
|
const create = vi.hoisted(() => vi.fn());
|
||||||
const deleteWhere = vi.hoisted(() => vi.fn());
|
const queryRaw = vi.hoisted(() => vi.fn());
|
||||||
const executeMock = vi.hoisted(() => vi.fn());
|
const executeRaw = vi.hoisted(() => vi.fn());
|
||||||
|
const catalogDelete = vi.hoisted(() => vi.fn());
|
||||||
|
const catalogUpdateMany = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@/lib/db", () => ({
|
vi.mock("@/lib/prisma", () => ({
|
||||||
db: {
|
prisma: {
|
||||||
insert: () => ({ values: insertValues }),
|
catalogPages: {
|
||||||
select: () => ({
|
findUnique,
|
||||||
from: () => ({
|
findMany,
|
||||||
where: () => ({
|
update,
|
||||||
limit: () => Promise.resolve(selectLimit()),
|
create,
|
||||||
}),
|
delete: catalogDelete,
|
||||||
orderBy: () => ({
|
updateMany: catalogUpdateMany,
|
||||||
where: () => ({
|
},
|
||||||
limit: () => Promise.resolve(selectLimit()),
|
$queryRaw: queryRaw,
|
||||||
}),
|
$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: {} }));
|
||||||
@@ -135,10 +121,10 @@ describe("buildNestedTree", () => {
|
|||||||
|
|
||||||
describe("createPage", () => {
|
describe("createPage", () => {
|
||||||
it("creates page with default values", async () => {
|
it("creates page with default values", async () => {
|
||||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
create.mockResolvedValue({ id: 1 });
|
||||||
await createPage({ parentId: 0, caption: "Test" });
|
await createPage({ parentId: 0, caption: "Test" });
|
||||||
|
|
||||||
const data = insertValues.mock.calls[0][0];
|
const data = create.mock.calls[0][0].data;
|
||||||
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");
|
||||||
@@ -148,51 +134,57 @@ describe("createPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("truncates captionSave to 25 chars", async () => {
|
it("truncates captionSave to 25 chars", async () => {
|
||||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
create.mockResolvedValue({ id: 1 });
|
||||||
await createPage({ parentId: 0, caption: "a".repeat(50) });
|
await createPage({ parentId: 0, caption: "a".repeat(50) });
|
||||||
|
|
||||||
expect(insertValues.mock.calls[0][0].captionSave).toBe("a".repeat(25));
|
expect(create.mock.calls[0][0].data.captionSave).toBe("a".repeat(25));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts boolean visible/enabled to string", async () => {
|
it("converts boolean visible/enabled to string", async () => {
|
||||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
create.mockResolvedValue({ id: 1 });
|
||||||
await createPage({
|
await createPage({
|
||||||
parentId: 0,
|
parentId: 0,
|
||||||
caption: "x",
|
caption: "x",
|
||||||
visible: false,
|
visible: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
expect(insertValues.mock.calls[0][0].visible).toBe("0");
|
expect(create.mock.calls[0][0].data.visible).toBe("0");
|
||||||
expect(insertValues.mock.calls[0][0].enabled).toBe("0");
|
expect(create.mock.calls[0][0].data.enabled).toBe("0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("movePage", () => {
|
describe("movePage", () => {
|
||||||
it("throws on circular hierarchy", async () => {
|
it("throws on circular hierarchy", async () => {
|
||||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
findUnique.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 () => {
|
||||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
update.mockResolvedValue({});
|
||||||
await movePage(1, 0);
|
await movePage(1, 0);
|
||||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
|
expect(update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 1 },
|
||||||
|
data: { parentId: 0 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows valid move to other parent", async () => {
|
it("allows valid move to other parent", async () => {
|
||||||
selectLimit.mockResolvedValue([{ parentId: 0 }]);
|
findUnique.mockResolvedValue({ parentId: 0 });
|
||||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
update.mockResolvedValue({});
|
||||||
await movePage(1, 2);
|
await movePage(1, 2);
|
||||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
expect(update).toHaveBeenCalledWith({
|
||||||
|
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 () => {
|
||||||
selectLimit
|
findUnique
|
||||||
.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);
|
||||||
@@ -202,19 +194,22 @@ describe("getAncestors", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stops when page not found", async () => {
|
it("stops when page not found", async () => {
|
||||||
selectLimit.mockResolvedValue([]);
|
findUnique.mockResolvedValue(null);
|
||||||
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 () => {
|
||||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
findUnique.mockResolvedValue({ parentId: 1 });
|
||||||
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
catalogDelete.mockResolvedValue({});
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
catalogUpdateMany.mockResolvedValue({ count: 2 });
|
||||||
|
|
||||||
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(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
|
expect(catalogUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { parentId: 5 },
|
||||||
|
data: { parentId: 1 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { CatalogPages, db } from "@/lib/db";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
||||||
|
|
||||||
@@ -23,27 +23,25 @@ export async function getCatalogItemCounts(
|
|||||||
|
|
||||||
if (pageIds) {
|
if (pageIds) {
|
||||||
const idStrs = pageIds.map(String);
|
const idStrs = pageIds.map(String);
|
||||||
const [rows] = (await db.execute(sql`
|
const rows = await prisma.$queryRaw<
|
||||||
|
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 (${sql.join(idStrs, sql`, `)})
|
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||||
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 db.execute(sql`
|
const rows = await prisma.$queryRaw<
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
@@ -57,10 +55,9 @@ 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 db
|
const allPages = await prisma.catalogPages.findMany({
|
||||||
.select()
|
orderBy: { orderNum: "asc" },
|
||||||
.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>();
|
||||||
@@ -72,23 +69,15 @@ 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, visiting = new Set<number>()): number {
|
function computeDepth(pageId: number): number {
|
||||||
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
||||||
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
|
||||||
if (visiting.has(pageId)) {
|
|
||||||
depthCache.set(pageId, 0);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
visiting.add(pageId);
|
|
||||||
const page = pageMap.get(pageId);
|
const page = pageMap.get(pageId);
|
||||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
const parentId = page ? toInt(page.parentId, -1) : -1;
|
||||||
if (!page || parentId <= 0) {
|
if (!page || parentId <= 0) {
|
||||||
visiting.delete(pageId);
|
|
||||||
depthCache.set(pageId, 0);
|
depthCache.set(pageId, 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const d = computeDepth(parentId, visiting) + 1;
|
const d = computeDepth(parentId) + 1;
|
||||||
visiting.delete(pageId);
|
|
||||||
depthCache.set(pageId, d);
|
depthCache.set(pageId, d);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
@@ -165,22 +154,24 @@ export async function createPage(data: {
|
|||||||
: "0"
|
: "0"
|
||||||
: (data.enabled ?? "1");
|
: (data.enabled ?? "1");
|
||||||
|
|
||||||
return db.insert(CatalogPages).values({
|
return prisma.catalogPages.create({
|
||||||
caption: data.caption,
|
data: {
|
||||||
parentId: data.parentId,
|
caption: data.caption,
|
||||||
pageLayout: data.pageLayout ?? "default_3x3",
|
parentId: data.parentId,
|
||||||
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
pageLayout: data.pageLayout ?? "default_3x3",
|
||||||
iconColor: data.iconColor ?? 0,
|
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||||
iconImage: data.iconImage ?? 0,
|
iconColor: data.iconColor ?? 0,
|
||||||
minRank: data.minRank ?? 1,
|
iconImage: data.iconImage ?? 0,
|
||||||
orderNum: data.orderNum ?? 0,
|
minRank: data.minRank ?? 1,
|
||||||
visible,
|
orderNum: data.orderNum ?? 0,
|
||||||
enabled,
|
visible,
|
||||||
clubOnly: "0",
|
enabled,
|
||||||
vipOnly: "0",
|
clubOnly: "0",
|
||||||
pageHeadline: "",
|
vipOnly: "0",
|
||||||
pageTeaser: "",
|
pageHeadline: "",
|
||||||
includes: "",
|
pageTeaser: "",
|
||||||
|
includes: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,20 +189,19 @@ 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 db
|
const parent = await prisma.catalogPages.findUnique({
|
||||||
.select({ parentId: CatalogPages.parentId })
|
where: { id: currentId },
|
||||||
.from(CatalogPages)
|
select: { parentId: true },
|
||||||
.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 db
|
await prisma.catalogPages.update({
|
||||||
.update(CatalogPages)
|
where: { id: pageId },
|
||||||
.set({ parentId: newParentId })
|
data: { parentId: newParentId },
|
||||||
.where(eq(CatalogPages.id, pageId));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,10 +211,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 db.execute(sql`
|
await prisma.$executeRaw`
|
||||||
DELETE FROM catalog_items
|
DELETE FROM catalog_items
|
||||||
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||||
`);
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,23 +224,22 @@ 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 db
|
const page = await prisma.catalogPages.findUnique({
|
||||||
.select({ parentId: CatalogPages.parentId })
|
where: { id: pageId },
|
||||||
.from(CatalogPages)
|
select: { parentId: true },
|
||||||
.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 db
|
const result = await prisma.catalogPages.updateMany({
|
||||||
.update(CatalogPages)
|
where: { parentId: pageId },
|
||||||
.set({ parentId: page.parentId })
|
data: { parentId: page.parentId },
|
||||||
.where(eq(CatalogPages.parentId, pageId));
|
});
|
||||||
|
|
||||||
await deleteCatalogItemsByPageIds([pageId]);
|
await deleteCatalogItemsByPageIds([pageId]);
|
||||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
|
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||||
|
|
||||||
return { deletedPages: 1, movedChildren: result.affectedRows };
|
return { deletedPages: 1, movedChildren: result.count };
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = await cascadeDelete(pageId);
|
const deleted = await cascadeDelete(pageId);
|
||||||
@@ -262,10 +251,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 db
|
const children = await prisma.catalogPages.findMany({
|
||||||
.select({ id: CatalogPages.id })
|
where: { parentId: { in: queue } },
|
||||||
.from(CatalogPages)
|
select: { id: true },
|
||||||
.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);
|
||||||
@@ -275,7 +264,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 db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
|
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return toDelete.length;
|
return toDelete.length;
|
||||||
@@ -290,15 +279,10 @@ 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 db
|
const page = await prisma.catalogPages.findUnique({
|
||||||
.select({
|
where: { id: currentId },
|
||||||
id: CatalogPages.id,
|
select: { id: true, caption: true, parentId: true },
|
||||||
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,6 +1,4 @@
|
|||||||
import { type SQL, sql } from "drizzle-orm";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
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}$/;
|
||||||
|
|
||||||
@@ -43,18 +41,11 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
|
|||||||
permissionMaxValues: Record<string, number>;
|
permissionMaxValues: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */
|
type RawDb = {
|
||||||
type RawDb = Pick<Db, "execute" | "transaction">;
|
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>;
|
||||||
|
$executeRaw: (query: Prisma.Sql) => Promise<number>;
|
||||||
async function rawRows<T>(db: RawDb, query: SQL | string): Promise<T> {
|
$transaction: <T>(queries: Promise<T>[]) => 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) {
|
||||||
@@ -88,9 +79,8 @@ 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 rawRows<Record<string, unknown>[]>(
|
const rows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||||
db,
|
Prisma.sql`
|
||||||
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
|
||||||
@@ -112,9 +102,8 @@ export async function fetchEmulatorRankForEdit(
|
|||||||
db: RawDb,
|
db: RawDb,
|
||||||
rankId: number,
|
rankId: number,
|
||||||
): Promise<EmulatorRankForEdit | null> {
|
): Promise<EmulatorRankForEdit | null> {
|
||||||
const rankRows = await rawRows<Record<string, unknown>[]>(
|
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||||
db,
|
Prisma.sql`
|
||||||
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
|
||||||
@@ -125,16 +114,15 @@ 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 rawRows<
|
const permissionRows = await db.$queryRaw<
|
||||||
{
|
{
|
||||||
permission_key: string;
|
permission_key: string;
|
||||||
max_value: number;
|
max_value: number;
|
||||||
value: number | string | null;
|
value: number | string | null;
|
||||||
}[]
|
}[]
|
||||||
>(
|
>(
|
||||||
db,
|
Prisma.sql`
|
||||||
sql`
|
SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value
|
||||||
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
|
||||||
`,
|
`,
|
||||||
@@ -171,41 +159,40 @@ 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 rawRows<{ next_id: number }[]>(
|
const rows = await db.$queryRaw<{ next_id: number }[]>(
|
||||||
db,
|
Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
||||||
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(async (tx) => {
|
await db.$transaction([
|
||||||
await rawExec(
|
db.$executeRaw(
|
||||||
tx,
|
Prisma.sql`
|
||||||
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})
|
||||||
`,
|
`,
|
||||||
);
|
),
|
||||||
await rawExec(
|
db.$executeRaw(
|
||||||
tx,
|
Prisma.sql`
|
||||||
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(async (tx) => {
|
await db.$transaction([
|
||||||
await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
|
db.$executeRaw(
|
||||||
await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`);
|
Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`,
|
||||||
});
|
),
|
||||||
|
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmulatorRank(
|
export async function updateEmulatorRank(
|
||||||
@@ -233,13 +220,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]) => sql`${sql.raw(quoteIdentifier(key))} = ${value}`,
|
([key, value]) =>
|
||||||
|
Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
|
||||||
);
|
);
|
||||||
return rawExec(
|
return db.$executeRaw(
|
||||||
db,
|
Prisma.sql`
|
||||||
sql`
|
UPDATE ${Prisma.raw(quoteIdentifier(table))}
|
||||||
UPDATE ${sql.raw(quoteIdentifier(table))}
|
SET ${Prisma.join(assignments, ", ")}
|
||||||
SET ${sql.join(assignments, sql`, `)}
|
|
||||||
WHERE id = ${rankId}
|
WHERE id = ${rankId}
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
@@ -253,20 +240,19 @@ 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]) => sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
|
([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
|
||||||
);
|
);
|
||||||
const keys = entries.map(([key]) => key);
|
const keys = entries.map(([key]) => key);
|
||||||
|
|
||||||
return rawExec(
|
return db.$executeRaw(
|
||||||
db,
|
Prisma.sql`
|
||||||
sql`
|
|
||||||
UPDATE permission_definitions
|
UPDATE permission_definitions
|
||||||
SET ${sql.raw(quoteIdentifier(rankColumn))} =
|
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
|
||||||
CASE permission_key
|
CASE permission_key
|
||||||
${sql.join(cases, sql` `)}
|
${Prisma.join(cases, " ")}
|
||||||
ELSE ${sql.raw(quoteIdentifier(rankColumn))}
|
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
|
||||||
END
|
END
|
||||||
WHERE permission_key IN (${sql.join(keys, sql`, `)})
|
WHERE permission_key IN (${Prisma.join(keys)})
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,9 +272,8 @@ 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 rawRows<{ c: number }[]>(
|
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
|
||||||
db,
|
Prisma.sql`
|
||||||
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()
|
||||||
@@ -298,30 +283,27 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
|||||||
);
|
);
|
||||||
if (Number(exists[0]?.c ?? 0) > 0) return;
|
if (Number(exists[0]?.c ?? 0) > 0) return;
|
||||||
|
|
||||||
await rawExec(
|
await db.$executeRaw(
|
||||||
db,
|
Prisma.sql`
|
||||||
sql`
|
|
||||||
ALTER TABLE permission_definitions
|
ALTER TABLE permission_definitions
|
||||||
ADD COLUMN ${sql.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
|
ADD COLUMN ${Prisma.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 rawExec(
|
await db.$executeRaw(
|
||||||
db,
|
Prisma.sql`
|
||||||
sql`
|
|
||||||
UPDATE permission_definitions
|
UPDATE permission_definitions
|
||||||
SET ${sql.raw(quoteIdentifier(rankColumn))} = 0
|
SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTableColumns(db: RawDb, table: string) {
|
async function getTableColumns(db: RawDb, table: string) {
|
||||||
const rows = await rawRows<{ column_name: string }[]>(
|
const rows = await db.$queryRaw<{ column_name: string }[]>(
|
||||||
db,
|
Prisma.sql`
|
||||||
sql`
|
|
||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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