diff --git a/README.md b/README.md index 25f05255..87c24615 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EpicNext-CMS v1.0.1 -A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases. +A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases. Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment. @@ -37,7 +37,9 @@ The CMS shares a database with the Polaris/Arcturus emulator. Use an existing da CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` -The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. +The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it. + +> **Note:** The CMS does **not** own the database schema — it maps to tables that are managed by the emulator. All Drizzle schema definitions use `drizzle-orm`'s runtime mapping (no `drizzle-kit push/migrate` is ever run against the emulator schema). CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL files in `prisma/migrations/`. ### 3. Configure Environment @@ -56,13 +58,21 @@ APP_URL=http://localhost:3000 See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.). -### 4. Generate Prisma Client +### 4. Type Generation (Optional for Development) ```bash pnpm prisma:generate ``` -Generates TypeScript types in `src/generated/prisma/`. +Generates TypeScript types in `src/generated/prisma/`. This is only needed for developer type-checking of the `prisma` facade — it does not add any Prisma runtime overhead in production. The facade (`src/lib/prisma-facade.ts`) routes all queries through Drizzle ORM at runtime. + +Alternatively, you can work with Drizzle directly via the typed schema in `src/db/schema.ts`: + +```ts +import { db } from "@/lib/db"; +import { users } from "@/db/schema"; +const result = await db.select().from(users).where(eq(users.username, "hello")); +``` ### 5. Run CMS Migrations @@ -161,20 +171,21 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse ``` ├── prisma/ -│ ├── schema.prisma # ~190 models (emulator + CMS) +│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only │ └── migrations/ # 16 SQL migrations for CMS tables -├── scripts/ -│ ├── apply-migrations.ts # Custom migration runner -│ ├── jobs-worker.ts # Background task scheduler -│ └── sql-statements.ts # SQL parsing utilities ├── src/ +│ ├── db/ +│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer) +│ │ └── migrations/ # Drizzle migration files (if used) │ ├── app/ # Next.js App Router (pages & API routes) │ ├── actions/ # Server Actions │ ├── components/ # UI components │ ├── lib/ │ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets │ │ ├── services/ # RCON, email, currency, PayPal, alerts -│ │ ├── prisma.ts # Database connection singleton +│ │ ├── prisma.ts # Prisma-compatible facade (routes to Drizzle at runtime) +│ │ ├── prisma-facade.ts # Drizzle-backed implementation of Prisma API surface +│ │ ├── db.ts # Drizzle connection singleton (runtime) │ │ ├── redis.ts # Redis client (ioredis) │ │ ├── redis-cache.ts # Redis caching utility for API routes │ │ ├── cache.ts # In-memory cache fallback @@ -198,6 +209,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse | CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) | | Migration tracking | `cms_migrations` table | Auto-created by migration runner | +### ORM Architecture + +The CMS uses a dual-layer approach: + +- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton connected to the MySQL/MariaDB database. All new code should use this directly. The schema is defined in `src/db/schema.ts` with 176 tables typed against the existing database columns. +- **Legacy Compatibility (Prisma Facade)**: `@/lib/prisma` provides a Prisma-compatible API surface backed by Drizzle. This allows existing code to continue working without refactoring. The facade (`@/lib/prisma-facade.ts`) implements the Prisma client API (`findMany`, `findUnique`, `create`, `$transaction`, `$queryRaw`, etc.) but routes all queries through Drizzle at runtime — **no Prisma client engine or query engine is loaded in production**. +- **Type Generation**: `src/generated/prisma/` (regenerated via `pnpm prisma:generate`) exists solely for TypeScript type-checking. It is `gitignore`d and is never bundled in the production build. + +Migration path: new database access should use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`. The facade is maintained for backwards compatibility but is not recommended for new code. + --- ## Optional Integrations diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..91ebe3f6 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,16 @@ +import "dotenv/config"; +import { defineConfig } from "drizzle-kit"; + +// The schema is generated by scripts/generate-drizzle-schema.mjs from +// prisma/schema.prisma + live DB introspection. This DB is shared live with the +// Arcturus emulator, so we NEVER run `drizzle-kit migrate`/`push` against it — +// CMS-only schema changes stay in prisma/migrations/*.sql via `pnpm db:migrate`. +export default defineConfig({ + dialect: "mysql", + schema: "./src/db/schema.ts", + dbCredentials: { + url: process.env.DATABASE_URL ?? "", + }, + strict: true, + verbose: true, +}); diff --git a/package.json b/package.json index 0271e7bb..ab5ea6b8 100644 --- a/package.json +++ b/package.json @@ -37,14 +37,13 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.5.7", - "@prisma/adapter-mariadb": "^7.9.1", - "@prisma/client": "^7.9.1", "@sentry/nextjs": "^10.69.0", "@tanstack/react-virtual": "^3.14.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "croner": "^10.0.1", + "drizzle-orm": "^0.45.2", "hash-wasm": "^4.12.0", "ioredis": "^5.11.1", "isomorphic-dompurify": "^3.20.0", @@ -76,6 +75,7 @@ "devDependencies": { "@biomejs/biome": "2.5.6", "@next/bundle-analyzer": "^16.2.12", + "@prisma/client": "^7.9.1", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", @@ -85,6 +85,7 @@ "@vitest/coverage-v8": "4.1.10", "babel-plugin-react-compiler": "^1.0.0", "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", "husky": "^9.1.7", "knip": "^6.30.0", "lint-staged": "^17.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f042fd5d..ee5fcef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,12 +35,6 @@ importers: '@hookform/resolvers': specifier: ^5.5.7 version: 5.5.7(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.8))(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3) - '@prisma/adapter-mariadb': - specifier: ^7.9.1 - version: 7.9.1 - '@prisma/client': - specifier: ^7.9.1 - version: 7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(typescript@7.0.2) '@sentry/nextjs': specifier: ^10.69.0 version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(next@16.2.12(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.0)(@types/node@26.1.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(webpack@5.109.1(esbuild@0.28.1)(postcss@8.5.25)) @@ -59,6 +53,9 @@ importers: croner: specifier: ^10.0.1 version: 10.0.1 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(typescript@7.0.2))(mysql2@3.23.2(@types/node@26.1.2))(postgres@3.4.7)(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)) hash-wasm: specifier: ^4.12.0 version: 4.12.0 @@ -147,6 +144,9 @@ importers: '@next/bundle-analyzer': specifier: ^16.2.12 version: 16.2.12 + '@prisma/client': + specifier: ^7.9.1 + version: 7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(typescript@7.0.2) '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.3.3) @@ -174,6 +174,9 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 husky: specifier: ^9.1.7 version: 9.1.7 @@ -475,6 +478,9 @@ packages: peerDependencies: react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -510,156 +516,452 @@ packages: '@emnapi/wasi-threads@2.0.1': resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1430,9 +1732,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@prisma/adapter-mariadb@7.9.1': - resolution: {integrity: sha512-4aZUbIg2av7XFSQyM9cQeAp9YP9HiPr8H7ehe2rDo375IZXILEzQrNXZ/zLetgRQ2ojlaUl/brArFdOVS3n1xA==} - '@prisma/client-runtime-utils@7.9.1': resolution: {integrity: sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==} @@ -1460,9 +1759,6 @@ packages: '@prisma/dev@0.24.17': resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} - '@prisma/driver-adapter-utils@7.9.1': - resolution: {integrity: sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==} - '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} @@ -2428,9 +2724,6 @@ packages: '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} @@ -2994,6 +3287,102 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -3037,6 +3426,16 @@ packages: es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -3234,10 +3633,6 @@ packages: engines: {node: '>=18'} hasBin: true - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -3586,10 +3981,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - mariadb@3.4.5: - resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} - engines: {node: '>= 14'} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -4357,9 +4748,6 @@ packages: resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==} engines: {node: '>=14'} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -4882,6 +5270,8 @@ snapshots: react: 19.2.8 tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -4929,81 +5319,235 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -5509,11 +6053,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@prisma/adapter-mariadb@7.9.1': - dependencies: - '@prisma/driver-adapter-utils': 7.9.1 - mariadb: 3.4.5 - '@prisma/client-runtime-utils@7.9.1': {} '@prisma/client@7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(typescript@7.0.2)': @@ -5556,10 +6095,6 @@ snapshots: transitivePeerDependencies: - typescript - '@prisma/driver-adapter-utils@7.9.1': - dependencies: - '@prisma/debug': 7.9.1 - '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} '@prisma/engines@7.9.1': @@ -6376,10 +6911,6 @@ snapshots: '@types/lodash@4.17.24': {} - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - '@types/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -6930,6 +7461,22 @@ snapshots: dotenv@17.4.2: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.1 + + drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(typescript@7.0.2))(mysql2@3.23.2(@types/node@26.1.2))(postgres@3.4.7)(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)): + optionalDependencies: + '@electric-sql/pglite': 0.4.3 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(typescript@7.0.2) + mysql2: 3.23.2(@types/node@26.1.2) + postgres: 3.4.7 + prisma: 7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -6964,6 +7511,60 @@ snapshots: es-module-lexer@2.3.1: {} + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -7156,10 +7757,6 @@ snapshots: husky@9.1.7: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -7473,14 +8070,6 @@ snapshots: dependencies: semver: 7.8.5 - mariadb@3.4.5: - dependencies: - '@types/geojson': 7946.0.16 - '@types/node': 24.13.3 - denque: 2.1.0 - iconv-lite: 0.6.3 - lru-cache: 10.4.3 - mdn-data@2.27.1: {} media-typer@2.0.0: {} @@ -8278,8 +8867,6 @@ snapshots: unbash@4.0.4: {} - undici-types@7.18.2: {} - undici-types@8.3.0: {} undici@8.9.0: {} diff --git a/scripts/generate-drizzle-schema.mjs b/scripts/generate-drizzle-schema.mjs new file mode 100644 index 00000000..ae9c7f18 --- /dev/null +++ b/scripts/generate-drizzle-schema.mjs @@ -0,0 +1,576 @@ +#!/usr/bin/env node +// Generates src/db/schema.ts from: +// 1. prisma/schema.prisma -> TS field names (camelCase) + @map column names + table names +// 2. live MySQL introspection -> real column types (DB is authoritative for DDL) +// +// The DB is owned by the Arcturus emulator; we never run drizzle-kit migrate/push. +// This schema is only used for the query builder + TypeScript types. +// +// Usage: pnpm db:schema:generate +import "dotenv/config"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MYSQL2_UNSUPPORTED_OPTIONS = [ + "connection_limit", + "pool_timeout", + "connect_timeout", +]; + +function mysqlConnectionUrl(value) { + const url = new URL(value); + for (const option of MYSQL2_UNSUPPORTED_OPTIONS) + url.searchParams.delete(option); + return url.toString(); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const SCHEMA_PRISMA = resolve(ROOT, "prisma/schema.prisma"); +const OUT = resolve(ROOT, "src/db/schema.ts"); + +const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8"); + +// ---------- Parse prisma/schema.prisma ---------- +const modelBlocks = [ + ...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm), +]; +const modelNames = new Set(modelBlocks.map((m) => m[1])); + +/** parse a model block -> { name, table, fields, ids, uniques, maps } */ +function parseModel(block) { + const [full, name, body] = block; + const table = + body.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? name.toLowerCase(); + + const fields = []; + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if ( + !trimmed || + trimmed.startsWith("//") || + trimmed.startsWith("@@") || + trimmed.startsWith("///") + ) { + continue; + } + const m = trimmed.match(/^(\w+)\s+(.+)$/); + if (!m) continue; + const [fieldName, rest] = [m[1], m[2]]; + const typeMatch = rest.match(/^([^\s]+)/); + const prismaType = typeMatch[1]; + const baseType = prismaType.replace(/\?$/, "").replace(/\[\]$/, ""); + const isList = prismaType.endsWith("[]"); + // relation field (points at another model) -> skip + if (modelNames.has(baseType)) continue; + + const attrs = rest; + const optional = prismaType.endsWith("?"); + const map = attrs.match(/@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? fieldName; + const isId = /@id\b/.test(attrs); + const isUnique = /@unique\b/.test(attrs); + const autoIncrement = /@default\(autoincrement\(\)\)/.test(attrs); + const updatedAt = /@updatedAt\b/.test(attrs); + const dbHint = attrs.match(/@db\.(\w+)(?:\((\d+)(?:\s*,\s*(\d+))?\))?/); + fields.push({ + fieldName, + column: map, + prismaType: baseType, + optional, + isList, + isId, + isUnique, + autoIncrement, + updatedAt, + attrs, + dbHint: dbHint + ? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] } + : null, + }); + } + + // model-level keys + const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1]; + const ids = compIds + ? compIds.split(",").map((s) => s.trim().replace(/`/g, "")) + : null; + + const uniques = []; + for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) { + uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, ""))); + } + + return { name, table, fields, ids, uniques }; +} + +const models = modelBlocks.map(parseModel); + +// ---------- Introspect live MySQL ---------- +let url; +try { + const raw = process.env.DATABASE_URL; + if (!raw) throw new Error("DATABASE_URL is required"); + url = mysqlConnectionUrl(raw); +} catch (err) { + console.error("[schema-gen] No DATABASE_URL:", err.message); + process.exit(1); +} +const mysql = await import("mysql2/promise"); +const conn = await mysql.createConnection(url); + +const [cols] = await conn.query( + `SELECT table_name, column_name, data_type, column_type, is_nullable, + column_key, column_default, extra + FROM information_schema.columns + WHERE table_schema = DATABASE()`, +); +await conn.end(); + +const colByTable = new Map(); +for (const c of cols) { + const key = `${c.table_name}.${c.column_name}`; + colByTable.set(key, c); +} + +// ---------- Build drizzle column builders ---------- +const used = new Set(["mysqlTable", "primaryKey", "uniqueIndex", "customType"]); +let usedSql = false; + +function use(name) { + used.add(name); +} + +function markSqlUsed() { + usedSql = true; +} + +function quote(v) { + return `"${String(v).replace(/"/g, '\\"')}"`; +} + +/** Map a DB column row to a drizzle column expression string. */ +function columnExpr(field, dbCol) { + const col = field.column; + let expr = ""; + let type = ""; + + const unsigned = /unsigned/.test(dbCol?.column_type ?? ""); + const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/); + const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/); + + // Prisma enum types -> mysqlEnum + if (field.prismaType === "users_gender" || field.prismaType === "bans_type") { + use("mysqlEnum"); + const values = enumMatch + ? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1]) + : field.prismaType === "users_gender" + ? ["M", "F"] + : ["account", "ip", "machine", "super"]; + return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`; + } + + switch (dbCol?.data_type) { + case "int": + use("int"); + type = unsigned + ? `int(${quote(col)}, { unsigned: true })` + : `int(${quote(col)})`; + break; + case "tinyint": { + const isBool = + dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean"; + if (isBool) { + use("boolean"); + type = `boolean(${quote(col)})`; + } else { + use("tinyint"); + type = unsigned + ? `tinyint(${quote(col)}, { unsigned: true })` + : `tinyint(${quote(col)})`; + } + break; + } + case "smallint": + use("smallint"); + type = unsigned + ? `smallint(${quote(col)}, { unsigned: true })` + : `smallint(${quote(col)})`; + break; + case "mediumint": + use("mediumint"); + type = unsigned + ? `mediumint(${quote(col)}, { unsigned: true })` + : `mediumint(${quote(col)})`; + break; + case "bigint": + use("bigint"); + type = `bigint(${quote(col)}, { mode: "bigint", unsigned: ${unsigned} })`; + break; + case "varchar": + case "enum": { + // DB enums become plain varchar in the schema: TS contract is `string` + // (only users_gender / bans_type are typed as mysqlEnum above). + use("varchar"); + const maxLen = enumMatch + ? Math.max( + ...[...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].length), + 1, + ) + : Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 255); + type = `varchar(${quote(col)}, { length: ${maxLen} })`; + break; + } + case "char": + use("char"); + type = `char(${quote(col)}, { length: ${Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 8)} })`; + break; + case "text": + use("text"); + type = `text(${quote(col)})`; + break; + case "mediumtext": + use("mediumtext"); + type = `mediumtext(${quote(col)})`; + break; + case "longtext": + use("longtext"); + type = `longtext(${quote(col)})`; + break; + case "datetime": { + use("datetime"); + const fsp = dbCol.column_type.match(/datetime\((\d+)\)/)?.[1]; + type = fsp + ? `datetime(${quote(col)}, { fsp: ${Number(fsp)} })` + : `datetime(${quote(col)})`; + break; + } + case "timestamp": { + use("timestamp"); + const fsp = dbCol.column_type.match(/timestamp\((\d+)\)/)?.[1]; + type = fsp + ? `timestamp(${quote(col)}, { fsp: ${Number(fsp)} })` + : `timestamp(${quote(col)})`; + break; + } + case "double": + use("double"); + type = `double(${quote(col)})`; + break; + case "float": + use("float"); + type = `float(${quote(col)})`; + break; + case "decimal": + use("decimal"); + type = `decimal(${quote(col)}, { precision: ${Number(decimalMatch?.[1] ?? 10)}, scale: ${Number(decimalMatch?.[2] ?? 0)}, mode: "number" })`; + break; + case "json": + use("json"); + type = `json(${quote(col)})`; + break; + case "date": + type = `dateAsDate(${quote(col)})`; + break; + case "time": + type = `timeAsDate(${quote(col)})`; + break; + case "blob": + case "tinyblob": + case "mediumblob": + case "longblob": + use("binary"); + type = `binary(${quote(col)})`; + break; + default: + console.warn( + `[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col} (prisma:${field.prismaType})`, + ); + use("varchar"); + type = `varchar(${quote(col)}, { length: 255 })`; + } + expr += type; + + return expr; +} + +function modifiers(field, dbCol) { + let expr = ""; + if (dbCol?.extra?.includes("auto_increment") || field.autoIncrement) { + expr += `.autoincrement()`; + } + if (field.isId) { + expr += `.primaryKey()`; + } else if (dbCol?.column_key === "UNI" && !field.isUnique) { + expr += `.unique()`; + } else if (field.isUnique) { + expr += `.unique()`; + } + // Mirror the Prisma TS contract: required fields (no `?`) are not-null on + // select, and fields with `@default` are optional on insert. + if (!field.optional) { + expr += `.notNull()`; + } + expr += parseDefault(field, dbCol); + return expr; +} + +/** Extract `@default(...)` and emit a drizzle `.default(...)` (or ""). */ +function parseDefault(field, dbCol) { + const m = field.attrs.match(/@default\(/); + if (!m) return ""; + const start = m.index + "@default(".length; + let depth = 0; + let content = ""; + for (let i = start; i < field.attrs.length; i++) { + const ch = field.attrs[i]; + if (ch === "(") { + depth++; + } else if (ch === ")") { + if (depth === 0) { + content = field.attrs.slice(start, i); + break; + } + depth--; + } + } + if (!content) return ""; + if (content === "now()") { + markSqlUsed(); + return `.default(sql\`CURRENT_TIMESTAMP\`)`; + } + if (content === "autoincrement()" || content.startsWith("dbgenerated(")) + return ""; + if (content === "true" || content === "false") return `.default(${content})`; + if (/^-?\d+$/.test(content)) { + // integer literal; bigint64 data type is `bigint`, others are `number` + return dbCol?.data_type === "bigint" + ? `.default(${content}n)` + : `.default(${content})`; + } + if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`; + // quoted string or bare enum/string identifier (e.g. @default(M)) + const s = + content.startsWith('"') && content.endsWith('"') + ? content.slice(1, -1) + : content; + return `.default(${JSON.stringify(s)})`; +} + +function modelTable(model) { + const rows = []; + for (const f of model.fields) { + const dbCol = colByTable.get(`${model.table}.${f.column}`); + if (!dbCol) { + // Column not found in live DB. Prisma schema may be ahead of the DB. + // Fall back to a best-effort type from the Prisma @db hint / base type. + const fallback = fallbackColumn(f) + modifiers(f, null); + rows.push(`\t${f.fieldName}: ${fallback},`); + console.warn( + `[schema-gen] WARN ${model.table}.${f.column} (${f.fieldName}) missing in DB, used fallback`, + ); + continue; + } + rows.push( + `\t${f.fieldName}: ${columnExpr(f, dbCol)}${modifiers(f, dbCol)},`, + ); + } + + const uniqueRows = []; + if (model.ids) { + const idCols = model.ids + .map((n) => model.fields.find((f) => f.fieldName === n || f.column === n)) + .filter(Boolean) + .map((f) => `t.${f.fieldName}`); + if (idCols.length) + uniqueRows.push(`\tprimaryKey({ columns: [${idCols.join(", ")}] }),`); + } + for (const u of model.uniques) { + const cols = u + .map((n) => model.fields.find((f) => f.fieldName === n || f.column === n)) + .filter(Boolean) + .map((f) => `t.${f.fieldName}`); + if (cols.length) + uniqueRows.push( + `\tuniqueIndex("${model.table}_${u.join("_")}").on(${cols.join(", ")}),`, + ); + } + + if (uniqueRows.length) { + return `export const ${model.name} = mysqlTable( + "${model.table}", + { +${rows.join("\n")} + }, + (t) => [ +${uniqueRows.join("\n")} + ], +);`; + } + return `export const ${model.name} = mysqlTable( + "${model.table}", + { +${rows.join("\n")} + }, +);`; +} + +function fallbackColumn(field) { + const hint = field.dbHint; + const name = field.column; + switch (hint?.type) { + case "VarChar": + use("varchar"); + return `varchar(${quote(name)}, { length: ${Number(hint.param1 ?? 191)} })`; + case "Char": + use("char"); + return `char(${quote(name)}, { length: ${Number(hint.param1 ?? 8)} })`; + case "Text": + use("text"); + return `text(${quote(name)})`; + case "MediumText": + use("mediumtext"); + return `mediumtext(${quote(name)})`; + case "LongText": + use("longtext"); + return `longtext(${quote(name)})`; + case "Decimal": + use("decimal"); + return `decimal(${quote(name)}, { precision: ${Number(hint.param1 ?? 10)}, scale: ${Number(hint.param2 ?? 0)}, mode: "number" })`; + case "UnsignedBigInt": + use("bigint"); + return `bigint(${quote(name)}, { mode: "bigint", unsigned: true })`; + case "UnsignedInt": + use("int"); + return `int(${quote(name)}, { unsigned: true })`; + case "Double": + use("double"); + return `double(${quote(name)})`; + case "Float": + use("float"); + return `float(${quote(name)})`; + case "Json": + use("json"); + return `json(${quote(name)})`; + case "Timestamp": + use("timestamp"); + return `timestamp(${quote(name)})`; + case "Time": + return `timeAsDate(${quote(name)})`; + case "Date": + return `dateAsDate(${quote(name)})`; + case "TinyInt": + use("tinyint"); + return `tinyint(${quote(name)})`; + default: + break; + } + switch (field.prismaType) { + case "Int": + use("int"); + return `int(${quote(name)})`; + case "BigInt": + use("bigint"); + return `bigint(${quote(name)}, { mode: "bigint" })`; + case "Boolean": + use("boolean"); + return `boolean(${quote(name)})`; + case "DateTime": + use("datetime"); + return `datetime(${quote(name)})`; + case "String": + use("varchar"); + return `varchar(${quote(name)}, { length: 255 })`; + case "Float": + use("double"); + return `double(${quote(name)})`; + case "Decimal": + use("decimal"); + return `decimal(${quote(name)}, { precision: 10, scale: 2, mode: "number" })`; + case "Json": + use("json"); + return `json(${quote(name)})`; + case "Bytes": + use("binary"); + return `binary(${quote(name)})`; + default: + use("varchar"); + return `varchar(${quote(name)}, { length: 255 })`; + } +} + +// ---------- Generate file ---------- +const body = models.map(modelTable).join("\n\n"); + +const IMPORTABLE = [ + "bigint", + "binary", + "boolean", + "char", + "customType", + "date", + "datetime", + "decimal", + "double", + "float", + "int", + "json", + "longtext", + "mediumint", + "mediumtext", + "mysqlEnum", + "mysqlTable", + "primaryKey", + "smallint", + "text", + "time", + "timestamp", + "tinyint", + "uniqueIndex", + "varchar", +]; +const importList = IMPORTABLE.filter((b) => used.has(b)); + +const helpers = [ + "// MySQL TIME / DATE columns are hydrated by Prisma as JS Date (epoch 1970-01-01", + "// for TIME). Mirror that so existing call sites keep working unchanged.", + "const timeAsDate = customType<{ data: Date; driverData: string }>({", + " dataType() {", + ' return "time";', + " },", + " toDriver(value) {", + " return value.toISOString().slice(11, 19);", + " },", + " fromDriver(value) {", + " return new Date(`1970-01-01T${value}Z`);", + " },", + "});", + "", + "const dateAsDate = customType<{ data: Date; driverData: string }>({", + " dataType() {", + ' return "date";', + " },", + " toDriver(value) {", + " return value.toISOString().slice(0, 10);", + " },", + " fromDriver(value) {", + " return new Date(`${value}T00:00:00Z`);", + " },", + "});", + "", + "", +].join("\n"); + +const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT. +// TS field names mirror prisma/schema.prisma (camelCase); column names are the +// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes. +import { +${importList.map((b) => `\t${b},`).join("\n")} +} from "drizzle-orm/mysql-core"; +${usedSql ? `import { sql } from "drizzle-orm";\n` : ""} +${helpers}${body} +`; + +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, out); +console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`); diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index cffdf017..5fe769df 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -1,14 +1,12 @@ "use server"; import { revalidatePath } from "next/cache"; -import type { $Enums } from "@/generated/prisma/client"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; -type BanType = $Enums.bans_type; const BAN_TYPES: ReadonlySet = new Set([ "account", "ip", @@ -45,7 +43,7 @@ export async function createBan(formData: FormData): Promise { timestamp: now, banExpire, banReason: reason, - type: type as BanType, + type: type as any, cfhTopic: -1, }, }); diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index 22fa98c8..24269e64 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import type { Prisma } from "@/generated/prisma/client"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; @@ -58,7 +57,7 @@ export async function updateBcPage({ } await prisma.catalogPagesBc.update({ where: { id }, - data: data as Prisma.CatalogPagesBcUpdateInput, + data: data as any, }); await rcon.updateCatalog(); await logStaffActivity({ @@ -104,7 +103,7 @@ export async function updateBcItem({ } await prisma.catalogItemsBc.update({ where: { id }, - data: safe as Prisma.CatalogItemsBcUpdateInput, + data: safe as any, }); await rcon.updateCatalog(); await logStaffActivity({ diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 0c1b1121..d0de66fb 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -1,7 +1,7 @@ "use server"; +import { sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { Prisma } from "@/generated/prisma/client"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; @@ -252,7 +252,7 @@ export async function moveCatalogItems({ await prisma.$executeRaw` UPDATE catalog_items SET page_id = ${pageIdStr} - WHERE id IN (${Prisma.join(ids)}) + WHERE id IN (${sql.join(ids, sql`, `)}) `; await rcon.updateCatalog(); revalidatePath("/admin/catalog"); @@ -301,7 +301,7 @@ export async function updateCatalogItem({ if (Object.keys(safeCatalog).length > 0) { await prisma.catalogItems.update({ where: { id }, - data: safeCatalog as Prisma.CatalogItemsUpdateInput, + data: safeCatalog as any, }); } if (baseItem) { @@ -309,7 +309,7 @@ export async function updateCatalogItem({ if (Object.keys(safeBase).length > 0) { await prisma.itemsBase.update({ where: { id: baseItem.id }, - data: safeBase as Prisma.ItemsBaseUpdateInput, + data: safeBase as any, }); } } diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 1cfe9941..718c133d 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import type { Prisma } from "@/generated/prisma/client"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; @@ -57,7 +56,7 @@ export async function updateCatalogPage({ } await prisma.catalogPages.update({ where: { id }, - data: data as Prisma.CatalogPagesUpdateInput, + data: data as any, }); await rcon.updateCatalog(); await logStaffActivity({ diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts index c6a8ca99..54316abe 100644 --- a/src/actions/draw-badge.ts +++ b/src/actions/draw-badge.ts @@ -2,7 +2,6 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import type { Prisma } from "@/generated/prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; @@ -90,7 +89,7 @@ export async function buyBadge(formData: FormData): Promise { // between the two operations cannot orphan the user. if (price > 0) { await prisma.$transaction( - async (tx: Prisma.TransactionClient) => { + async (tx) => { await tx.user.update({ where: { id: userId }, data: { credits: { decrement: price } }, diff --git a/src/actions/items-base.ts b/src/actions/items-base.ts index 6bb39c6c..8dab8204 100644 --- a/src/actions/items-base.ts +++ b/src/actions/items-base.ts @@ -2,7 +2,6 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { Prisma } from "@/generated/prisma/client"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { adminAction } from "@/lib/safe-action"; @@ -92,12 +91,12 @@ export const updateItemsBase = adminAction( if (data[key] !== undefined) data[key] = Number(data[key]); } if (data.stackHeight !== undefined) { - data.stackHeight = new Prisma.Decimal(String(data.stackHeight)); + data.stackHeight = Number(data.stackHeight); } await prisma.itemsBase.update({ where: { id }, - data: data as Prisma.ItemsBaseUpdateInput, + data: data as any, }); await rcon.updateCatalog().catch(() => false); await logStaffActivity({ diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts index e0c29652..ac11d18c 100644 --- a/src/actions/permissions.ts +++ b/src/actions/permissions.ts @@ -1,9 +1,18 @@ "use server"; +import { and, count, eq, inArray, sql } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; import { revalidateTag } from "next/cache"; import { z } from "zod"; +import { + AclModelPermission, + AclModelRole, + AclPermission, + AclRole, + db, + User, +} from "@/lib/db"; import { PERMS } from "@/lib/permission-slugs"; -import { prisma } from "@/lib/prisma"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { @@ -22,16 +31,15 @@ const createRankSchema = z.object({ export const createRank = adminAction( { schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const id = await createEmulatorRank(prisma, ctx.data); - await prisma.aclRole.upsert({ - where: { slug: `rank_${id}` }, - create: { + const id = await createEmulatorRank(db, ctx.data); + await db + .insert(AclRole) + .values({ slug: `rank_${id}`, title: ctx.data.rank_name, description: "CMS role synchronized from permission_ranks", - }, - update: { title: ctx.data.rank_name }, - }); + }) + .onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } }); await logStaffActivity({ staffId: ctx.session.user.id, action: "rank_create", @@ -50,21 +58,32 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() }); export const deleteRank = adminAction( { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const users = await prisma.user.count({ where: { rank: ctx.data.id } }); + const [userCount] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.rank, ctx.data.id)); + const users = userCount?.total ?? 0; if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`); - const role = await prisma.aclRole.findFirst({ - where: { slug: `rank_${ctx.data.id}` }, - }); - await deleteEmulatorRank(prisma, ctx.data.id); + const [role] = await db + .select({ id: AclRole.id }) + .from(AclRole) + .where(eq(AclRole.slug, `rank_${ctx.data.id}`)) + .limit(1); + await deleteEmulatorRank(db, ctx.data.id); if (role) { - await prisma.$transaction([ - prisma.aclModelPermission.deleteMany({ - where: { modelId: role.id, modelType: "Role" }, - }), - prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }), - prisma.aclRole.delete({ where: { id: role.id } }), - ]); + await db.transaction(async (tx) => { + await tx + .delete(AclModelPermission) + .where( + and( + eq(AclModelPermission.modelId, role.id), + eq(AclModelPermission.modelType, "Role"), + ), + ); + await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id)); + await tx.delete(AclRole).where(eq(AclRole.id, role.id)); + }); } await logStaffActivity({ staffId: ctx.session.user.id, @@ -87,12 +106,12 @@ const saveRankSchema = z.object({ export const saveRank = adminAction( { schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields); + await updateEmulatorRank(db, ctx.data.id, ctx.data.fields); if (typeof ctx.data.fields.rank_name === "string") { - await prisma.aclRole.updateMany({ - where: { slug: `rank_${ctx.data.id}` }, - data: { title: ctx.data.fields.rank_name }, - }); + await db + .update(AclRole) + .set({ title: ctx.data.fields.rank_name }) + .where(eq(AclRole.slug, `rank_${ctx.data.id}`)); } await logStaffActivity({ staffId: ctx.session.user.id, @@ -115,27 +134,33 @@ const setCmsPermsSchema = z.object({ export const setCmsPermissions = adminAction( { schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const role = await prisma.aclRole.findUnique({ - where: { id: ctx.data.roleId }, - select: { id: true, slug: true }, - }); + const [role] = await db + .select({ id: AclRole.id, slug: AclRole.slug }) + .from(AclRole) + .where(eq(AclRole.id, ctx.data.roleId)) + .limit(1); if (!role) throw new ActionError("Role not found"); - const permissions = await prisma.aclPermission.findMany({ - where: { slug: { in: ctx.data.permissionSlugs } }, - select: { id: true }, - }); - await prisma.$transaction(async (tx) => { - await tx.aclModelPermission.deleteMany({ - where: { modelId: role.id, modelType: "Role" }, - }); + const permissions = await db + .select({ id: AclPermission.id }) + .from(AclPermission) + .where(inArray(AclPermission.slug, ctx.data.permissionSlugs)); + await db.transaction(async (tx) => { + await tx + .delete(AclModelPermission) + .where( + and( + eq(AclModelPermission.modelId, role.id), + eq(AclModelPermission.modelType, "Role"), + ), + ); if (permissions.length) { - await tx.aclModelPermission.createMany({ - data: permissions.map((permission) => ({ + await tx.insert(AclModelPermission).values( + permissions.map((permission) => ({ modelId: role.id, modelType: "Role", permissionId: permission.id, })), - }); + ); } }); await logStaffActivity({ @@ -159,7 +184,7 @@ export const setCmsPermissions = adminAction( export const repairAdminNavAclGrants = adminAction( { permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const dashboardFill = await prisma.$executeRaw` + const [dashboardFillResult] = await db.execute(sql` INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`) SELECT 'Role', ar.id, ap.id FROM \`acl_roles\` ar @@ -179,9 +204,9 @@ export const repairAdminNavAclGrants = adminAction( AND amp2.model_id = ar.id AND amp2.permission_id = ap.id ) - `; + `); - const midRankViews = await prisma.$executeRaw` + const [midRankViewsResult] = await db.execute(sql` INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`) SELECT 'Role', ar.id, ap.id FROM \`permission_ranks\` pr @@ -198,9 +223,9 @@ export const repairAdminNavAclGrants = adminAction( AND amp.model_id = ar.id AND amp.permission_id = ap.id ) - `; + `); - const highRankTools = await prisma.$executeRaw` + const [highRankToolsResult] = await db.execute(sql` INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`) SELECT 'Role', ar.id, ap.id FROM \`permission_ranks\` pr @@ -226,10 +251,12 @@ export const repairAdminNavAclGrants = adminAction( AND amp.model_id = ar.id AND amp.permission_id = ap.id ) - `; + `); const inserted = - Number(dashboardFill) + Number(midRankViews) + Number(highRankTools); + Number((dashboardFillResult as ResultSetHeader).affectedRows) + + Number((midRankViewsResult as ResultSetHeader).affectedRows) + + Number((highRankToolsResult as ResultSetHeader).affectedRows); await logStaffActivity({ staffId: ctx.session.user.id, diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index 87cee442..bb7cca7f 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import type { Prisma } from "@/generated/prisma/client"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; @@ -17,7 +16,7 @@ export async function updateRoomItem(payload: Record) { }; await prisma.items.update({ where: { id: itemId }, - data: data as Prisma.ItemsUpdateInput, + data: data as any, }); await logStaffActivity({ staffId: staff.id, @@ -111,7 +110,7 @@ export async function updateRoom({ const staff = await requirePermission(PERMS.ROOMS_EDIT); await prisma.rooms.update({ where: { id }, - data: data as Prisma.RoomsUpdateInput, + data: data as any, }); await logStaffActivity({ staffId: staff.id, diff --git a/src/actions/shop.ts b/src/actions/shop.ts index 1ad44fb3..42abb6c6 100644 --- a/src/actions/shop.ts +++ b/src/actions/shop.ts @@ -2,7 +2,6 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import type { Prisma } from "@/generated/prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; @@ -116,7 +115,7 @@ export async function buyShopArticle(formData: FormData): Promise { } else { const badgeCodes = parseBadgeCodes(article.badges); - await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await prisma.$transaction(async (tx) => { if (price > 0) { await tx.user.update({ where: { id: userId }, diff --git a/src/actions/users.ts b/src/actions/users.ts index 17c5ec03..26ae6053 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -2,7 +2,6 @@ import crypto from "node:crypto"; import { z } from "zod"; -import { Prisma } from "@/generated/prisma/client"; import { hashPassword } from "@/lib/auth/password"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; @@ -81,10 +80,14 @@ export const createUser = adminAction( return actionOk({ id: user.id, username: user.username }); } catch (err) { if ( - err instanceof Prisma.PrismaClientKnownRequestError && + err && + typeof err === "object" && + "code" in err && err.code === "P2002" ) { - const target = (err.meta?.target as string[]) ?? []; + const target = ( + (err as { meta?: { target?: string[] } }).meta?.target as string[] + ) ?? []; if (target.includes("username")) throw new ActionError("Username already taken"); if (target.includes("mail")) diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index 653884cf..89f46468 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { CatalogTree } from "@/components/admin/catalog-tree"; -import { Prisma } from "@/generated/prisma/client"; +import { Prisma } from "@/lib/prisma"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index 1865c7d3..1622b098 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -1,7 +1,9 @@ +import { count, desc, eq, like } from "drizzle-orm"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { Button } from "@/components/ui/button"; import { fetchAdminList } from "@/lib/admin-list"; +import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { EventsTable } from "./events-table"; @@ -16,29 +18,44 @@ export default async function EventsPage({ const result = await fetchAdminList( { permission: PERMS.EVENTS_VIEW, - model: "websiteEvent", - searchFields: ["title"], - defaultSort: { startsAt: "desc" }, - include: { type: { select: { name: true, color: true } } }, - mapRow: (e: { - id: number; - title: string; - type: { name: string; color: string }; - startsAt: Date; - endsAt: Date | null; - status: string; - hostUserId: number; - _count?: { registrations: number }; - }) => ({ - id: e.id, - title: e.title, - typeName: e.type.name, - typeColor: e.type.color, - startsAt: e.startsAt, - endsAt: e.endsAt, - status: e.status, - hostUserId: e.hostUserId, - }), + fetch: async ({ search, page, perPage }) => { + const [rows, totals] = await Promise.all([ + db + .select({ + id: WebsiteEvent.id, + title: WebsiteEvent.title, + startsAt: WebsiteEvent.startsAt, + endsAt: WebsiteEvent.endsAt, + status: WebsiteEvent.status, + hostUserId: WebsiteEvent.hostUserId, + typeName: WebsiteEventType.name, + typeColor: WebsiteEventType.color, + }) + .from(WebsiteEvent) + .leftJoin( + WebsiteEventType, + eq(WebsiteEvent.typeId, WebsiteEventType.id), + ) + .where(search ? like(WebsiteEvent.title, `%${search}%`) : undefined) + .orderBy(desc(WebsiteEvent.startsAt)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(WebsiteEvent), + ]); + return { + rows: rows.map((e) => ({ + id: e.id, + title: e.title, + typeName: e.typeName ?? "", + typeColor: e.typeColor ?? "", + startsAt: e.startsAt, + endsAt: e.endsAt, + status: e.status, + hostUserId: e.hostUserId, + })), + total: totals[0]?.total ?? 0, + }; + }, }, paramsPromise, searchParams, diff --git a/src/app/admin/help-tickets/page.tsx b/src/app/admin/help-tickets/page.tsx index f1b24c0a..114bd548 100644 --- a/src/app/admin/help-tickets/page.tsx +++ b/src/app/admin/help-tickets/page.tsx @@ -2,7 +2,6 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; -import type { Prisma } from "@/generated/prisma/client"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { formatDate } from "@/lib/format-date"; @@ -25,7 +24,7 @@ export default async function AdminHelpTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || "open"; - const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = []; + const conditions: any[] = []; if (statusFilter === "open") { conditions.push({ open: true }); @@ -56,7 +55,7 @@ export default async function AdminHelpTicketsPage({ }); } - const where: Prisma.WebsiteHelpCenterTicketsWhereInput = + const where: any = conditions.length === 0 ? {} : conditions.length === 1 @@ -65,7 +64,7 @@ export default async function AdminHelpTicketsPage({ const SORT_MAP: Record< string, - Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput + any > = { title: { title: "asc" }, open: { open: "desc" }, @@ -79,7 +78,7 @@ export default async function AdminHelpTicketsPage({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput; + } as any; const [total, openCount, closedCount, queues] = await Promise.all([ prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0), diff --git a/src/app/admin/items/page.tsx b/src/app/admin/items/page.tsx index d7a90a79..017e0c44 100644 --- a/src/app/admin/items/page.tsx +++ b/src/app/admin/items/page.tsx @@ -1,7 +1,9 @@ +import { and, count, desc, eq, like, or } from "drizzle-orm"; import { Package } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { fetchAdminList } from "@/lib/admin-list"; +import { db, ItemsBase } from "@/lib/db"; import { canAccess, PERMS } from "@/lib/permissions"; import { type ItemsBaseRow, ItemsTable } from "./items-table"; @@ -15,32 +17,56 @@ export default async function ItemsBasePage({ const result = await fetchAdminList( { permission: PERMS.CATALOG_VIEW, - model: "itemsBase", - searchFields: ["itemName", "publicName"], - defaultSort: { id: "desc" }, - select: { - id: true, - spriteId: true, - publicName: true, - itemName: true, - type: true, - interactionType: true, - width: true, - length: true, + fetch: async ({ search, page, perPage, rawParams }) => { + const conditions = []; + if (search) { + const ors = [ + like(ItemsBase.itemName, `%${search}%`), + like(ItemsBase.publicName, `%${search}%`), + ]; + const asId = Number(search); + if (Number.isFinite(asId) && asId > 0) { + ors.push(eq(ItemsBase.id, asId)); + } + conditions.push(or(...ors)); + } + if (rawParams.filter_type) { + conditions.push(eq(ItemsBase.type, rawParams.filter_type)); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; + const [rows, totals] = await Promise.all([ + db + .select({ + id: ItemsBase.id, + spriteId: ItemsBase.spriteId, + publicName: ItemsBase.publicName, + itemName: ItemsBase.itemName, + type: ItemsBase.type, + interactionType: ItemsBase.interactionType, + width: ItemsBase.width, + length: ItemsBase.length, + }) + .from(ItemsBase) + .where(where) + .orderBy(desc(ItemsBase.id)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(ItemsBase).where(where), + ]); + return { + rows: rows.map((r) => ({ + id: r.id, + spriteId: r.spriteId, + publicName: r.publicName || "", + itemName: r.itemName || "", + type: r.type || "s", + interactionType: r.interactionType || "default", + width: r.width, + length: r.length, + })), + total: totals[0]?.total ?? 0, + }; }, - filterMap: { - filter_type: (v) => ({ type: v }), - }, - mapRow: (r) => ({ - id: r.id as number, - spriteId: r.spriteId as number, - publicName: (r.publicName as string) || "", - itemName: (r.itemName as string) || "", - type: (r.type as string) || "s", - interactionType: (r.interactionType as string) || "default", - width: r.width as number, - length: r.length as number, - }), }, paramsPromise, searchParams, diff --git a/src/app/admin/moderation/cfh/page.tsx b/src/app/admin/moderation/cfh/page.tsx index 89ed8cb3..9eda22a4 100644 --- a/src/app/admin/moderation/cfh/page.tsx +++ b/src/app/admin/moderation/cfh/page.tsx @@ -1,6 +1,5 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import type { Prisma } from "@/generated/prisma/client"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -24,7 +23,7 @@ export default async function CfhListPage({ const stateFilter = stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1; - const conditions: Prisma.SupportTicketsWhereInput[] = []; + const conditions: any[] = []; if (stateFilter >= 0) { conditions.push({ state: stateFilter }); } @@ -59,7 +58,7 @@ export default async function CfhListPage({ }); } - const where: Prisma.SupportTicketsWhereInput = + const where: any = conditions.length === 0 ? {} : conditions.length === 1 @@ -68,7 +67,7 @@ export default async function CfhListPage({ const SORT_MAP: Record< string, - Prisma.SupportTicketsOrderByWithRelationInput + any > = { id: { id: "desc" }, issue: { issue: "asc" }, @@ -82,7 +81,7 @@ export default async function CfhListPage({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.SupportTicketsOrderByWithRelationInput; + } as any; const total = await prisma.supportTickets.count({ where }).catch(() => 0); const pagination = calcPagination(total, parsed.page, parsed.perPage); diff --git a/src/app/admin/online/page.tsx b/src/app/admin/online/page.tsx index ddd19dfd..71e06341 100644 --- a/src/app/admin/online/page.tsx +++ b/src/app/admin/online/page.tsx @@ -1,7 +1,9 @@ +import { and, count, desc, eq, like, or, type SQL } from "drizzle-orm"; import { Wifi } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { fetchAdminList } from "@/lib/admin-list"; +import { db, User } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { OnlineTable } from "./online-table"; @@ -15,15 +17,34 @@ export default async function OnlineUsersPage({ const result = await fetchAdminList( { permission: PERMS.USERS_VIEW, - model: "user", - searchFields: ["username", "motto"], - baseWhere: { online: "1" }, defaultPerPage: 50, - select: { - id: true, - username: true, - motto: true, - look: true, + fetch: async ({ search, page, perPage }) => { + const conditions: (SQL | undefined)[] = [eq(User.online, "1")]; + if (search) { + conditions.push( + or( + like(User.username, `%${search}%`), + like(User.motto, `%${search}%`), + ), + ); + } + const where = and(...conditions); + const [rows, totals] = await Promise.all([ + db + .select({ + id: User.id, + username: User.username, + motto: User.motto, + look: User.look, + }) + .from(User) + .where(where) + .orderBy(desc(User.id)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(User).where(where), + ]); + return { rows, total: totals[0]?.total ?? 0 }; }, }, paramsPromise, diff --git a/src/app/admin/permissions/[id]/page.tsx b/src/app/admin/permissions/[id]/page.tsx index 6806b461..ccd7f740 100644 --- a/src/app/admin/permissions/[id]/page.tsx +++ b/src/app/admin/permissions/[id]/page.tsx @@ -1,7 +1,8 @@ +import { asc, count, eq } from "drizzle-orm"; import { notFound, redirect } from "next/navigation"; import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb"; +import { AclModelPermission, AclPermission, AclRole, db, User } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks"; import { RankEditClient } from "./rank-edit-client"; @@ -19,23 +20,32 @@ export default async function RankEditPage({ const rankId = parseInt(rawId, 10); if (Number.isNaN(rankId)) notFound(); - const rank = await fetchEmulatorRankForEdit(prisma, rankId); + const rank = await fetchEmulatorRankForEdit(db, rankId); if (!rank) notFound(); // Fetch users with this rank (limit 200) - const users = await prisma.user.findMany({ - where: { rank: rankId }, - select: { id: true, username: true, look: true }, - orderBy: { username: "asc" }, - take: 200, - }); + const users = await db + .select({ id: User.id, username: User.username, look: User.look }) + .from(User) + .where(eq(User.rank, rankId)) + .orderBy(asc(User.username)) + .limit(200); - const totalUsers = await prisma.user.count({ where: { rank: rankId } }); + const [totalUsersRow] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.rank, rankId)); + const totalUsers = totalUsersRow?.total ?? 0; // Fetch all CMS permissions - const allCmsPermissions = await prisma.aclPermission.findMany({ - orderBy: { slug: "asc" }, - }); + const allCmsPermissions = await db + .select({ + id: AclPermission.id, + slug: AclPermission.slug, + title: AclPermission.title, + }) + .from(AclPermission) + .orderBy(asc(AclPermission.slug)); // Auto-create CMS role for rank >= 4 let cmsRole: { @@ -46,22 +56,37 @@ export default async function RankEditPage({ } | null = null; if (rankId >= 4) { - const role = await prisma.aclRole.upsert({ - where: { slug: `rank_${rankId}` }, - create: { - slug: `rank_${rankId}`, - title: rank.rank_name || `Rank ${rankId}`, - }, - update: {}, - include: { - rolePermissions: { include: { permission: true } }, - }, - }); + const slug = `rank_${rankId}`; + const title = rank.rank_name || `Rank ${rankId}`; + const [existing] = await db + .select({ id: AclRole.id }) + .from(AclRole) + .where(eq(AclRole.slug, slug)) + .limit(1); + + let roleId: number; + if (existing) { + roleId = existing.id; + await db.update(AclRole).set({ title }).where(eq(AclRole.id, roleId)); + } else { + const inserted = await db.insert(AclRole).values({ slug, title }); + roleId = inserted[0].insertId; + } + + const permissionRows = await db + .select({ slug: AclPermission.slug }) + .from(AclModelPermission) + .innerJoin( + AclPermission, + eq(AclModelPermission.permissionId, AclPermission.id), + ) + .where(eq(AclModelPermission.modelId, roleId)); + cmsRole = { - id: role.id, - slug: role.slug, - title: role.title, - permissionSlugs: role.rolePermissions.map((rp) => rp.permission.slug), + id: roleId, + slug, + title, + permissionSlugs: permissionRows.map((rp) => rp.slug), }; } diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx index 4a260617..55647d3b 100644 --- a/src/app/admin/permissions/page.tsx +++ b/src/app/admin/permissions/page.tsx @@ -1,7 +1,7 @@ +import { sql } from "drizzle-orm"; import { redirect } from "next/navigation"; -import { Prisma } from "@/generated/prisma/client"; +import { db } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { PermissionsList } from "./permissions-list"; @@ -19,12 +19,12 @@ export default async function PermissionsPage({ const sp = (await searchParams) ?? {}; const fromHousekeeping = sp.from === "housekeeping"; - const ranks = await fetchEmulatorRankSummaries(prisma); + const ranks = await fetchEmulatorRankSummaries(db); // User counts per rank - const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw( - Prisma.sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``, - ); + const [rawCounts] = (await db.execute( + sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``, + )) as unknown as [{ rank: number; cnt: number }[], unknown[]]; const countMap: Record = {}; for (const row of rawCounts) { countMap[Number(row.rank)] = Number(row.cnt); diff --git a/src/app/admin/polls/page.tsx b/src/app/admin/polls/page.tsx index da704bba..5b079130 100644 --- a/src/app/admin/polls/page.tsx +++ b/src/app/admin/polls/page.tsx @@ -1,7 +1,9 @@ +import { count, desc, like } from "drizzle-orm"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { Button } from "@/components/ui/button"; import { fetchAdminList } from "@/lib/admin-list"; +import { db, WebsitePoll } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { PollsTable } from "./polls-table"; @@ -16,28 +18,31 @@ export default async function PollsPage({ const result = await fetchAdminList( { permission: PERMS.POLLS_VIEW, - model: "websitePoll", - searchFields: ["title"], - defaultSort: { createdAt: "desc" }, - mapRow: (p: { - id: number; - title: string; - status: string; - showResults: number; - multipleChoice: number; - startsAt: Date | null; - endsAt: Date | null; - createdAt: Date; - }) => ({ - id: p.id, - title: p.title, - status: p.status, - showResults: p.showResults, - multipleChoice: p.multipleChoice, - startsAt: p.startsAt, - endsAt: p.endsAt, - createdAt: p.createdAt, - }), + fetch: async ({ search, page, perPage }) => { + const where = search + ? like(WebsitePoll.title, `%${search}%`) + : undefined; + const [rows, totals] = await Promise.all([ + db + .select({ + id: WebsitePoll.id, + title: WebsitePoll.title, + status: WebsitePoll.status, + showResults: WebsitePoll.showResults, + multipleChoice: WebsitePoll.multipleChoice, + startsAt: WebsitePoll.startsAt, + endsAt: WebsitePoll.endsAt, + createdAt: WebsitePoll.createdAt, + }) + .from(WebsitePoll) + .where(where) + .orderBy(desc(WebsitePoll.createdAt)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(WebsitePoll).where(where), + ]); + return { rows, total: totals[0]?.total ?? 0 }; + }, }, paramsPromise, searchParams, diff --git a/src/app/admin/rooms/page.tsx b/src/app/admin/rooms/page.tsx index 3d512fff..7b3bd03d 100644 --- a/src/app/admin/rooms/page.tsx +++ b/src/app/admin/rooms/page.tsx @@ -1,7 +1,9 @@ +import { and, count, desc, eq, like, or } from "drizzle-orm"; import { DoorOpen } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { fetchAdminList } from "@/lib/admin-list"; +import { db, Rooms } from "@/lib/db"; import { canAccess, PERMS } from "@/lib/permissions"; import { RoomsTable } from "./rooms-table"; @@ -15,33 +17,56 @@ export default async function RoomsPage({ const result = await fetchAdminList( { permission: PERMS.ROOMS_VIEW, - // Prisma model is `Rooms` → client accessor `rooms`. - // Owner is denormalized on the row (ownerId / ownerName), not a relation. - model: "rooms", - searchFields: ["name", "ownerName"], - select: { - id: true, - name: true, - ownerId: true, - ownerName: true, - state: true, - users: true, - usersMax: true, - description: true, + fetch: async ({ search, page, perPage, rawParams }) => { + const conditions = []; + if (search) { + const ors = [ + like(Rooms.name, `%${search}%`), + like(Rooms.ownerName, `%${search}%`), + ]; + const asId = Number(search); + if (Number.isFinite(asId) && asId > 0) { + ors.push(eq(Rooms.id, asId)); + } + conditions.push(or(...ors)); + } + if (rawParams.filter_state) { + conditions.push(eq(Rooms.state, rawParams.filter_state)); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; + const [rows, totals] = await Promise.all([ + db + .select({ + id: Rooms.id, + name: Rooms.name, + ownerId: Rooms.ownerId, + ownerName: Rooms.ownerName, + state: Rooms.state, + users: Rooms.users, + usersMax: Rooms.usersMax, + description: Rooms.description, + }) + .from(Rooms) + .where(where) + .orderBy(desc(Rooms.id)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(Rooms).where(where), + ]); + return { + rows: rows.map((r) => ({ + id: r.id, + name: r.name, + ownerName: r.ownerName || "—", + ownerId: r.ownerId ?? 0, + state: r.state, + users: r.users, + usersMax: r.usersMax, + description: r.description || "", + })), + total: totals[0]?.total ?? 0, + }; }, - filterMap: { - filter_state: (v) => ({ state: v }), - }, - mapRow: (r) => ({ - id: r.id as number, - name: r.name as string, - ownerName: (r.ownerName as string) || "—", - ownerId: (r.ownerId as number) ?? 0, - state: r.state as string, - users: r.users as number, - usersMax: r.usersMax as number, - description: (r.description as string) || "", - }), }, paramsPromise as Promise<{ locale: string }>, searchParams, diff --git a/src/app/admin/tickets/page.tsx b/src/app/admin/tickets/page.tsx index 2e52f6f8..439fdc16 100644 --- a/src/app/admin/tickets/page.tsx +++ b/src/app/admin/tickets/page.tsx @@ -2,7 +2,6 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; -import type { Prisma } from "@/generated/prisma/client"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { formatDate } from "@/lib/format-date"; @@ -25,7 +24,7 @@ export default async function AdminTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || raw.status || "active"; - const conditions: Prisma.WebsiteTicketWhereInput[] = []; + const conditions: any[] = []; if (statusFilter === "active") { conditions.push({ status: { not: "closed" } }); @@ -47,14 +46,14 @@ export default async function AdminTicketsPage({ }); } - const where: Prisma.WebsiteTicketWhereInput = + const where: any = conditions.length === 0 ? {} : conditions.length === 1 ? conditions[0] : { AND: conditions }; - const SORT_MAP: Record = + const SORT_MAP: Record = { subject: { subject: "asc" }, status: { status: "asc" }, @@ -68,7 +67,7 @@ export default async function AdminTicketsPage({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.WebsiteTicketOrderByWithRelationInput; + } as any; const [total, openCount, inProgressCount, waitingCount, closedCount, queues] = await Promise.all([ diff --git a/src/app/admin/transactions/page.tsx b/src/app/admin/transactions/page.tsx index 0d0fa325..8c31073d 100644 --- a/src/app/admin/transactions/page.tsx +++ b/src/app/admin/transactions/page.tsx @@ -1,7 +1,6 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; -import type { Prisma } from "@/generated/prisma/client"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -36,7 +35,7 @@ function formatAmount(amount: number, currency: string): string { const SORT_MAP: Record< string, - Prisma.WebsitePaypalTransactionsOrderByWithRelationInput + any > = { id: { id: "desc" }, amount: { amount: "desc" }, @@ -61,7 +60,7 @@ export default async function AdminTransactions({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status ?? ""; - const where: Prisma.WebsitePaypalTransactionsWhereInput = {}; + const where: any = {}; if (statusFilter === "paid") { where.status = { in: [...PAID_STATUS] }; @@ -113,7 +112,7 @@ export default async function AdminTransactions({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput; + } as any; let transactions: Awaited< ReturnType diff --git a/src/app/admin/translations/emulator/page.tsx b/src/app/admin/translations/emulator/page.tsx index fdb7f5f2..7667b3ec 100644 --- a/src/app/admin/translations/emulator/page.tsx +++ b/src/app/admin/translations/emulator/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form"; import { Button } from "@/components/ui/button"; -import type { Prisma } from "@/generated/prisma/client"; import { calcPagination } from "@/lib/admin-helpers"; import { prisma } from "@/lib/prisma"; @@ -30,21 +29,21 @@ export default async function EmulatorTranslationsPage({ const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200); const page = Math.max(parseInt(sp.page || "1", 10), 1); - const patternFilters: Prisma.EmulatorSettingsWhereInput[] = + const patternFilters: any[] = TRANSLATION_KEY_PATTERNS.map((p) => ({ key: { contains: p.replace(/%/g, "") }, })); - const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters }; + const baseFilter: any = { OR: patternFilters }; - const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter]; + const conditions: any[] = [baseFilter]; if (group) conditions.push({ key: { startsWith: `${group}.` } }); if (search) { conditions.push({ OR: [{ key: { contains: search } }, { value: { contains: search } }], }); } - const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions }; + const where: any = { AND: conditions }; const [settings, total, allKeys] = await Promise.all([ prisma.emulatorSettings.findMany({ diff --git a/src/app/admin/users/edit/[id]/page.tsx b/src/app/admin/users/edit/[id]/page.tsx index 2ac7f509..044eebef 100644 --- a/src/app/admin/users/edit/[id]/page.tsx +++ b/src/app/admin/users/edit/[id]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Prisma } from "@/generated/prisma/client"; +import { Prisma } from "@/lib/prisma"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { getWatchedUserIds } from "@/lib/services/watch"; diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 5387734a..38e40b7e 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,7 +1,9 @@ +import { and, count, desc, eq, gte, like, or } from "drizzle-orm"; import { Users } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { fetchAdminList } from "@/lib/admin-list"; +import { db, User } from "@/lib/db"; import { canAccess, PERMS } from "@/lib/permissions"; import { CreateUserDialog } from "./_components/create-user-dialog"; import { UsersTable } from "./users-table"; @@ -16,30 +18,54 @@ export default async function UsersPage({ const result = await fetchAdminList( { permission: PERMS.USERS_VIEW, - model: "user", - searchFields: ["username", "mail", "ipRegister", "ipCurrent"], - select: { - id: true, - username: true, - mail: true, - rank: true, - credits: true, - pixels: true, - online: true, - motto: true, - look: true, - ipRegister: true, - accountCreated: true, - lastLogin: true, - }, - filterMap: { - filter_rank: (v) => { - const n = Number(v); - if (!Number.isFinite(n)) return null; + fetch: async ({ search, page, perPage, rawParams }) => { + const conditions = []; + if (search) { + const ors = [ + like(User.username, `%${search}%`), + like(User.mail, `%${search}%`), + like(User.ipRegister, `%${search}%`), + like(User.ipCurrent, `%${search}%`), + ]; + const asId = Number(search); + if (Number.isFinite(asId) && asId > 0) { + ors.push(eq(User.id, asId)); + } + conditions.push(or(...ors)); + } + const rank = Number(rawParams.filter_rank); + if (Number.isFinite(rank)) { // For rank 7 the preset means "super admins" — match ≥ 7 instead of equals. - return n >= 7 ? { rank: { gte: 7 } } : { rank: n }; - }, - filter_online: (v) => ({ online: v }), + conditions.push(rank >= 7 ? gte(User.rank, 7) : eq(User.rank, rank)); + } + if (rawParams.filter_online) { + conditions.push(eq(User.online, rawParams.filter_online)); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; + const [rows, totals] = await Promise.all([ + db + .select({ + id: User.id, + username: User.username, + mail: User.mail, + rank: User.rank, + credits: User.credits, + pixels: User.pixels, + online: User.online, + motto: User.motto, + look: User.look, + ipRegister: User.ipRegister, + accountCreated: User.accountCreated, + lastLogin: User.lastLogin, + }) + .from(User) + .where(where) + .orderBy(desc(User.id)) + .limit(perPage) + .offset((page - 1) * perPage), + db.select({ total: count() }).from(User).where(where), + ]); + return { rows, total: totals[0]?.total ?? 0 }; }, }, paramsPromise, diff --git a/src/app/admin/users/users-table.tsx b/src/app/admin/users/users-table.tsx index b161d894..5f7ae43e 100644 --- a/src/app/admin/users/users-table.tsx +++ b/src/app/admin/users/users-table.tsx @@ -37,7 +37,7 @@ import type { DataTableColumn, PaginatedResult } from "@/types/common"; interface UserRow { id: number; username: string; - mail: string; + mail: string | null; rank: number; credits: number; pixels: number; diff --git a/src/app/api/admin/prefixes/route.ts b/src/app/api/admin/prefixes/route.ts index 346c1127..7e38125b 100644 --- a/src/app/api/admin/prefixes/route.ts +++ b/src/app/api/admin/prefixes/route.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@/generated/prisma/client"; +import { Prisma } from "@/lib/prisma"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts index 7d09f24f..d9f1276f 100644 --- a/src/app/api/badges/leaderboard/route.ts +++ b/src/app/api/badges/leaderboard/route.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@/generated/prisma/client"; +import { Prisma } from "@/lib/prisma"; import { apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; import { auth } from "@/lib/auth"; diff --git a/src/app/mod/cfh/page.tsx b/src/app/mod/cfh/page.tsx index d5fdb4c8..f6f276ef 100644 --- a/src/app/mod/cfh/page.tsx +++ b/src/app/mod/cfh/page.tsx @@ -1,6 +1,5 @@ import { getTranslations } from "next-intl/server"; import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table"; -import type { Prisma } from "@/generated/prisma/client"; import { requireModPermission } from "@/lib/admin/guard"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { PERMS } from "@/lib/permissions"; @@ -20,7 +19,7 @@ export default async function ModCfhListPage({ const stateFilter = stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1; - const conditions: Prisma.SupportTicketsWhereInput[] = []; + const conditions: any[] = []; if (stateFilter >= 0) { conditions.push({ state: stateFilter }); } @@ -55,7 +54,7 @@ export default async function ModCfhListPage({ }); } - const where: Prisma.SupportTicketsWhereInput = + const where: any = conditions.length === 0 ? {} : conditions.length === 1 @@ -64,7 +63,7 @@ export default async function ModCfhListPage({ const SORT_MAP: Record< string, - Prisma.SupportTicketsOrderByWithRelationInput + any > = { id: { id: "desc" }, issue: { issue: "asc" }, @@ -78,7 +77,7 @@ export default async function ModCfhListPage({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.SupportTicketsOrderByWithRelationInput; + } as any; const total = await prisma.supportTickets.count({ where }).catch(() => 0); const pagination = calcPagination(total, parsed.page, parsed.perPage); diff --git a/src/app/mod/help-tickets/page.tsx b/src/app/mod/help-tickets/page.tsx index 6f1dd12c..606a3abb 100644 --- a/src/app/mod/help-tickets/page.tsx +++ b/src/app/mod/help-tickets/page.tsx @@ -5,7 +5,6 @@ import { } from "@/app/admin/help-tickets/help-tickets-table"; import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; -import type { Prisma } from "@/generated/prisma/client"; import { requireModPermission } from "@/lib/admin/guard"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; @@ -25,7 +24,7 @@ export default async function ModHelpTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || "open"; - const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = []; + const conditions: any[] = []; if (statusFilter === "open") { conditions.push({ open: true }); @@ -56,7 +55,7 @@ export default async function ModHelpTicketsPage({ }); } - const where: Prisma.WebsiteHelpCenterTicketsWhereInput = + const where: any = conditions.length === 0 ? {} : conditions.length === 1 @@ -65,7 +64,7 @@ export default async function ModHelpTicketsPage({ const SORT_MAP: Record< string, - Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput + any > = { title: { title: "asc" }, open: { open: "desc" }, @@ -79,7 +78,7 @@ export default async function ModHelpTicketsPage({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput; + } as any; const [total, openCount, closedCount, queues] = await Promise.all([ prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0), diff --git a/src/app/mod/tickets/page.tsx b/src/app/mod/tickets/page.tsx index f8f844f4..da693150 100644 --- a/src/app/mod/tickets/page.tsx +++ b/src/app/mod/tickets/page.tsx @@ -5,7 +5,6 @@ import { } from "@/app/admin/tickets/tickets-table"; import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; -import type { Prisma } from "@/generated/prisma/client"; import { requireModPermission } from "@/lib/admin/guard"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; @@ -25,7 +24,7 @@ export default async function ModTicketsPage({ const parsed = parseListParams(new URLSearchParams(raw)); const statusFilter = raw.filter_status || raw.status || "active"; - const conditions: Prisma.WebsiteTicketWhereInput[] = []; + const conditions: any[] = []; if (statusFilter === "active") { conditions.push({ status: { not: "closed" } }); @@ -47,14 +46,14 @@ export default async function ModTicketsPage({ }); } - const where: Prisma.WebsiteTicketWhereInput = + const where: any = conditions.length === 0 ? {} : conditions.length === 1 ? conditions[0] : { AND: conditions }; - const SORT_MAP: Record = + const SORT_MAP: Record = { subject: { subject: "asc" }, status: { status: "asc" }, @@ -68,7 +67,7 @@ export default async function ModTicketsPage({ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; const orderBy = { [orderField]: parsed.order, - } as Prisma.WebsiteTicketOrderByWithRelationInput; + } as any; const [total, openCount, inProgressCount, waitingCount, closedCount, queues] = await Promise.all([ diff --git a/src/db/relations.ts b/src/db/relations.ts new file mode 100644 index 00000000..4cb653a0 --- /dev/null +++ b/src/db/relations.ts @@ -0,0 +1,140 @@ +import { relations } from "drizzle-orm"; +import { + AclModelPermission, + AclModelRole, + AclPermission, + AclRole, + AdminAuditLog, + User, + WebsiteEvent, + WebsiteEventPrize, + WebsiteEventRegistration, + WebsiteEventType, + WebsiteEventWinner, + WebsitePoll, + WebsitePollQuestion, + WebsitePollVote, + WebsiteTicket, + WebsiteTicketMessage, +} from "@/db/schema"; + +export const userRelations = relations(User, ({ many }) => ({ + createdTickets: many(WebsiteTicket, { relationName: "ticketCreator" }), + assignedTickets: many(WebsiteTicket, { relationName: "ticketAssignee" }), + ticketMessages: many(WebsiteTicketMessage), + auditLogs: many(AdminAuditLog), +})); + +export const websiteTicketRelations = relations(WebsiteTicket, ({ one, many }) => ({ + creator: one(User, { + fields: [WebsiteTicket.creatorId], + references: [User.id], + relationName: "ticketCreator", + }), + assignee: one(User, { + fields: [WebsiteTicket.assigneeId], + references: [User.id], + relationName: "ticketAssignee", + }), + messages: many(WebsiteTicketMessage), +})); + +export const websiteTicketMessageRelations = relations( + WebsiteTicketMessage, + ({ one }) => ({ + ticket: one(WebsiteTicket, { + fields: [WebsiteTicketMessage.ticketId], + references: [WebsiteTicket.id], + }), + user: one(User, { + fields: [WebsiteTicketMessage.userId], + references: [User.id], + }), + }), +); + +export const adminAuditLogRelations = relations(AdminAuditLog, ({ one }) => ({ + user: one(User, { + fields: [AdminAuditLog.userId], + references: [User.id], + }), +})); + +export const aclRoleRelations = relations(AclRole, ({ many }) => ({ + rolePermissions: many(AclModelPermission), + modelRoles: many(AclModelRole), +})); + +export const aclModelPermissionRelations = relations( + AclModelPermission, + ({ one }) => ({ + role: one(AclRole, { + fields: [AclModelPermission.modelId], + references: [AclRole.id], + }), + permission: one(AclPermission, { + fields: [AclModelPermission.permissionId], + references: [AclPermission.id], + }), + }), +); + +export const websiteEventTypeRelations = relations(WebsiteEventType, ({ many }) => ({ + events: many(WebsiteEvent), +})); + +export const websiteEventRelations = relations(WebsiteEvent, ({ one, many }) => ({ + type: one(WebsiteEventType, { + fields: [WebsiteEvent.typeId], + references: [WebsiteEventType.id], + }), + registrations: many(WebsiteEventRegistration), + prizes: many(WebsiteEventPrize), + winners: many(WebsiteEventWinner), +})); + +export const websiteEventRegistrationRelations = relations( + WebsiteEventRegistration, + ({ one }) => ({ + event: one(WebsiteEvent, { + fields: [WebsiteEventRegistration.eventId], + references: [WebsiteEvent.id], + }), + }), +); + +export const websiteEventPrizeRelations = relations(WebsiteEventPrize, ({ one }) => ({ + event: one(WebsiteEvent, { + fields: [WebsiteEventPrize.eventId], + references: [WebsiteEvent.id], + }), +})); + +export const websiteEventWinnerRelations = relations(WebsiteEventWinner, ({ one }) => ({ + event: one(WebsiteEvent, { + fields: [WebsiteEventWinner.eventId], + references: [WebsiteEvent.id], + }), +})); + +export const websitePollRelations = relations(WebsitePoll, ({ many }) => ({ + questions: many(WebsitePollQuestion), +})); + +export const websitePollQuestionRelations = relations( + WebsitePollQuestion, + ({ one, many }) => ({ + poll: one(WebsitePoll, { + fields: [WebsitePollQuestion.pollId], + references: [WebsitePoll.id], + }), + votes: many(WebsitePollVote), + }), +); + +export const websitePollVoteRelations = relations(WebsitePollVote, ({ one }) => ({ + question: one(WebsitePollQuestion, { + fields: [WebsitePollVote.questionId], + references: [WebsitePollQuestion.id], + }), +})); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 00000000..be41885a --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,2569 @@ +// 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 { sql } from "drizzle-orm"; +import { + bigint, + binary, + boolean, + customType, + datetime, + decimal, + double, + int, + longtext, + mediumtext, + mysqlEnum, + mysqlTable, + primaryKey, + text, + timestamp, + tinyint, + uniqueIndex, + varchar, +} from "drizzle-orm/mysql-core"; + +// MySQL TIME / DATE columns are hydrated by Prisma as JS Date (epoch 1970-01-01 +// for TIME). Mirror that so existing call sites keep working unchanged. +const timeAsDate = customType<{ data: Date; driverData: string }>({ + dataType() { + return "time"; + }, + toDriver(value) { + return value.toISOString().slice(11, 19); + }, + fromDriver(value) { + return new Date(`1970-01-01T${value}Z`); + }, +}); + +const dateAsDate = customType<{ data: Date; driverData: string }>({ + dataType() { + return "date"; + }, + toDriver(value) { + return value.toISOString().slice(0, 10); + }, + fromDriver(value) { + return new Date(`${value}T00:00:00Z`); + }, +}); + +export const User = mysqlTable("users", { + id: int("id").autoincrement().primaryKey().notNull(), + username: varchar("username", { length: 25 }).unique().notNull(), + realName: varchar("real_name", { length: 25 }).notNull().default("KREWS DEV"), + password: varchar("password", { length: 255 }).notNull(), + mail: varchar("mail", { length: 500 }), + mailVerified: varchar("mail_verified", { length: 1 }).notNull().default("0"), + accountCreated: int("account_created").notNull(), + accountDayOfBirth: int("account_day_of_birth").notNull().default(0), + lastLogin: int("last_login").notNull().default(0), + lastOnline: int("last_online").notNull().default(0), + motto: varchar("motto", { length: 127 }).notNull().default(""), + look: varchar("look", { length: 256 }).notNull().default(""), + gender: mysqlEnum("gender", ["M", "F"]).notNull().default("M"), + rank: int("rank").notNull().default(1), + credits: int("credits").notNull().default(2500), + pixels: int("pixels").notNull().default(500), + points: int("points").notNull().default(10), + online: varchar("online", { length: 1 }).notNull().default("0"), + authTicket: varchar("auth_ticket", { length: 256 }).notNull().default(""), + ipRegister: varchar("ip_register", { length: 255 }).notNull(), + ipCurrent: varchar("ip_current", { length: 255 }).notNull(), + machineId: varchar("machine_id", { length: 64 }).notNull().default(""), + homeRoom: int("home_room").notNull().default(0), + secretKey: varchar("secret_key", { length: 40 }), + pincode: varchar("pincode", { length: 11 }), + extraRank: int("extra_rank"), + twoFactorSecret: text("two_factor_secret"), + twoFactorRecoveryCodes: text("two_factor_recovery_codes"), + twoFactorConfirmedAt: timestamp("two_factor_confirmed_at"), + websiteJwtVersion: int("website_jwt_version").notNull().default(0), +}); + +export const UsersCurrency = mysqlTable( + "users_currency", + { + userId: int("user_id").notNull(), + type: int("type").notNull(), + amount: int("amount").notNull().default(0), + }, + (t) => [primaryKey({ columns: [t.userId, t.type] })], +); + +export const Ban = mysqlTable("bans", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull(), + ip: varchar("ip", { length: 50 }).notNull().default(""), + machineId: varchar("machine_id", { length: 255 }).notNull().default(""), + userStaffId: int("user_staff_id").notNull(), + timestamp: int("timestamp").notNull(), + banExpire: int("ban_expire").notNull().default(0), + banReason: varchar("ban_reason", { length: 200 }).notNull().default(""), + type: mysqlEnum("type", ["account", "ip", "machine", "super"]) + .notNull() + .default("account"), + cfhTopic: int("cfh_topic").notNull().default(-1), +}); + +export const WebsiteSetting = mysqlTable("website_settings", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + key: varchar("key", { length: 255 }).unique().notNull(), + value: text("value").notNull(), + comment: varchar("comment", { length: 255 }), +}); + +export const PasswordReset = mysqlTable("password_resets", { + email: varchar("email", { length: 255 }).primaryKey().notNull(), + token: varchar("token", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), +}); + +export const Achievements = mysqlTable( + "achievements", + { + id: int("id").autoincrement().unique().notNull(), + name: varchar("name", { length: 64 }).notNull().default("ACH_"), + category: varchar("category", { length: 12 }).notNull().default("identity"), + level: int("level").notNull().default(1), + rewardAmount: int("reward_amount").notNull().default(100), + rewardType: int("reward_type").notNull().default(0), + points: int("points").default(10), + progressNeeded: int("progress_needed").notNull().default(1), + }, + (t) => [primaryKey({ columns: [t.name, t.level] })], +); + +export const CalendarCampaigns = mysqlTable("calendar_campaigns", { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 255 }).notNull().default(""), + image: varchar("image", { length: 255 }).notNull().default(""), + startTimestamp: int("start_timestamp").notNull().default(0), + totalDays: int("total_days").notNull().default(30), + lockExpired: varchar("lock_expired", { length: 1 }).notNull().default("1"), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), +}); + +export const CalendarRewards = mysqlTable("calendar_rewards", { + id: int("id").autoincrement().primaryKey().notNull(), + campaignId: int("campaign_id").notNull().default(0), + productName: varchar("product_name", { length: 128 }).notNull().default(""), + customImage: varchar("custom_image", { length: 128 }).notNull().default(""), + credits: int("credits").notNull().default(0), + pixels: int("pixels").notNull().default(0), + points: int("points").notNull().default(0), + pointsType: int("points_type").notNull().default(0), + badge: varchar("badge", { length: 25 }).notNull().default(""), + itemId: int("item_id").notNull().default(0), + subscriptionType: varchar("subscription_type", { length: 128 }).default(""), + subscriptionDays: int("subscription_days").notNull().default(0), +}); + +export const CameraWeb = mysqlTable("camera_web", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull(), + roomId: int("room_id").notNull().default(0), + timestamp: int("timestamp").notNull(), + url: varchar("url", { length: 128 }).notNull().default(""), +}); + +export const CatalogItems = mysqlTable("catalog_items", { + id: int("id").autoincrement().primaryKey().notNull(), + itemIds: varchar("item_ids", { length: 666 }).notNull(), + pageId: int("page_id").notNull(), + catalogName: varchar("catalog_name", { length: 100 }).notNull(), + costCredits: int("cost_credits").notNull().default(3), + costPoints: int("cost_points").notNull().default(0), + pointsType: int("points_type").notNull().default(0), + amount: int("amount").notNull().default(1), + limitedStack: int("limited_stack").notNull().default(0), + limitedSells: int("limited_sells").notNull().default(0), + orderNumber: int("order_number").notNull().default(1), + offerId: int("offer_id").notNull().default(-1), + songId: int("song_id", { unsigned: true }).notNull().default(0), + extradata: varchar("extradata", { length: 500 }).notNull().default(""), + haveOffer: varchar("haveOffer", { length: 255 }).notNull().default("1"), + clubOnly: varchar("club_only", { length: 1 }).notNull().default("0"), +}); + +export const CatalogItemsBc = mysqlTable("catalog_items_bc", { + id: int("id").autoincrement().primaryKey().notNull(), + itemIds: varchar("item_ids", { length: 666 }).notNull(), + pageId: int("page_id").notNull(), + catalogName: varchar("catalog_name", { length: 100 }).notNull(), + orderNumber: int("order_number").notNull().default(1), + extradata: varchar("extradata", { length: 500 }).notNull().default(""), +}); + +export const CatalogPages = mysqlTable("catalog_pages", { + id: int("id").autoincrement().primaryKey().notNull(), + parentId: int("parent_id").notNull().default(-1), + captionSave: varchar("caption_save", { length: 25 }).notNull().default(""), + caption: varchar("caption", { length: 128 }).notNull(), + pageLayout: varchar("page_layout", { length: 26 }) + .notNull() + .default("default_3x3"), + iconColor: int("icon_color").notNull().default(1), + iconImage: int("icon_image").notNull().default(1), + minRank: int("min_rank").notNull().default(1), + orderNum: int("order_num").notNull().default(1), + visible: varchar("visible", { length: 1 }).notNull().default("1"), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), + clubOnly: varchar("club_only", { length: 1 }).notNull().default("0"), + vipOnly: varchar("vip_only", { length: 1 }).notNull().default("0"), + pageHeadline: varchar("page_headline", { length: 1024 }) + .notNull() + .default(""), + pageTeaser: varchar("page_teaser", { length: 64 }).notNull().default(""), + pageSpecial: varchar("page_special", { length: 2048 }), + pageText1: text("page_text1"), + pageText2: text("page_text2"), + pageTextDetails: text("page_text_details"), + pageTextTeaser: text("page_text_teaser"), + roomId: int("room_id").default(0), + includes: varchar("includes", { length: 128 }).notNull().default(""), +}); + +export const CatalogPagesBc = mysqlTable("catalog_pages_bc", { + id: int("id").autoincrement().primaryKey().notNull(), + parentId: int("parent_id").notNull().default(-1), + caption: varchar("caption", { length: 128 }).notNull(), + pageLayout: varchar("page_layout", { length: 26 }) + .notNull() + .default("default_3x3"), + iconColor: int("icon_color").notNull().default(1), + iconImage: int("icon_image").notNull().default(1), + orderNum: int("order_num").notNull().default(1), + visible: varchar("visible", { length: 1 }).notNull().default("1"), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), + pageHeadline: varchar("page_headline", { length: 1024 }) + .notNull() + .default(""), + pageTeaser: varchar("page_teaser", { length: 64 }).notNull().default(""), + pageSpecial: varchar("page_special", { length: 2048 }), + pageText1: text("page_text1"), + pageText2: text("page_text2"), + pageTextDetails: text("page_text_details"), + pageTextTeaser: text("page_text_teaser"), +}); + +export const EmulatorErrors = mysqlTable("emulator_errors", { + id: int("id").autoincrement().primaryKey().notNull(), + timestamp: int("timestamp").notNull().default(0), + version: varchar("version", { length: 64 }).notNull(), + buildHash: varchar("build_hash", { length: 64 }).notNull(), + type: varchar("type", { length: 32 }).notNull().default("Exception"), + stacktrace: binary("stacktrace").notNull(), +}); + +export const EmulatorSettings = mysqlTable("emulator_settings", { + key: varchar("key", { length: 100 }).primaryKey().notNull(), + value: varchar("value", { length: 512 }).notNull(), +}); + +export const EmulatorTexts = mysqlTable("emulator_texts", { + key: varchar("key", { length: 100 }).primaryKey().notNull(), + value: varchar("value", { length: 4096 }).notNull(), +}); + +export const Guilds = mysqlTable("guilds", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull().default(0), + name: varchar("name", { length: 50 }).notNull().default(""), + description: varchar("description", { length: 250 }).notNull().default(""), + roomId: int("room_id").notNull().default(0), + state: int("state").notNull().default(0), + rights: varchar("rights", { length: 1 }).notNull().default("0"), + colorOne: int("color_one").notNull().default(0), + colorTwo: int("color_two").notNull().default(0), + badge: varchar("badge", { length: 256 }).notNull().default(""), + dateCreated: int("date_created").notNull(), + forum: varchar("forum", { length: 1 }).notNull().default("0"), + readForum: varchar("read_forum", { length: 8 }).notNull().default("EVERYONE"), + postMessages: varchar("post_messages", { length: 8 }) + .notNull() + .default("EVERYONE"), + postThreads: varchar("post_threads", { length: 8 }) + .notNull() + .default("EVERYONE"), + modForum: varchar("mod_forum", { length: 6 }).notNull().default("ADMINS"), +}); + +export const GuildsForumsComments = mysqlTable("guilds_forums_comments", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + threadId: int("thread_id", { unsigned: true }).notNull().default(0), + userId: int("user_id").notNull().default(0), + message: text("message").notNull(), + createdAt: int("created_at").notNull().default(0), + state: int("state").notNull().default(0), + adminId: int("admin_id").notNull().default(0), +}); + +export const GuildsForumsThreads = mysqlTable("guilds_forums_threads", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + guildId: int("guild_id").default(0), + openerId: int("opener_id").default(0), + subject: varchar("subject", { length: 255 }).default(""), + postsCount: int("posts_count").default(0), + createdAt: int("created_at").default(0), + updatedAt: int("updated_at").default(0), + state: int("state").default(0), + pinned: tinyint("pinned").default(0), + locked: tinyint("locked").default(0), + adminId: int("admin_id").default(0), +}); + +export const GuildsMembers = mysqlTable("guilds_members", { + id: int("id").autoincrement().primaryKey().notNull(), + guildId: int("guild_id").notNull().default(0), + userId: int("user_id").notNull().default(0), + levelId: int("level_id").notNull().default(0), + memberSince: int("member_since").notNull().default(0), +}); + +export const Items = mysqlTable("items", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull().default(0), + roomId: int("room_id").notNull().default(0), + itemId: int("item_id", { unsigned: true }).default(0), + wallPos: varchar("wall_pos", { length: 20 }).notNull().default(""), + x: int("x").notNull().default(0), + y: int("y").notNull().default(0), + z: double("z").notNull().default(0.0), + rot: int("rot").notNull().default(0), + extraData: varchar("extra_data", { length: 1024 }).notNull().default(""), + wiredData: varchar("wired_data", { length: 10000 }).notNull().default(""), + limitedData: varchar("limited_data", { length: 10 }).notNull().default("0:0"), + guildId: int("guild_id").notNull().default(0), +}); + +export const ItemsBase = mysqlTable("items_base", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + spriteId: int("sprite_id").notNull().default(0), + publicName: varchar("public_name", { length: 56 }).notNull().default(""), + itemName: varchar("item_name", { length: 70 }).notNull(), + type: varchar("type", { length: 3 }).notNull().default("s"), + width: int("width").notNull().default(1), + length: int("length").notNull().default(1), + stackHeight: double("stack_height").notNull().default(0.0), + allowStack: tinyint("allow_stack").notNull().default(1), + allowSit: tinyint("allow_sit").notNull().default(0), + allowLay: tinyint("allow_lay").notNull().default(0), + allowWalk: tinyint("allow_walk").notNull().default(0), + allowGift: tinyint("allow_gift").notNull().default(1), + allowTrade: tinyint("allow_trade").notNull().default(1), + allowRecycle: tinyint("allow_recycle").notNull().default(0), + allowMarketplaceSell: tinyint("allow_marketplace_sell").notNull().default(0), + allowInventoryStack: tinyint("allow_inventory_stack").notNull().default(1), + interactionType: varchar("interaction_type", { length: 500 }) + .notNull() + .default("default"), + interactionModesCount: int("interaction_modes_count").notNull().default(1), + vendingIds: varchar("vending_ids", { length: 255 }).notNull().default("0"), + multiheight: varchar("multiheight", { length: 50 }).notNull().default("0"), + customparams: varchar("customparams", { length: 25600 }) + .notNull() + .default(""), + effectIdMale: int("effect_id_male").notNull().default(0), + effectIdFemale: int("effect_id_female").notNull().default(0), + clothingOnWalk: varchar("clothing_on_walk", { length: 255 }) + .notNull() + .default(""), +}); + +export const LogsShopPurchases = mysqlTable("logs_shop_purchases", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + timestamp: int("timestamp", { unsigned: true }), + userId: int("user_id"), + catalogItemId: int("catalog_item_id", { unsigned: true }), + itemIds: text("item_ids"), + catalogName: varchar("catalog_name", { length: 255 }), + costCredits: int("cost_credits"), + costPoints: int("cost_points"), + pointsType: int("points_type"), + amount: int("amount"), +}); + +export const MarketplaceItems = mysqlTable("marketplace_items", { + id: int("id").autoincrement().primaryKey().notNull(), + itemId: int("item_id").notNull(), + userId: int("user_id").notNull(), + price: int("price").notNull(), + timestamp: int("timestamp").notNull(), + soldTimestamp: int("sold_timestamp").notNull().default(0), + state: int("state").notNull().default(1), +}); + +export const MessengerFriendrequests = mysqlTable("messenger_friendrequests", { + id: int("id").autoincrement().primaryKey().notNull(), + userToId: int("user_to_id").notNull().default(0), + userFromId: int("user_from_id").notNull().default(0), +}); + +export const MessengerFriendships = mysqlTable("messenger_friendships", { + id: int("id").autoincrement().primaryKey().notNull(), + userOneId: int("user_one_id").notNull().default(0), + userTwoId: int("user_two_id").notNull().default(0), + relation: int("relation").notNull().default(0), + friendsSince: int("friends_since").notNull().default(0), + category: int("category").notNull().default(0), +}); + +export const MessengerOffline = mysqlTable("messenger_offline", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull().default(0), + userFromId: int("user_from_id").notNull().default(0), + message: varchar("message", { length: 500 }).notNull(), + sendedOn: int("sended_on").notNull(), +}); + +export const NavigatorFlatcats = mysqlTable("navigator_flatcats", { + id: int("id").autoincrement().primaryKey().notNull(), + minRank: int("min_rank").notNull().default(0), + captionSave: varchar("caption_save", { length: 32 }) + .notNull() + .default("caption_save"), + caption: varchar("caption", { length: 100 }).notNull(), + canTrade: varchar("canTrade", { length: 255 }).notNull().default("1"), + maxUserCount: int("max_user_count").notNull().default(100), + public: varchar("public", { length: 1 }).notNull().default("0"), + listType: int("list_type").notNull().default(0), + orderNum: int("order_num").notNull().default(0), +}); + +export const NavigatorPubliccats = mysqlTable("navigator_publiccats", { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 32 }).notNull().default("Staff Picks"), + image: varchar("image", { length: 1 }).notNull().default("0"), + visible: varchar("visible", { length: 1 }).notNull().default("1"), + orderNum: int("order_num").notNull().default(0), +}); + +export const RoomTradeLog = mysqlTable("room_trade_log", { + id: int("id").autoincrement().primaryKey().notNull(), + userOneId: int("user_one_id").notNull(), + userTwoId: int("user_two_id").notNull(), + userOneIp: varchar("user_one_ip", { length: 45 }).notNull(), + userTwoIp: varchar("user_two_ip", { length: 45 }).notNull(), + timestamp: int("timestamp").notNull(), + userOneItemCount: int("user_one_item_count").notNull(), + userTwoItemCount: int("user_two_item_count").notNull(), +}); + +export const Rooms = mysqlTable("rooms", { + id: int("id").autoincrement().primaryKey().notNull(), + ownerId: int("owner_id").notNull().default(0), + ownerName: varchar("owner_name", { length: 25 }).notNull().default(""), + name: varchar("name", { length: 50 }).notNull().default(""), + description: varchar("description", { length: 512 }).notNull().default(""), + model: varchar("model", { length: 20 }).notNull().default("model_a"), + password: varchar("password", { length: 20 }).notNull().default(""), + state: varchar("state", { length: 9 }).notNull().default("open"), + users: int("users").notNull().default(0), + usersMax: int("users_max").notNull().default(25), + guildId: int("guild_id").notNull().default(0), + category: int("category").notNull().default(1), + score: int("score").notNull().default(0), + paperFloor: varchar("paper_floor", { length: 50 }).notNull().default("0.0"), + paperWall: varchar("paper_wall", { length: 50 }).notNull().default("0.0"), + paperLandscape: varchar("paper_landscape", { length: 50 }) + .notNull() + .default("0.0"), + thicknessWall: int("thickness_wall").notNull().default(0), + wallHeight: int("wall_height").notNull().default(-1), + thicknessFloor: int("thickness_floor").notNull().default(0), + moodlightData: varchar("moodlight_data", { length: 254 }) + .notNull() + .default("2,1,1,#000000,255;2,3,1,#000000,255;2,3,1,#000000,255;"), + tags: varchar("tags", { length: 500 }).notNull().default(""), + isPublic: varchar("is_public", { length: 1 }).notNull().default("0"), + isStaffPicked: varchar("is_staff_picked", { length: 1 }) + .notNull() + .default("0"), + allowOtherPets: varchar("allow_other_pets", { length: 1 }) + .notNull() + .default("0"), + allowOtherPetsEat: varchar("allow_other_pets_eat", { length: 1 }) + .notNull() + .default("0"), + allowWalkthrough: varchar("allow_walkthrough", { length: 1 }) + .notNull() + .default("1"), + allowHidewall: varchar("allow_hidewall", { length: 1 }) + .notNull() + .default("0"), + chatMode: int("chat_mode").notNull().default(0), + chatWeight: int("chat_weight").notNull().default(1), + chatSpeed: int("chat_speed").notNull().default(1), + chatHearingDistance: int("chat_hearing_distance").notNull().default(50), + chatProtection: int("chat_protection").notNull().default(2), + overrideModel: varchar("override_model", { length: 1 }) + .notNull() + .default("0"), + whoCanMute: int("who_can_mute").notNull().default(0), + whoCanKick: int("who_can_kick").notNull().default(0), + whoCanBan: int("who_can_ban").notNull().default(0), + pollId: int("poll_id").notNull().default(0), + rollerSpeed: int("roller_speed").notNull().default(4), + promoted: varchar("promoted", { length: 1 }).notNull().default("0"), + tradeMode: int("trade_mode").notNull().default(2), + moveDiagonally: varchar("move_diagonally", { length: 1 }) + .notNull() + .default("1"), + jukeboxActive: varchar("jukebox_active", { length: 1 }) + .notNull() + .default("0"), + hidewired: varchar("hidewired", { length: 1 }).notNull().default("0"), + isForSale: varchar("is_forsale", { length: 1 }).notNull().default("0"), +}); + +export const Soundtracks = mysqlTable("soundtracks", { + id: int("id").autoincrement().primaryKey().notNull(), + code: varchar("code", { length: 32 }).notNull(), + name: varchar("name", { length: 100 }).notNull(), + author: varchar("author", { length: 50 }).notNull(), + track: text("track").notNull(), + length: int("length").notNull().default(0), +}); + +export const SupportTickets = mysqlTable("support_tickets", { + id: int("id").autoincrement().primaryKey().notNull(), + state: int("state").notNull().default(0), + type: int("type").notNull().default(1), + timestamp: int("timestamp").notNull().default(0), + score: int("score").notNull().default(0), + senderId: int("sender_id").notNull().default(0), + reportedId: int("reported_id").notNull().default(0), + roomId: int("room_id").notNull().default(0), + modId: int("mod_id").notNull().default(0), + issue: varchar("issue", { length: 500 }).notNull().default(""), + category: int("category").notNull().default(0), + groupId: int("group_id").notNull(), + threadId: int("thread_id").notNull(), + commentId: int("comment_id").notNull(), + photoItemId: int("photo_item_id").notNull().default(-1), +}); + +export const UsersBadges = mysqlTable("users_badges", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull().default(0), + slotId: int("slot_id").notNull().default(0), + badgeCode: varchar("badge_code", { length: 32 }).notNull(), +}); + +export const UsersSettings = mysqlTable("users_settings", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").unique().notNull().default(0), + credits: int("credits").notNull().default(0), + achievementScore: int("achievement_score").notNull().default(0), + dailyRespectPoints: int("daily_respect_points").notNull().default(3), + dailyPetRespectPoints: int("daily_pet_respect_points").notNull().default(3), + respectsGiven: int("respects_given").notNull().default(0), + respectsReceived: int("respects_received").notNull().default(0), + guildId: int("guild_id").notNull().default(0), + canChangeName: varchar("can_change_name", { length: 1 }) + .notNull() + .default("0"), + canTrade: varchar("can_trade", { length: 1 }).notNull().default("1"), + isCitizen: varchar("is_citizen", { length: 1 }).notNull().default("0"), + citizenLevel: int("citizen_level").notNull().default(0), + helperLevel: int("helper_level").notNull().default(0), + tradelockAmount: int("tradelock_amount").notNull().default(0), + cfhSend: int("cfh_send").notNull().default(0), + cfhAbusive: int("cfh_abusive").notNull().default(0), + cfhWarnings: int("cfh_warnings").notNull().default(0), + cfhBans: int("cfh_bans").notNull().default(0), + blockFollowing: varchar("block_following", { length: 1 }) + .notNull() + .default("0"), + blockFriendrequests: varchar("block_friendrequests", { length: 1 }) + .notNull() + .default("0"), + blockRoominvites: varchar("block_roominvites", { length: 1 }) + .notNull() + .default("0"), + volumeSystem: int("volume_system").notNull().default(100), + volumeFurni: int("volume_furni").notNull().default(100), + volumeTrax: int("volume_trax").notNull().default(100), + oldChat: varchar("old_chat", { length: 1 }).notNull().default("0"), + blockCameraFollow: varchar("block_camera_follow", { length: 1 }) + .notNull() + .default("0"), + chatColor: int("chat_color").notNull().default(0), + homeRoom: int("home_room").notNull().default(0), + onlineTime: int("online_time").notNull().default(0), + tags: varchar("tags", { length: 255 }) + .notNull() + .default("Arcturus Emulator;"), + clubExpireTimestamp: int("club_expire_timestamp").notNull().default(0), + loginStreak: int("login_streak").notNull().default(0), + rentSpaceId: int("rent_space_id").notNull().default(0), + rentSpaceEndtime: int("rent_space_endtime").notNull().default(0), + hofPoints: int("hof_points").notNull().default(0), + blockAlerts: varchar("block_alerts", { length: 1 }).notNull().default("0"), + talentTrackCitizenshipLevel: int("talent_track_citizenship_level") + .notNull() + .default(-1), + talentTrackHelpersLevel: int("talent_track_helpers_level") + .notNull() + .default(-1), + ignoreBots: varchar("ignore_bots", { length: 1 }).notNull().default("0"), + ignorePets: varchar("ignore_pets", { length: 1 }).notNull().default("0"), + nux: varchar("nux", { length: 1 }).notNull().default("0"), + muteEndTimestamp: int("mute_end_timestamp").notNull().default(0), + allowNameChange: varchar("allow_name_change", { length: 1 }) + .notNull() + .default("0"), + perkTrade: varchar("perk_trade", { length: 1 }).notNull().default("0"), + forumsPostCount: int("forums_post_count").default(0), + uiFlags: int("ui_flags").notNull().default(1), + hasGottenDefaultSavedSearches: tinyint("has_gotten_default_saved_searches") + .notNull() + .default(0), + hcGiftsClaimed: int("hc_gifts_claimed").default(0), + lastHcPayday: int("last_hc_payday").default(0), + maxRooms: int("max_rooms").default(50), + maxFriends: int("max_friends").default(300), +}); + +export const WebsiteArticles = mysqlTable("website_articles", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + slug: varchar("slug", { length: 255 }).unique().notNull(), + title: varchar("title", { length: 255 }).notNull(), + shortStory: varchar("short_story", { length: 255 }).notNull(), + fullStory: longtext("full_story").notNull(), + userId: int("user_id"), + image: varchar("image", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteLanguages = mysqlTable("website_languages", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + countryCode: varchar("country_code", { length: 8 }).unique().notNull(), + language: varchar("language", { length: 255 }).unique().notNull(), +}); + +export const UserReferrals = mysqlTable("user_referrals", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + referralsTotal: bigint("referrals_total", { + mode: "bigint", + unsigned: true, + }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const Referrals = mysqlTable("referrals", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + referredUserId: bigint("referred_user_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + referredUserIp: varchar("referred_user_ip", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const ClaimedReferralLogs = mysqlTable("claimed_referral_logs", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + ipAddress: varchar("ip_address", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteHousekeepingPermissions = mysqlTable( + "website_housekeeping_permissions", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + permission: varchar("permission", { length: 255 }).unique().notNull(), + minRank: int("min_rank").notNull(), + description: varchar("description", { length: 255 }), + groupName: varchar("group_name", { length: 100 }), + dependsOn: varchar("depends_on", { length: 255 }), + updatedBy: int("updated_by"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteIpWhitelist = mysqlTable("website_ip_whitelist", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + ipAddress: varchar("ip_address", { length: 255 }).notNull(), + asn: varchar("asn", { length: 255 }), + whitelistAsn: boolean("whitelist_asn").notNull().default(false), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteIpBlacklist = mysqlTable("website_ip_blacklist", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + ipAddress: varchar("ip_address", { length: 255 }).notNull(), + asn: varchar("asn", { length: 255 }), + blacklistAsn: boolean("blacklist_asn").notNull().default(false), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsitePermissions = mysqlTable("website_permissions", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + permission: varchar("permission", { length: 255 }).unique().notNull(), + minRank: varchar("min_rank", { length: 255 }).notNull(), + description: varchar("description", { length: 255 }), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteArticleReactions = mysqlTable("website_article_reactions", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id", { unsigned: true }).notNull(), + articleId: bigint("article_id", { mode: "bigint", unsigned: true }).notNull(), + reaction: varchar("reaction", { length: 50 }).notNull(), + active: boolean("active").notNull().default(true), +}); + +export const WebsiteWordfilter = mysqlTable("website_wordfilter", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + word: varchar("word", { length: 255 }).unique().notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteWriteableBoxes = mysqlTable("website_writeable_boxes", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + title: varchar("title", { length: 255 }).notNull(), + icon: varchar("icon", { length: 255 }), + content: text("content").notNull(), + position: int("position").notNull().default(0), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteLoginLogs = mysqlTable("website_login_logs", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + ip: varchar("ip", { length: 45 }).notNull(), + userAgent: varchar("user_agent", { length: 512 }), + createdAt: timestamp("created_at"), +}); + +export const WebsiteBetaCodes = mysqlTable("website_beta_codes", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + code: varchar("code", { length: 255 }).unique().notNull(), + userId: int("user_id"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteTeams = mysqlTable("website_teams", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + rankName: varchar("rank_name", { length: 255 }).unique().notNull(), + hiddenRank: boolean("hidden_rank").notNull().default(false), + badge: varchar("badge", { length: 255 }), + jobDescription: varchar("job_description", { length: 255 }), + staffColor: varchar("staff_color", { length: 255 }) + .notNull() + .default("#327fa8"), + staffBackground: varchar("staff_background", { length: 255 }) + .notNull() + .default("staff-bg.png"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteStaffApplications = mysqlTable( + "website_staff_applications", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + rankId: int("rank_id").notNull(), + content: text("content").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteOpenPositions = mysqlTable("website_open_positions", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + permissionId: int("permission_id").unique().notNull(), + description: text("description").notNull(), + applyFrom: timestamp("apply_from"), + applyTo: timestamp("apply_to"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteArticleComments = mysqlTable("website_article_comments", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + articleId: bigint("article_id", { mode: "bigint", unsigned: true }).notNull(), + userId: int("user_id").notNull(), + comment: varchar("comment", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteRareValueCategories = mysqlTable( + "website_rare_value_categories", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).unique().notNull(), + badge: varchar("badge", { length: 255 }).notNull(), + priority: int("priority", { unsigned: true }).notNull().default(1), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteRareValues = mysqlTable("website_rare_values", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + categoryId: bigint("category_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + itemId: int("item_id"), + name: varchar("name", { length: 255 }).notNull(), + creditValue: varchar("credit_value", { length: 255 }), + currencyValue: varchar("currency_value", { length: 255 }), + currencyType: varchar("currency_type", { length: 255 }) + .notNull() + .default("diamonds"), + furnitureIcon: varchar("furniture_icon", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteRuleCategories = mysqlTable("website_rule_categories", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).unique().notNull(), + description: varchar("description", { length: 255 }).notNull(), + badge: varchar("badge", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteRules = mysqlTable("website_rules", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + categoryId: bigint("category_id", { mode: "bigint", unsigned: true }), + paragraph: varchar("paragraph", { length: 8 }).notNull(), + rule: text("rule").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteInstallation = mysqlTable("website_installation", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + step: int("step", { unsigned: true }).default(0), + completed: boolean("completed").notNull().default(false), + installationKey: varchar("installation_key", { length: 255 }), + userIp: varchar("user_ip", { length: 255 }), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteHelpCenterCategories = mysqlTable( + "website_help_center_categories", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).unique().notNull(), + content: text("content").notNull(), + position: int("position", { unsigned: true }).notNull().default(1), + imageUrl: varchar("image_url", { length: 255 }), + buttonText: varchar("button_text", { length: 255 }), + buttonUrl: varchar("button_url", { length: 255 }), + buttonColor: varchar("button_color", { length: 16 }) + .notNull() + .default("#eeb425"), + buttonBorderColor: varchar("button_border_color", { length: 16 }) + .notNull() + .default("#facc15"), + smallBox: boolean("small_box").notNull().default(false), + }, +); + +export const WebsiteHelpCenterTickets = mysqlTable( + "website_help_center_tickets", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id"), + categoryId: bigint("category_id", { mode: "bigint", unsigned: true }), + title: varchar("title", { length: 255 }).notNull(), + content: text("content").notNull(), + open: boolean("open").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsitePaypalTransactions = mysqlTable( + "website_paypal_transactions", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + transactionId: varchar("transaction_id", { length: 255 }).notNull(), + status: varchar("status", { length: 255 }), + description: varchar("description", { length: 512 }), + amount: double("amount").notNull(), + currency: varchar("currency", { length: 255 }).notNull().default("USD"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteShopArticles = mysqlTable("website_shop_articles", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + categoryId: bigint("website_shop_category_id", { + mode: "bigint", + unsigned: true, + }), + name: varchar("name", { length: 255 }).unique().notNull(), + info: varchar("info", { length: 255 }).notNull(), + iconUrl: varchar("icon_url", { length: 255 }).notNull(), + color: varchar("color", { length: 255 }).notNull(), + costs: int("costs", { unsigned: true }).notNull(), + giveRank: int("give_rank"), + isGiftable: boolean("is_giftable").notNull().default(false), + credits: int("credits", { unsigned: true }), + duckets: int("duckets", { unsigned: true }), + diamonds: int("diamonds", { unsigned: true }), + badges: varchar("badges", { length: 255 }), + furniture: longtext("furniture"), + position: int("position", { unsigned: true }).notNull().default(0), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteShopVouchers = mysqlTable("website_shop_vouchers", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + code: varchar("code", { length: 255 }).unique().notNull(), + maxUses: int("max_uses", { unsigned: true }).notNull().default(1), + useCount: int("use_count", { unsigned: true }).notNull().default(0), + amount: int("amount", { unsigned: true }).notNull(), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteUsedShopVouchers = mysqlTable( + "website_used_shop_vouchers", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + voucherId: bigint("voucher_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteShopArticleFeatures = mysqlTable( + "website_shop_article_features", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + articleId: bigint("article_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + content: varchar("content", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteHelpCenterTicketReplies = mysqlTable( + "website_help_center_ticket_replies", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + ticketId: bigint("ticket_id", { mode: "bigint", unsigned: true }).notNull(), + userId: int("user_id").notNull(), + content: text("content").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, +); + +export const WebsiteTicket = mysqlTable("website_tickets", { + id: int("id").autoincrement().primaryKey().notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + category: varchar("category", { length: 50 }).notNull().default("general"), + priority: varchar("priority", { length: 20 }).notNull().default("normal"), + status: varchar("status", { length: 20 }).notNull().default("open"), + creatorId: int("creator_id").notNull(), + assigneeId: int("assignee_id"), + closedAt: datetime("closed_at"), + createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at").notNull(), +}); + +export const WebsiteTicketMessage = mysqlTable("website_ticket_messages", { + id: int("id").autoincrement().primaryKey().notNull(), + ticketId: int("ticket_id").notNull(), + userId: int("user_id").notNull(), + message: text("message").notNull(), + isStaff: int("is_staff").notNull().default(0), + createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +export const WebsiteTicketTemplate = mysqlTable("website_ticket_templates", { + id: int("id").autoincrement().primaryKey().notNull(), + title: varchar("title", { length: 255 }).notNull(), + content: text("content").notNull(), + category: varchar("category", { length: 50 }).notNull().default("general"), + sortOrder: int("sort_order").notNull().default(0), + createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +export const UserWatch = mysqlTable( + "user_watch", + { + id: int("id").autoincrement().primaryKey().notNull(), + staffId: int("staff_id").notNull(), + targetUserId: int("target_user_id").notNull(), + reason: varchar("reason", { length: 255 }).notNull().default(""), + createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, + (t) => [ + uniqueIndex("user_watch_staffId_targetUserId").on( + t.staffId, + t.targetUserId, + ), + ], +); + +export const AdminAuditLog = mysqlTable("admin_audit_log", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull(), + action: varchar("action", { length: 191 }).notNull().default(""), + target: varchar("target", { length: 191 }).notNull().default(""), + targetId: int("target_id"), + details: text("details"), + before: text("before"), + after: text("after"), + diff: text("diff"), + ipAddress: varchar("ip_address", { length: 45 }), + createdAt: varchar("created_at", { length: 64 }).notNull().default(""), + updatedAt: varchar("updated_at", { length: 64 }), +}); + +export const AclRole = mysqlTable("acl_roles", { + id: int("id").autoincrement().primaryKey().notNull(), + slug: varchar("slug", { length: 191 }).unique().notNull(), + title: varchar("title", { length: 255 }).notNull().default(""), + description: text("description"), + createdAt: datetime("created_at").default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at"), +}); + +export const AclPermission = mysqlTable("acl_permissions", { + id: int("id").autoincrement().primaryKey().notNull(), + slug: varchar("slug", { length: 191 }).unique().notNull(), + title: varchar("title", { length: 255 }).notNull().default(""), + createdAt: datetime("created_at").default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at"), +}); + +export const AclModelRole = mysqlTable("acl_model_roles", { + id: int("id").autoincrement().primaryKey().notNull(), + modelType: varchar("model_type", { length: 191 }).notNull().default("User"), + modelId: int("model_id").notNull(), + roleId: int("role_id").notNull(), +}); + +export const AclModelPermission = mysqlTable("acl_model_permissions", { + id: int("id").autoincrement().primaryKey().notNull(), + modelType: varchar("model_type", { length: 191 }).notNull().default("Role"), + modelId: int("model_id").notNull(), + permissionId: int("permission_id").notNull(), +}); + +export const AlertLogs = mysqlTable("alert_logs", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + type: varchar("type", { length: 255 }).notNull(), + severity: varchar("severity", { length: 255 }).notNull(), + message: text("message").notNull(), + context: longtext("context"), + sentViaEmail: boolean("sent_via_email").notNull().default(false), + sentViaDiscord: boolean("sent_via_discord").notNull().default(false), + isRead: boolean("is_read").notNull().default(false), + readAt: timestamp("read_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteUserGuestbooks = mysqlTable("website_user_guestbooks", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + profileId: int("profile_id").notNull(), + userId: int("user_id").notNull(), + message: varchar("message", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteShopCategories = mysqlTable("website_shop_categories", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + icon: varchar("icon", { length: 255 }), + order: int("order").notNull().default(0), +}); + +export const WebsiteMaintenanceTasks = mysqlTable("website_maintenance_tasks", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id"), + task: varchar("task", { length: 255 }).notNull(), + completed: boolean("completed").notNull().default(false), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const Tags = mysqlTable("tags", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + backgroundColor: varchar("background_color", { length: 10 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const Taggables = mysqlTable( + "taggables", + { + tagId: bigint("tag_id", { mode: "bigint", unsigned: true }).notNull(), + taggableId: bigint("taggable_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + taggableType: varchar("taggable_type", { length: 255 }).notNull(), + }, + (t) => [ + uniqueIndex("taggables_tagId_taggableId_taggableType").on( + t.tagId, + t.taggableId, + t.taggableType, + ), + ], +); + +export const WebsiteBadges = mysqlTable("website_badges", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + badgeKey: varchar("badge_key", { length: 255 }).unique().notNull(), + badgeName: varchar("badge_name", { length: 255 }).notNull(), + badgeDescription: text("badge_description").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteAds = mysqlTable("website_ads", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + image: varchar("image", { length: 255 }).notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteDrawbadges = mysqlTable("website_drawbadges", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id").notNull(), + badgePath: varchar("badge_path", { length: 255 }).notNull(), + badgeUrl: varchar("badge_url", { length: 255 }).notNull(), + badgeName: varchar("badge_name", { length: 255 }).notNull(), + badgeDesc: varchar("badge_desc", { length: 255 }).notNull(), + published: boolean("published").notNull().default(false), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioRanks = mysqlTable("radio_ranks", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + description: varchar("description", { length: 255 }), + badgeCode: varchar("badge_code", { length: 255 }), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioShouts = mysqlTable("radio_shouts", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + message: text("message").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioApplications = mysqlTable("radio_applications", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + realName: varchar("real_name", { length: 255 }).notNull(), + age: int("age").notNull(), + availability: text("availability").notNull(), + experience: text("experience"), + motivation: text("motivation").notNull(), + musicStyle: text("music_style"), + discordUsername: varchar("discord_username", { length: 255 }), + rankId: bigint("rank_id", { mode: "bigint", unsigned: true }), + status: varchar("status", { length: 8 }).notNull().default("pending"), + approvedBy: bigint("approved_by", { mode: "bigint", unsigned: true }), + approvedAt: timestamp("approved_at"), + rejectedBy: bigint("rejected_by", { mode: "bigint", unsigned: true }), + rejectedAt: timestamp("rejected_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioSchedules = mysqlTable("radio_schedules", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + day: varchar("day", { length: 255 }).notNull(), + startTime: timeAsDate("start_time").notNull(), + endTime: timeAsDate("end_time").notNull(), + showName: varchar("show_name", { length: 255 }), + description: text("description"), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioBanners = mysqlTable("radio_banners", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + imagePath: varchar("image_path", { length: 255 }).notNull(), + title: varchar("title", { length: 255 }), + description: text("description"), + sortOrder: int("sort_order").notNull().default(0), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioHistory = mysqlTable("radio_history", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + showName: varchar("show_name", { length: 255 }), + startedAt: timestamp("started_at").notNull(), + endedAt: timestamp("ended_at"), + listenersCount: int("listeners_count").notNull().default(0), + notes: text("notes"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioContests = mysqlTable("radio_contests", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + prize: varchar("prize", { length: 255 }), + startDate: timestamp("start_date"), + endDate: timestamp("end_date"), + winnerId: bigint("winner_id", { mode: "bigint", unsigned: true }), + isActive: boolean("is_active").notNull().default(true), + createdBy: bigint("created_by", { mode: "bigint", unsigned: true }), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioGiveaways = mysqlTable("radio_giveaways", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + prize: varchar("prize", { length: 255 }), + prizeAmount: int("prize_amount").notNull().default(0), + prizeCurrency: varchar("prize_currency", { length: 50 }), + startDate: timestamp("start_date"), + endDate: timestamp("end_date"), + winnerId: bigint("winner_id", { mode: "bigint", unsigned: true }), + isActive: boolean("is_active").notNull().default(true), + createdBy: bigint("created_by", { mode: "bigint", unsigned: true }), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioListenerPoints = mysqlTable("radio_listener_points", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + points: decimal("points", { precision: 10, scale: 2, mode: "number" }) + .notNull() + .default(0), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioSongRequests = mysqlTable("radio_song_requests", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }), + songTitle: varchar("song_title", { length: 255 }), + artist: varchar("artist", { length: 255 }), + isApproved: boolean("is_approved").notNull().default(false), + isPlayed: boolean("is_played").notNull().default(false), + votes: int("votes").notNull().default(0), + submittedAt: timestamp("submitted_at"), + playedAt: timestamp("played_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioSongVotes = mysqlTable("radio_song_votes", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const GameScores = mysqlTable("game_scores", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: int("user_id", { unsigned: true }).notNull(), + game: varchar("game", { length: 50 }).notNull(), + score: int("score").notNull().default(0), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const GameRooms = mysqlTable("game_rooms", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + game: varchar("game", { length: 50 }).notNull(), + player1Id: int("player1_id", { unsigned: true }).notNull(), + player2Id: int("player2_id", { unsigned: true }), + state: longtext("state"), + turn: int("turn", { unsigned: true }).notNull().default(1), + winnerId: int("winner_id", { unsigned: true }), + status: varchar("status", { length: 8 }).notNull().default("waiting"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const GameRewardClaims = mysqlTable( + "game_reward_claims", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + game: varchar("game", { length: 50 }).notNull(), + winsRequired: int("wins_required").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, + (t) => [ + uniqueIndex("game_reward_claims_userId_game_winsRequired").on( + t.userId, + t.game, + t.winsRequired, + ), + ], +); + +export const GameChatMessages = mysqlTable("game_chat_messages", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + roomId: bigint("room_id", { mode: "bigint", unsigned: true }).notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + message: text("message").notNull(), + type: varchar("type", { length: 255 }).notNull().default("chat"), + reactions: longtext("reactions"), + createdAt: timestamp("created_at").notNull(), +}); + +export const GameReplays = mysqlTable("game_replays", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + game: varchar("game", { length: 255 }).notNull(), + player1Id: bigint("player1_id", { mode: "bigint", unsigned: true }).notNull(), + player2Id: bigint("player2_id", { mode: "bigint", unsigned: true }), + gameData: longtext("game_data").notNull(), + moves: longtext("moves").notNull(), + winner: varchar("winner", { length: 255 }), + player1Score: int("player1_score").notNull().default(0), + player2Score: int("player2_score").notNull().default(0), + startedAt: timestamp("started_at").notNull(), + endedAt: timestamp("ended_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const GameRanks = mysqlTable("game_ranks", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + slug: varchar("slug", { length: 255 }).unique().notNull(), + icon: varchar("icon", { length: 255 }).notNull().default("⭐"), + color: varchar("color", { length: 255 }).notNull().default("#ffffff"), + minPoints: int("min_points").notNull().default(0), + maxPoints: int("max_points"), + tier: int("tier").notNull().default(1), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const UserGameRanks = mysqlTable( + "user_game_ranks", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + game: varchar("game", { length: 255 }).notNull(), + rankId: bigint("rank_id", { mode: "bigint", unsigned: true }).notNull(), + points: int("points").notNull().default(0), + wins: int("wins").notNull().default(0), + gamesPlayed: int("games_played").notNull().default(0), + winStreak: int("win_streak").notNull().default(0), + bestStreak: int("best_streak").notNull().default(0), + lastPlayedAt: timestamp("last_played_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, + (t) => [uniqueIndex("user_game_ranks_userId_game").on(t.userId, t.game)], +); + +export const GameInvitations = mysqlTable("game_invitations", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + fromUserId: bigint("from_user_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + toUserId: bigint("to_user_id", { mode: "bigint", unsigned: true }).notNull(), + game: varchar("game", { length: 255 }).notNull(), + status: varchar("status", { length: 255 }).notNull().default("pending"), + betAmount: int("bet_amount").notNull().default(0), + betCurrency: varchar("bet_currency", { length: 255 }) + .notNull() + .default("credits"), + roomId: bigint("room_id", { mode: "bigint", unsigned: true }), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const UserGameXp = mysqlTable( + "user_game_xp", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + game: varchar("game", { length: 255 }).notNull(), + xp: int("xp").notNull().default(0), + level: int("level").notNull().default(1), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, + (t) => [uniqueIndex("user_game_xp_userId_game").on(t.userId, t.game)], +); + +export const GameLevelRewards = mysqlTable("game_level_rewards", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + game: varchar("game", { length: 255 }).notNull(), + level: int("level").notNull(), + rewardType: varchar("reward_type", { length: 255 }).notNull(), + rewardCurrency: varchar("reward_currency", { length: 255 }), + rewardAmount: int("reward_amount").notNull().default(0), + claimed: boolean("claimed").notNull().default(false), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const DailyChallenges = mysqlTable("daily_challenges", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + description: varchar("description", { length: 255 }).notNull(), + game: varchar("game", { length: 255 }), + type: varchar("type", { length: 255 }).notNull(), + target: int("target").notNull(), + rewardCurrency: varchar("reward_currency", { length: 255 }) + .notNull() + .default("credits"), + rewardAmount: int("reward_amount").notNull(), + date: dateAsDate("date").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const UserChallengeProgress = mysqlTable( + "user_challenge_progress", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + challengeId: bigint("challenge_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + progress: int("progress").notNull().default(0), + completed: boolean("completed").notNull().default(false), + claimed: boolean("claimed").notNull().default(false), + completedAt: timestamp("completed_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, + (t) => [ + uniqueIndex("user_challenge_progress_userId_challengeId").on( + t.userId, + t.challengeId, + ), + ], +); + +export const PersonalAccessTokens = mysqlTable("personal_access_tokens", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + tokenableId: bigint("tokenable_id", { + mode: "bigint", + unsigned: true, + }).notNull(), + tokenableType: varchar("tokenable_type", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }).notNull(), + token: varchar("token", { length: 64 }).unique().notNull(), + abilities: text("abilities"), + lastUsedAt: timestamp("last_used_at"), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const StaffActivities = mysqlTable("staff_activities", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + action: varchar("action", { length: 50 }).notNull(), + targetType: varchar("target_type", { length: 100 }), + targetId: bigint("target_id", { mode: "bigint", unsigned: true }), + description: text("description").notNull(), + ipAddress: varchar("ip_address", { length: 45 }), + metadata: longtext("metadata"), + createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: timestamp("updated_at"), +}); + +export const EmailTemplates = mysqlTable("email_templates", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).unique().notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + body: text("body").notNull(), + variables: text("variables"), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const SocialAccounts = mysqlTable( + "social_accounts", + { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + userId: bigint("user_id", { mode: "bigint", unsigned: true }).notNull(), + provider: varchar("provider", { length: 255 }).notNull(), + providerId: varchar("provider_id", { length: 255 }).notNull(), + avatar: varchar("avatar", { length: 255 }), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, + (t) => [ + uniqueIndex("social_accounts_provider_providerId").on( + t.provider, + t.providerId, + ), + ], +); + +export const RadioApiKeys = mysqlTable("radio_api_keys", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + name: varchar("name", { length: 255 }).notNull(), + key: varchar("key", { length: 64 }).unique().notNull(), + allowedIps: varchar("allowed_ips", { length: 255 }), + permissions: longtext("permissions"), + rateLimit: int("rate_limit").notNull().default(300), + lastUsedAt: timestamp("last_used_at"), + expiresAt: timestamp("expires_at"), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioSongPlays = mysqlTable("radio_song_plays", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + title: varchar("title", { length: 255 }).notNull(), + artist: varchar("artist", { length: 255 }), + album: varchar("album", { length: 255 }), + artworkUrl: varchar("artwork_url", { length: 255 }), + duration: int("duration", { unsigned: true }), + playedAt: timestamp("played_at").notNull(), + djId: bigint("dj_id", { mode: "bigint", unsigned: true }), + metadata: longtext("metadata"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const RadioAutoDjPlaylist = mysqlTable("radio_auto_dj_playlist", { + id: bigint("id", { mode: "bigint", unsigned: true }) + .autoincrement() + .primaryKey() + .notNull(), + title: varchar("title", { length: 255 }).notNull(), + artist: varchar("artist", { length: 255 }), + album: varchar("album", { length: 255 }), + artworkUrl: varchar("artwork_url", { length: 255 }), + duration: int("duration", { unsigned: true }), + playCount: int("play_count", { unsigned: true }).notNull().default(0), + lastPlayedAt: timestamp("last_played_at"), + isActive: boolean("is_active").notNull().default(true), + sortOrder: int("sort_order", { unsigned: true }).notNull().default(0), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); + +export const WebsiteBanner = mysqlTable("website_banners", { + id: int("id").autoincrement().primaryKey().notNull(), + title: varchar("title", { length: 255 }).notNull().default(""), + subtitle: varchar("subtitle", { length: 500 }).notNull().default(""), + image: varchar("image", { length: 500 }).notNull().default(""), + link: varchar("link", { length: 500 }).notNull().default(""), + color: varchar("color", { length: 20 }).notNull().default(""), + isActive: int("is_active").notNull().default(1), + sortOrder: int("sort_order").notNull().default(0), + startDate: varchar("start_date", { length: 50 }), + endDate: varchar("end_date", { length: 50 }), +}); + +export const WebsiteEventType = mysqlTable("website_event_types", { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 100 }).notNull(), + slug: varchar("slug", { length: 100 }).unique().notNull(), + description: varchar("description", { length: 500 }).notNull().default(""), + color: varchar("color", { length: 20 }).notNull().default("#3b82f6"), + icon: varchar("icon", { length: 50 }).notNull().default("calendar"), + isActive: int("is_active").notNull().default(1), + minRank: int("min_rank").notNull().default(0), +}); + +export const WebsiteEvent = mysqlTable("website_events", { + id: int("id").autoincrement().primaryKey().notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description").notNull(), + typeId: int("type_id").notNull(), + hostUserId: int("host_user_id").notNull(), + roomId: int("room_id"), + startsAt: datetime("starts_at", { fsp: 3 }).notNull(), + endsAt: datetime("ends_at", { fsp: 3 }), + maxPlayers: int("max_players"), + isRecurring: int("is_recurring").notNull().default(0), + recurrenceRule: varchar("recurrence_rule", { length: 255 }), + status: varchar("status", { length: 20 }).notNull().default("draft"), + image: varchar("image", { length: 500 }), + createdAt: datetime("created_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at", { fsp: 3 }).notNull(), +}); + +export const WebsiteEventRegistration = mysqlTable( + "website_event_registrations", + { + id: int("id").autoincrement().primaryKey().notNull(), + eventId: int("event_id").notNull(), + userId: int("user_id").notNull(), + createdAt: datetime("created_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (t) => [ + uniqueIndex("website_event_registrations_eventId_userId").on( + t.eventId, + t.userId, + ), + ], +); + +export const WebsiteEventPrize = mysqlTable("website_event_prizes", { + id: int("id").autoincrement().primaryKey().notNull(), + eventId: int("event_id").notNull(), + position: int("position").notNull().default(1), + prizeType: varchar("prize_type", { length: 20 }).notNull().default("badge"), + badgeCode: varchar("badge_code", { length: 50 }), + credits: int("credits").notNull().default(0), + pixels: int("pixels").notNull().default(0), + points: int("points").notNull().default(0), + itemId: int("item_id"), + description: varchar("description", { length: 255 }).notNull().default(""), +}); + +export const WebsiteEventWinner = mysqlTable( + "website_event_winners", + { + id: int("id").autoincrement().primaryKey().notNull(), + eventId: int("event_id").notNull(), + userId: int("user_id").notNull(), + position: int("position").notNull().default(1), + createdAt: datetime("created_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (t) => [ + uniqueIndex("website_event_winners_eventId_userId").on(t.eventId, t.userId), + ], +); + +export const WebsitePoll = mysqlTable("website_polls", { + id: int("id").autoincrement().primaryKey().notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + status: varchar("status", { length: 20 }).notNull().default("draft"), + showResults: int("show_results").notNull().default(1), + multipleChoice: int("multiple_choice").notNull().default(0), + startsAt: datetime("starts_at", { fsp: 3 }), + endsAt: datetime("ends_at", { fsp: 3 }), + createdAt: datetime("created_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at", { fsp: 3 }).notNull(), +}); + +export const WebsitePollQuestion = mysqlTable("website_poll_questions", { + id: int("id").autoincrement().primaryKey().notNull(), + pollId: int("poll_id").notNull(), + question: varchar("question", { length: 500 }).notNull(), + type: varchar("type", { length: 20 }).notNull().default("single"), + sortOrder: int("sort_order").notNull().default(0), + options: text("options").notNull(), +}); + +export const WebsitePollVote = mysqlTable( + "website_poll_votes", + { + id: int("id").autoincrement().primaryKey().notNull(), + questionId: int("question_id").notNull(), + userId: int("user_id").notNull(), + answer: varchar("answer", { length: 500 }).notNull(), + createdAt: datetime("created_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (t) => [ + uniqueIndex("website_poll_votes_questionId_userId").on( + t.questionId, + t.userId, + ), + ], +); + +export const AchievementsTalents = mysqlTable("achievements_talents", { + id: int("id").autoincrement().primaryKey().notNull(), + type: varchar("type", { length: 11 }).notNull().default("citizenship"), + level: int("level").notNull().default(0), + achievementIds: varchar("achievement_ids", { length: 100 }) + .notNull() + .default(""), + achievementLevels: varchar("achievement_levels", { length: 100 }) + .notNull() + .default(""), + rewardFurni: varchar("reward_furni", { length: 100 }).notNull().default(""), + rewardPerks: varchar("reward_perks", { length: 100 }).notNull().default(""), + rewardBadges: varchar("reward_badges", { length: 100 }).notNull().default(""), +}); + +export const Bots = mysqlTable("bots", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull().default(0), + roomId: int("room_id").notNull().default(0), + name: varchar("name", { length: 25 }).notNull().default(""), + motto: varchar("motto", { length: 100 }).notNull().default(""), + figure: varchar("figure", { length: 500 }).notNull().default(""), + gender: varchar("gender", { length: 1 }).notNull().default("M"), + x: int("x").notNull().default(0), + y: int("y").notNull().default(0), + z: double("z").notNull().default(0.0), + rot: int("rot").notNull().default(0), + chatLines: varchar("chat_lines", { length: 5112 }).notNull().default(""), + chatAuto: varchar("chat_auto", { length: 1 }).notNull().default("1"), + chatRandom: varchar("chat_random", { length: 1 }).notNull().default("1"), + chatDelay: int("chat_delay").notNull().default(5), + dance: int("dance").notNull().default(0), + freeroam: varchar("freeroam", { length: 1 }).notNull().default("0"), + type: varchar("type", { length: 32 }).notNull().default("generic"), + effect: int("effect").notNull().default(0), + bubbleId: int("bubble_id").default(31), +}); + +export const CatalogClothing = mysqlTable("catalog_clothing", { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 75 }).notNull(), + setid: varchar("setid", { length: 200 }).notNull(), +}); + +export const CatalogClubOffers = mysqlTable("catalog_club_offers", { + id: int("id").primaryKey().notNull(), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), + name: varchar("name", { length: 35 }).notNull(), + days: int("days").notNull(), + credits: int("credits").notNull().default(10), + points: int("points").notNull().default(0), + pointsType: int("points_type").notNull().default(0), + type: varchar("type", { length: 19 }).notNull().default("HC"), + deal: varchar("deal", { length: 1 }).notNull().default("0"), + giftable: varchar("giftable", { length: 1 }).notNull().default("0"), +}); + +export const CatalogFeaturedPages = mysqlTable("catalog_featured_pages", { + slotId: int("slot_id").primaryKey().notNull(), + image: varchar("image", { length: 70 }).notNull().default(""), + caption: varchar("caption", { length: 130 }).notNull().default(""), + type: varchar("type", { length: 12 }).notNull().default("page_name"), + expireTimestamp: int("expire_timestamp").notNull().default(-1), + pageName: varchar("page_name", { length: 30 }).notNull().default(""), + pageId: int("page_id").notNull().default(0), + productName: varchar("product_name", { length: 40 }).notNull().default(""), +}); + +export const CatalogItemsLimited = mysqlTable( + "catalog_items_limited", + { + catalogItemId: int("catalog_item_id").notNull(), + number: int("number").notNull(), + userId: int("user_id").notNull().default(0), + timestamp: int("timestamp").notNull().default(0), + itemId: int("item_id").notNull().default(0), + }, + (t) => [ + uniqueIndex("catalog_items_limited_catalogItemId_number").on( + t.catalogItemId, + t.number, + ), + ], +); + +export const CatalogTargetOffers = mysqlTable("catalog_target_offers", { + id: int("id").autoincrement().primaryKey().notNull(), + offerCode: varchar("offer_code", { length: 32 }).notNull(), + title: varchar("title", { length: 128 }).notNull().default(""), + description: varchar("description", { length: 2048 }).notNull().default(""), + image: varchar("image", { length: 128 }).notNull(), + icon: varchar("icon", { length: 128 }).notNull(), + endTimestamp: int("end_timestamp").notNull(), + credits: int("credits").notNull().default(10), + points: int("points").notNull().default(10), + pointsType: int("points_type").notNull().default(5), + purchaseLimit: int("purchase_limit").notNull().default(5), + catalogItem: int("catalog_item").notNull(), + vars: varchar("vars", { length: 1024 }).notNull().default(""), +}); + +export const ChatlogsPrivate = mysqlTable("chatlogs_private", { + id: int("id").autoincrement().primaryKey().notNull(), + userFromId: int("user_from_id").notNull(), + userToId: int("user_to_id").notNull(), + message: varchar("message", { length: 255 }).notNull(), + timestamp: int("timestamp").notNull().default(0), +}); + +export const CraftingAltarsRecipes = mysqlTable( + "crafting_altars_recipes", + { + altarId: int("altar_id").notNull(), + recipeId: int("recipe_id").notNull(), + }, + (t) => [ + uniqueIndex("crafting_altars_recipes_altarId_recipeId").on( + t.altarId, + t.recipeId, + ), + ], +); + +export const CraftingRecipes = mysqlTable( + "crafting_recipes", + { + id: int("id").primaryKey().notNull(), + productName: varchar("product_name", { length: 64 }).unique().notNull(), + reward: int("reward").notNull(), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), + achievement: varchar("achievement", { length: 255 }).notNull().default(""), + secret: varchar("secret", { length: 1 }).notNull().default("0"), + limited: varchar("limited", { length: 1 }).notNull().default("0"), + remaining: int("remaining").notNull().default(0), + }, + (t) => [uniqueIndex("crafting_recipes_productName").on(t.productName)], +); + +export const GiftWrappers = mysqlTable("gift_wrappers", { + id: int("id").autoincrement().primaryKey().notNull(), + spriteId: int("sprite_id").notNull(), + itemId: int("item_id").notNull(), + type: varchar("type", { length: 7 }).notNull().default("wrapper"), +}); + +export const GroupsItems = mysqlTable( + "groups_items", + { + type: varchar("type", { length: 6 }).notNull(), + id: int("id").notNull(), + firstvalue: varchar("firstvalue", { length: 255 }).notNull(), + secondvalue: varchar("secondvalue", { length: 2000 }).notNull(), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), + }, + (t) => [primaryKey({ columns: [t.id, t.type] })], +); + +export const GuildForumViews = mysqlTable("guild_forum_views", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull(), + guildId: int("guild_id").notNull(), + timestamp: int("timestamp").notNull(), +}); + +export const GuildsElements = mysqlTable( + "guilds_elements", + { + id: int("id").notNull(), + firstvalue: varchar("firstvalue", { length: 300 }).notNull(), + secondvalue: varchar("secondvalue", { length: 300 }).notNull(), + type: varchar("type", { length: 50 }).notNull(), + enabled: varchar("enabled", { length: 1 }).notNull().default("1"), + }, + (t) => [uniqueIndex("guilds_elements_id_type").on(t.id, t.type)], +); + +export const HotelviewNews = mysqlTable("hotelview_news", { + id: int("id").autoincrement().primaryKey().notNull(), + title: varchar("title", { length: 100 }).notNull(), + text: varchar("text", { length: 500 }).notNull(), + buttonText: varchar("button_text", { length: 50 }).notNull(), + buttonType: varchar("button_type", { length: 6 }).notNull().default("web"), + buttonLink: varchar("button_link", { length: 200 }).notNull(), + image: varchar("image", { length: 200 }).notNull(), +}); + +export const ItemsCrackable = mysqlTable("items_crackable", { + itemId: int("item_id").primaryKey().notNull(), + itemName: varchar("item_name", { length: 255 }).notNull(), + count: int("count").notNull(), + prizes: varchar("prizes", { length: 860 }).notNull().default("179:1"), + achievementTick: varchar("achievement_tick", { length: 64 }).notNull(), + achievementCracked: varchar("achievement_cracked", { length: 64 }).notNull(), + requiredEffect: int("required_effect").notNull().default(0), + subscriptionDuration: int("subscription_duration"), + subscriptionType: varchar("subscription_type", { length: 255 }), +}); + +export const ItemsHighscoreData = mysqlTable("items_highscore_data", { + id: int("id").autoincrement().primaryKey().notNull(), + itemId: int("item_id").notNull(), + userIds: varchar("user_ids", { length: 500 }).notNull(), + score: int("score").notNull(), + isWin: tinyint("is_win").default(0), + timestamp: int("timestamp").notNull(), +}); + +export const LogsHcPayday = mysqlTable("logs_hc_payday", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + timestamp: int("timestamp", { unsigned: true }), + userId: int("user_id"), + hcStreak: int("hc_streak", { unsigned: true }), + totalCoinsSpent: int("total_coins_spent", { unsigned: true }), + rewardCoinsSpent: int("reward_coins_spent", { unsigned: true }), + rewardStreak: int("reward_streak", { unsigned: true }), + totalPayout: int("total_payout", { unsigned: true }), + currency: varchar("currency", { length: 255 }), + claimed: tinyint("claimed").default(0), +}); + +export const MessengerCategories = mysqlTable("messenger_categories", { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 25 }).notNull(), + userId: int("user_id").notNull(), +}); + +export const NavigatorFilter = mysqlTable("navigator_filter", { + key: varchar("key", { length: 11 }).primaryKey().notNull(), + field: varchar("field", { length: 32 }).notNull(), + compare: varchar("compare", { length: 18 }).notNull(), + databaseQuery: varchar("database_query", { length: 1024 }).notNull(), +}); + +export const NuxGifts = mysqlTable("nux_gifts", { + id: int("id").autoincrement().primaryKey().notNull(), + type: varchar("type", { length: 4 }).notNull().default("item"), + value: varchar("value", { length: 32 }).notNull(), + image: varchar("image", { length: 256 }).notNull(), +}); + +export const OldGuildsForums = mysqlTable("old_guilds_forums", { + id: int("id").autoincrement().primaryKey().notNull(), + guildId: int("guild_id").notNull(), + userId: int("user_id").notNull(), + subject: mediumtext("subject").notNull(), + message: longtext("message").notNull(), + state: varchar("state", { length: 15 }).notNull().default("OPEN"), + adminId: int("admin_id").notNull().default(0), + pinned: varchar("pinned", { length: 1 }).notNull().default("0"), + locked: varchar("locked", { length: 1 }).notNull().default("0"), + timestamp: int("timestamp").notNull(), +}); + +export const OldGuildsForumsComments = mysqlTable( + "old_guilds_forums_comments", + { + id: int("id").autoincrement().primaryKey().notNull(), + threadId: int("thread_id").notNull(), + userId: int("user_id").notNull(), + timestamp: int("timestamp").notNull(), + message: longtext("message").notNull(), + state: varchar("state", { length: 15 }).notNull().default("OPEN"), + adminId: int("admin_id").notNull().default(0), + }, +); + +export const PetActions = mysqlTable("pet_actions", { + id: int("ID").autoincrement().primaryKey().notNull(), + petType: int("pet_type").notNull(), + petName: varchar("pet_name", { length: 32 }).notNull(), + offspringType: int("offspring_type").notNull().default(-1), + happyActions: varchar("happy_actions", { length: 100 }).notNull().default(""), + tiredActions: varchar("tired_actions", { length: 100 }).notNull().default(""), + randomActions: varchar("random_actions", { length: 100 }) + .notNull() + .default(""), + canSwim: varchar("can_swim", { length: 1 }).default("0"), +}); + +export const PetBreeds = mysqlTable( + "pet_breeds", + { + race: int("race").notNull(), + colorOne: int("color_one").notNull(), + colorTwo: int("color_two").notNull(), + hasColorOne: varchar("has_color_one", { length: 1 }).notNull().default("0"), + hasColorTwo: varchar("has_color_two", { length: 1 }).notNull().default("0"), + }, + (t) => [ + uniqueIndex("pet_breeds_race_colorOne_colorTwo_hasColorOne_hasColorTwo").on( + t.race, + t.colorOne, + t.colorTwo, + t.hasColorOne, + t.hasColorTwo, + ), + ], +); + +export const PetCommandsData = mysqlTable("pet_commands_data", { + commandId: int("command_id").primaryKey().notNull(), + text: varchar("text", { length: 15 }).notNull(), + requiredLevel: int("required_level").notNull(), + rewardXp: int("reward_xp").notNull().default(5), + costHappyness: int("cost_happyness").notNull().default(0), + costEnergy: int("cost_energy").notNull().default(0), +}); + +export const Polls = mysqlTable("polls", { + id: int("id").autoincrement().primaryKey().notNull(), + title: varchar("title", { length: 255 }) + .notNull() + .default( + "Hey! We'd appreciate it if you could take some time to answer these questions. It will help improve our hotel.", + ), + thanksMessage: varchar("thanks_message", { length: 255 }).notNull(), + rewardBadge: varchar("reward_badge", { length: 10 }).notNull().default(""), +}); + +export const PollsAnswers = mysqlTable( + "polls_answers", + { + pollId: int("poll_id").notNull(), + userId: int("user_id").notNull(), + questionId: int("question_id").notNull(), + answer: varchar("answer", { length: 255 }).notNull(), + }, + (t) => [ + uniqueIndex("polls_answers_pollId_userId_questionId").on( + t.pollId, + t.userId, + t.questionId, + ), + ], +); + +export const PollsQuestions = mysqlTable("polls_questions", { + id: int("id").autoincrement().primaryKey().notNull(), + parentId: int("parent_id").notNull().default(0), + pollId: int("poll_id").notNull(), + order: int("order").notNull(), + question: varchar("question", { length: 255 }).notNull(), + type: int("type").notNull().default(2), + minSelections: int("min_selections").notNull().default(1), + options: varchar("options", { length: 255 }).notNull(), +}); + +export const RoomModels = mysqlTable("room_models", { + name: varchar("name", { length: 100 }).primaryKey().notNull(), + doorX: int("door_x").notNull(), + doorY: int("door_y").notNull(), + doorDir: int("door_dir").notNull().default(2), + heightmap: text("heightmap").notNull(), + publicItems: text("public_items").notNull(), + clubOnly: varchar("club_only", { length: 1 }).notNull().default("0"), +}); + +export const RoomTradeLogItems = mysqlTable( + "room_trade_log_items", + { + id: int("id").notNull(), + itemId: int("item_id").notNull(), + userId: int("user_id").notNull(), + }, + (t) => [ + uniqueIndex("room_trade_log_items_id_itemId_userId").on( + t.id, + t.itemId, + t.userId, + ), + ], +); + +export const RoomWordfilter = mysqlTable( + "room_wordfilter", + { + roomId: int("room_id").notNull(), + word: varchar("word", { length: 25 }).notNull(), + }, + (t) => [uniqueIndex("room_wordfilter_roomId_word").on(t.roomId, t.word)], +); + +export const SanctionLevels = mysqlTable("sanction_levels", { + level: int("level").primaryKey().notNull(), + type: varchar("type", { length: 5 }).notNull(), + hourLength: int("hour_length").notNull(), + probationDays: int("probation_days").notNull(), +}); + +export const Sanctions = mysqlTable("sanctions", { + id: int("id").autoincrement().primaryKey().notNull(), + habboId: int("habbo_id").notNull().default(0), + sanctionLevel: int("sanction_level").notNull().default(0), + probationTimestamp: int("probation_timestamp").notNull().default(0), + reason: varchar("reason", { length: 255 }).notNull().default(""), + tradeLockedUntil: int("trade_locked_until").notNull().default(0), + isMuted: tinyint("is_muted").notNull().default(0), + muteDuration: int("mute_duration").notNull().default(0), +}); + +export const SupportCfhCategories = mysqlTable("support_cfh_categories", { + id: int("id").autoincrement().primaryKey().notNull(), + nameInternal: varchar("name_internal", { length: 255 }), + nameExternal: varchar("name_external", { length: 255 }), +}); + +export const SupportCfhTopics = mysqlTable("support_cfh_topics", { + id: int("id").autoincrement().primaryKey().notNull(), + categoryId: int("category_id"), + nameInternal: varchar("name_internal", { length: 255 }), + nameExternal: varchar("name_external", { length: 255 }), + action: varchar("action", { length: 11 }).default("mods"), + ignoreTarget: varchar("ignore_target", { length: 1 }).notNull().default("0"), + autoReply: text("auto_reply"), + defaultSanction: int("default_sanction").notNull().default(0), +}); + +export const SupportIssueCategories = mysqlTable("support_issue_categories", { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 255 }).notNull().default("PII"), +}); + +export const SupportIssuePresets = mysqlTable("support_issue_presets", { + id: int("id").autoincrement().primaryKey().notNull(), + category: int("category").notNull().default(1), + name: varchar("name", { length: 100 }).notNull().default(""), + message: varchar("message", { length: 300 }).notNull().default(""), + reminder: varchar("reminder", { length: 100 }).notNull(), + banFor: int("ban_for").notNull().default(0), + muteFor: int("mute_for").notNull().default(0), +}); + +export const SupportPresets = mysqlTable("support_presets", { + id: int("id").autoincrement().primaryKey().notNull(), + type: varchar("type", { length: 4 }).notNull().default("user"), + preset: varchar("preset", { length: 200 }).notNull(), +}); + +export const UsersAchievements = mysqlTable( + "users_achievements", + { + userId: int("user_id").notNull(), + achievementName: varchar("achievement_name", { length: 255 }).notNull(), + progress: int("progress").notNull().default(1), + }, + (t) => [ + uniqueIndex("users_achievements_userId_achievementName").on( + t.userId, + t.achievementName, + ), + ], +); + +export const UsersAchievementsQueue = mysqlTable( + "users_achievements_queue", + { + userId: int("user_id").notNull(), + achievementId: int("achievement_id").notNull(), + amount: int("amount").notNull(), + }, + (t) => [ + uniqueIndex("users_achievements_queue_userId_achievementId").on( + t.userId, + t.achievementId, + ), + ], +); + +export const UsersClothing = mysqlTable( + "users_clothing", + { + userId: int("user_id").notNull(), + clothingId: int("clothing_id").notNull(), + }, + (t) => [ + uniqueIndex("users_clothing_userId_clothingId").on(t.userId, t.clothingId), + ], +); + +export const UsersEffects = mysqlTable( + "users_effects", + { + userId: int("user_id").notNull(), + effect: int("effect").notNull(), + duration: int("duration").notNull().default(86400), + activationTimestamp: int("activation_timestamp").notNull().default(-1), + total: int("total").notNull().default(1), + }, + (t) => [uniqueIndex("users_effects_userId_effect").on(t.userId, t.effect)], +); + +export const UsersFavoriteRooms = mysqlTable( + "users_favorite_rooms", + { + userId: int("user_id").notNull(), + roomId: int("room_id").notNull(), + }, + (t) => [ + uniqueIndex("users_favorite_rooms_userId_roomId").on(t.userId, t.roomId), + ], +); + +export const UsersPets = mysqlTable("users_pets", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull(), + roomId: int("room_id").notNull(), + name: varchar("name", { length: 15 }).notNull().default("User Pet"), + race: int("race").notNull(), + type: int("type").notNull(), + color: varchar("color", { length: 6 }).notNull(), + happyness: int("happyness").notNull().default(100), + experience: int("experience").notNull().default(0), + energy: int("energy").notNull().default(100), + hunger: int("hunger").notNull().default(0), + thirst: int("thirst").notNull().default(0), + respect: int("respect").notNull().default(0), + created: int("created").notNull(), + x: int("x").notNull().default(0), + y: int("y").notNull().default(0), + z: double("z").notNull().default(0), + rot: int("rot").notNull().default(0), + hairStyle: int("hair_style").notNull().default(-1), + hairColor: int("hair_color").notNull().default(0), + saddle: varchar("saddle", { length: 1 }).notNull().default("0"), + ride: varchar("ride", { length: 1 }).notNull().default("0"), + mpType: int("mp_type").notNull().default(0), + mpColor: int("mp_color").notNull().default(0), + mpNose: int("mp_nose").notNull().default(0), + mpNoseColor: tinyint("mp_nose_color").notNull().default(0), + mpEyes: int("mp_eyes").notNull().default(0), + mpEyesColor: tinyint("mp_eyes_color").notNull().default(0), + mpMouth: int("mp_mouth").notNull().default(0), + mpMouthColor: tinyint("mp_mouth_color").notNull().default(0), + mpDeathTimestamp: int("mp_death_timestamp").notNull().default(0), + mpBreedable: varchar("mp_breedable", { length: 1 }).notNull().default("0"), + mpAllowBreed: varchar("mp_allow_breed", { length: 1 }).notNull().default("0"), + gnomeData: varchar("gnome_data", { length: 80 }).notNull().default(""), + mpIsDead: tinyint("mp_is_dead").notNull().default(0), + saddleItemId: int("saddle_item_id"), +}); + +export const UsersRecipes = mysqlTable( + "users_recipes", + { + userId: int("user_id").notNull(), + recipe: int("recipe").notNull(), + }, + (t) => [uniqueIndex("users_recipes_userId_recipe").on(t.userId, t.recipe)], +); + +export const UsersSavedSearches = mysqlTable("users_saved_searches", { + id: int("id").autoincrement().primaryKey().notNull(), + searchCode: varchar("search_code", { length: 255 }).notNull(), + filter: varchar("filter", { length: 255 }), + userId: int("user_id").notNull(), +}); + +export const UsersSubscriptions = mysqlTable("users_subscriptions", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + userId: int("user_id"), + subscriptionType: varchar("subscription_type", { length: 255 }), + timestampStart: int("timestamp_start", { unsigned: true }), + duration: int("duration", { unsigned: true }), + active: tinyint("active").default(1), +}); + +export const UsersTargetOfferPurchases = mysqlTable( + "users_target_offer_purchases", + { + userId: int("user_id").notNull(), + offerId: int("offer_id").notNull(), + state: int("state").notNull().default(0), + amount: int("amount").notNull().default(0), + lastPurchase: int("last_purchase").notNull().default(0), + }, + (t) => [ + uniqueIndex("users_target_offer_purchases_userId_offerId").on( + t.userId, + t.offerId, + ), + ], +); + +export const UsersWardrobe = mysqlTable("users_wardrobe", { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull().default(0), + slotId: int("slot_id").notNull().default(0), + look: varchar("look", { length: 256 }).notNull(), + gender: varchar("gender", { length: 1 }).notNull().default("F"), +}); + +export const VoucherHistory = mysqlTable("voucher_history", { + id: int("id").autoincrement().primaryKey().notNull(), + voucherId: int("voucher_id").notNull(), + userId: int("user_id").notNull(), + timestamp: int("timestamp").notNull(), +}); + +export const Vouchers = mysqlTable("vouchers", { + id: int("id").autoincrement().primaryKey().notNull(), + code: varchar("code", { length: 10 }).notNull(), + credits: int("credits").notNull().default(0), + points: int("points").notNull().default(0), + pointsType: int("points_type").notNull().default(0), + catalogItemId: int("catalog_item_id").notNull().default(0), + amount: int("amount").notNull().default(1), + limit: int("limit").notNull().default(-1), +}); + +export const YoutubePlaylists = mysqlTable( + "youtube_playlists", + { + itemId: int("item_id").notNull(), + playlistId: varchar("playlist_id", { length: 255 }).notNull(), + order: int("order").notNull(), + }, + (t) => [ + uniqueIndex("youtube_playlists_itemId_playlistId_order").on( + t.itemId, + t.playlistId, + t.order, + ), + ], +); diff --git a/src/lib/admin-list.ts b/src/lib/admin-list.ts index 5d1b8f38..46f942eb 100644 --- a/src/lib/admin-list.ts +++ b/src/lib/admin-list.ts @@ -1,45 +1,22 @@ import { redirect } from "next/navigation"; import { calcPagination, parseListParams } from "./admin-helpers"; import { canAccess, getAdminContext, type PermissionSet } from "./permissions"; -import { prisma } from "./prisma"; - -// biome-ignore lint/suspicious/noExplicitAny: dynamic Prisma model access requires it -type PrismaModel = any; interface AdminListConfig { /** Permission slug required to view this page */ permission: string; - /** Prisma model name (e.g. 'user', 'ban', 'room') */ - model: string; - /** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */ - searchFields: string[]; - /** Default sort when no sort param provided. Default: { id: 'desc' } */ - defaultSort?: Record; /** Default items per page. Default: 20 */ defaultPerPage?: number; - /** Prisma include clause for relations */ - include?: Record; - /** Prisma select clause (mutually exclusive with include) */ - select?: Record; - /** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */ - baseWhere?: Record; /** - * Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives - * the raw string value from the URL and returns the WHERE fragment to merge. - * Returning null/undefined drops the filter. - * - * @example - * filterMap: { - * filter_rank: (v) => ({ rank: { gte: Number(v) } }), - * filter_online: (v) => ({ online: v }), - * } + * Runs the actual data query (Drizzle). Receives parsed list params and + * returns the rows + total count for the current page. */ - filterMap?: Record< - string, - (value: string) => Record | null | undefined - >; - /** Transform raw Prisma row to the shape needed by the table component */ - mapRow?: (row: PrismaModel) => TRow; + fetch: (args: { + search: string; + page: number; + perPage: number; + rawParams: Record; + }) => Promise<{ rows: TRow[]; total: number }>; } interface AdminListResult { @@ -53,35 +30,12 @@ interface AdminListResult { rank: number; } -/** - * Build a nested Prisma WHERE condition from a dot-notation field path. - * e.g. 'owner.username' => { owner: { username: { contains: search } } } - * e.g. 'username' => { username: { contains: search } } - */ -function buildFieldCondition( - fieldPath: string, - search: string, -): Record { - const parts = fieldPath.split("."); - if (parts.length === 1) { - return { [parts[0]]: { contains: search } }; - } - // Build nested object from right to left - let result: Record = { - [parts[parts.length - 1]]: { contains: search }, - }; - for (let i = parts.length - 2; i >= 0; i--) { - result = { [parts[i]]: result }; - } - return result; -} - /** * Generic admin list data fetcher. - * Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction, - * Prisma query with pagination, and row mapping. + * Centralizes the common pattern: auth check, param parsing, pagination and + * row mapping. The caller supplies a Drizzle query via `config.fetch`. */ -export async function fetchAdminList( +export async function fetchAdminList( config: AdminListConfig, paramsPromise: Promise<{ locale: string }>, searchParamsPromise: Promise>, @@ -99,64 +53,15 @@ export async function fetchAdminList( const perPage = config.defaultPerPage ? Number(rawParams.perPage) || config.defaultPerPage : parsed.perPage; - const { search, page, sort, order } = parsed; - // Build WHERE clause - const searchOr = - search && config.searchFields.length > 0 - ? config.searchFields.map((field) => buildFieldCondition(field, search)) - : []; - // Exact numeric id match (furni / rooms / items lookup by id). - if (search && /^\d+$/.test(search)) { - const asId = Number(search); - if (Number.isFinite(asId) && asId > 0) { - searchOr.push({ id: asId }); - } - } - const searchWhere = searchOr.length > 0 ? { OR: searchOr } : {}; + const { rows, total } = await config.fetch({ + search: parsed.search, + page: parsed.page, + perPage, + rawParams, + }); - // Apply URL filter_* params via filterMap - const filterWhere: Record = {}; - if (config.filterMap) { - for (const [paramKey, build] of Object.entries(config.filterMap)) { - const value = rawParams[paramKey]; - if (!value) continue; - const fragment = build(value); - if (fragment) Object.assign(filterWhere, fragment); - } - } - - const where = { - ...(config.baseWhere ?? {}), - ...filterWhere, - ...searchWhere, - }; - - // Build orderBy - const orderBy = sort - ? { [sort]: order } - : (config.defaultSort ?? { id: "desc" as const }); - - // Access Prisma model dynamically - const delegate = (prisma as PrismaModel)[config.model]; - - const queryArgs: PrismaModel = { - where, - orderBy, - skip: (page - 1) * perPage, - take: perPage, - }; - - if (config.include) queryArgs.include = config.include; - if (config.select) queryArgs.select = config.select; - - const [rawRows, total] = await Promise.all([ - delegate.findMany(queryArgs), - delegate.count({ where }), - ]); - - const pagination = calcPagination(total, page, perPage); - const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows; + const pagination = calcPagination(total, parsed.page, perPage); return { rows, diff --git a/src/lib/admin/acl-management-contract.test.ts b/src/lib/admin/acl-management-contract.test.ts index 75ef591a..018169be 100644 --- a/src/lib/admin/acl-management-contract.test.ts +++ b/src/lib/admin/acl-management-contract.test.ts @@ -6,7 +6,7 @@ describe("ACL management contract", () => { const source = readFileSync("src/actions/permissions.ts", "utf8"); expect(source).toContain("PERMS.PERMISSIONS_MANAGE"); expect(source).toContain("adminAction"); - expect(source).toContain("aclModelPermission"); + expect(source).toContain("AclModelPermission"); expect(source).not.toContain("websiteHousekeepingPermissions"); expect(source).not.toContain("websiteTeams"); }); diff --git a/src/lib/admin/log-filters.ts b/src/lib/admin/log-filters.ts index e7944511..a574755e 100644 --- a/src/lib/admin/log-filters.ts +++ b/src/lib/admin/log-filters.ts @@ -1,5 +1,3 @@ -import type { Prisma } from "@/generated/prisma/client"; - export interface StaffActivityFilters { q?: string; staffId?: number | null; @@ -9,8 +7,8 @@ export interface StaffActivityFilters { export function buildStaffActivityWhere( filters: StaffActivityFilters, -): Prisma.StaffActivitiesWhereInput { - const where: Prisma.StaffActivitiesWhereInput = {}; +): any { + const where: any = {}; if (filters.q?.trim()) where.OR = [ { action: { contains: filters.q.trim() } }, diff --git a/src/lib/db-pool.test.ts b/src/lib/db-pool.test.ts new file mode 100644 index 00000000..b133a11b --- /dev/null +++ b/src/lib/db-pool.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { resolveConnectionLimit } from "./db-pool"; + +describe("resolveConnectionLimit", () => { + it("caps the pool during production build phases", () => { + expect(resolveConnectionLimit(40, "phase-production-build")).toBe(5); + expect(resolveConnectionLimit(40, "phase-production-compile")).toBe(5); + expect(resolveConnectionLimit(3, "phase-production-build")).toBe(3); + }); + + it("keeps the configured pool outside build", () => { + expect(resolveConnectionLimit(40, undefined)).toBe(40); + expect(resolveConnectionLimit(40, "phase-development-server")).toBe(40); + }); +}); diff --git a/src/lib/prisma-pool.ts b/src/lib/db-pool.ts similarity index 73% rename from src/lib/prisma-pool.ts rename to src/lib/db-pool.ts index 6aa532d8..ec82b8cf 100644 --- a/src/lib/prisma-pool.ts +++ b/src/lib/db-pool.ts @@ -1,8 +1,8 @@ /** * Cap pool size during `next build` so N workers × pool don't exhaust MySQL. - * Kept free of Prisma/env imports so unit tests can exercise it in isolation. + * Kept free of driver/env imports so unit tests can exercise it in isolation. */ -export function resolvePrismaConnectionLimit( +export function resolveConnectionLimit( poolSize: number, phase = process.env.NEXT_PHASE, ): number { diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 00000000..942349c3 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,62 @@ +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import mysql from "mysql2/promise"; +import * as relations from "@/db/relations"; +import * as schema from "@/db/schema"; +import { env } from "@/env"; +import { resolveConnectionLimit } from "@/lib/db-pool"; + +const fullSchema = { ...schema, ...relations }; + +const globalForDb = globalThis as unknown as { + db?: MySql2Database; +}; + +function createDb(): MySql2Database { + const url = new URL(env.DATABASE_URL); + const isBuild = + process.env.NEXT_PHASE === "phase-production-build" || + process.env.NEXT_PHASE === "phase-production-compile"; + const connectionLimit = resolveConnectionLimit(env.DATABASE_POOL_SIZE); + // Fail fast during SSG so Next can retry/fallback instead of hanging 30s+. + const connectTimeout = isBuild + ? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000) + : env.DATABASE_CONNECT_TIMEOUT_MS; + + const pool = mysql.createPool({ + host: url.hostname, + port: Number(url.port) || 3306, + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: url.pathname.replace(/^\//, ""), + charset: "utf8mb4", + waitForConnections: true, + connectionLimit, + queueLimit: 0, + connectTimeout, + idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, + // Keep BIGINT precision; safe values come back as numbers, huge ones as + // strings (drizzle bigint mode maps both to JS bigint). + supportBigNumbers: true, + }); + + return drizzle(pool, { + schema: fullSchema, + mode: "default", + logger: + env.NODE_ENV === "development" + ? { + logQuery(query, params) { + console.log("[db]", query, params); + }, + } + : false, + }); +} + +export const db = globalForDb.db ?? createDb(); + +if (env.NODE_ENV !== "production") globalForDb.db = db; + +export type Db = MySql2Database; + +export * from "@/db/schema"; diff --git a/src/lib/prisma-facade.ts b/src/lib/prisma-facade.ts new file mode 100644 index 00000000..6d214546 --- /dev/null +++ b/src/lib/prisma-facade.ts @@ -0,0 +1,1053 @@ +import "server-only"; + +import { + and, + asc, + desc, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + like, + lt, + lte, + ne, + not, + notInArray, + or, + SQL, + sql, +} from "drizzle-orm"; +import { createTableRelationsHelpers, One } from "drizzle-orm/relations"; +import * as relationsDefs from "@/db/relations"; +import * as schema from "@/db/schema"; +import { db } from "@/lib/db"; + +type Client = typeof db; + +const FULL = { ...schema, ...relationsDefs } as Record; + +const COLUMNS_SYM = Symbol.for("drizzle:Columns"); + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function lowerFirst(s: string): string { + return s.charAt(0).toLowerCase() + s.slice(1); +} + +function getTable(model: string): any { + const table = FULL[capitalize(model)]; + if (!table) { + throw new Error(`[prisma] unknown model "${model}"`); + } + return table; +} + +function tableName(table: any): string { + return table[Symbol.for("drizzle:Name")] ?? "?"; +} + +function tableColumns(table: any): Record { + return table[COLUMNS_SYM] ?? table; +} + +function getRelations(table: any): Record { + const relDef = FULL[`${lowerFirst(tableName(table))}Relations`]; + if (!relDef) return {}; + return relDef.config(createTableRelationsHelpers(table)); +} + +function coerceValue(col: any, value: any): any { + if (value === null || value === undefined) return value; + if (typeof value === "object" && typeof value.toNumber === "function") { + return Number(value.toNumber()); + } + if (col?.dataType === "bigint") { + if (typeof value === "number") return BigInt(Math.trunc(value)); + if (typeof value === "string" && /^-?\d+$/.test(value)) { + return BigInt(value); + } + } + return value; +} + +function coerceArray(col: any, values: unknown[]): unknown[] { + return values.map((v) => coerceValue(col, v)); +} + +function buildFieldCondition(col: any, value: any): SQL { + if (value === null) return isNull(col); + if (typeof value !== "object") return eq(col, coerceValue(col, value)); + + const parts: SQL[] = []; + for (const [op, v] of Object.entries(value)) { + switch (op) { + case "equals": + parts.push(eq(col, coerceValue(col, v))); + break; + case "not": + if (v === null) parts.push(isNotNull(col)); + else if (typeof v === "object") + parts.push(not(buildFieldCondition(col, v))); + else parts.push(ne(col, coerceValue(col, v))); + break; + case "in": + parts.push(inArray(col, coerceArray(col, v as unknown[]))); + break; + case "notIn": + parts.push(notInArray(col, coerceArray(col, v as unknown[]))); + break; + case "lt": + parts.push(lt(col, coerceValue(col, v))); + break; + case "lte": + parts.push(lte(col, coerceValue(col, v))); + break; + case "gt": + parts.push(gt(col, coerceValue(col, v))); + break; + case "gte": + parts.push(gte(col, coerceValue(col, v))); + break; + case "contains": + parts.push(like(col, `%${v}%`)); + break; + case "startsWith": + parts.push(like(col, `${v}%`)); + break; + case "endsWith": + parts.push(like(col, `%${v}`)); + break; + case "mode": + case "search": + // MySQL collations are case-insensitive by default; full-text not used. + break; + default: + break; + } + } + return parts.length === 1 + ? parts[0] + : (and(...(parts as [SQL, ...SQL[]])) as SQL); +} + +function buildCondition(table: any, where: any): SQL | undefined { + if (where === undefined || where === null) return undefined; + const cols = tableColumns(table); + const conds: SQL[] = []; + + for (const [key, value] of Object.entries(where)) { + if (key === "AND") { + const list = Array.isArray(value) ? value : [value]; + const inner = list + .map((w: any) => buildCondition(table, w)) + .filter((w): w is SQL => Boolean(w)); + if (inner.length) conds.push(and(...(inner as [SQL, ...SQL[]])) as SQL); + } else if (key === "OR") { + const list = Array.isArray(value) ? value : [value]; + const inner = list + .map((w: any) => buildCondition(table, w)) + .filter((w): w is SQL => Boolean(w)); + if (inner.length) conds.push(or(...(inner as [SQL, ...SQL[]])) as SQL); + } else if (key === "NOT") { + const inner = Array.isArray(value) + ? and( + ...value + .map((w: any) => buildCondition(table, w)) + .filter((w): w is SQL => Boolean(w)), + ) + : buildCondition(table, value); + if (inner) conds.push(not(inner)); + } else { + const col = cols[key]; + if (!col) { + // Compound unique key, e.g. `eventId_userId`. + const parts = key.split("_"); + if (parts.length > 1 && parts.every((p) => cols[p])) { + conds.push( + and( + ...(parts.map((p) => + eq( + (cols as Record)[p], + coerceValue( + (cols as Record)[p], + (value as any)?.[p], + ), + ), + ) as [SQL, ...SQL[]]), + ) as SQL, + ); + } else { + throw new Error( + `[prisma] unknown field "${key}" on model "${tableName(table)}"`, + ); + } + continue; + } + conds.push(buildFieldCondition(col, value)); + } + } + + if (conds.length === 0) return undefined; + return conds.length === 1 ? conds[0] : and(...conds); +} + +function buildOrderBy(table: any, orderBy: any): SQL[] { + if (!orderBy) return []; + const entries = Array.isArray(orderBy) ? orderBy : [orderBy]; + const cols = tableColumns(table); + return entries.flatMap((entry: any) => { + if (typeof entry === "string") { + const col = cols[entry]; + return col ? [asc(col)] : []; + } + return Object.entries(entry).flatMap(([key, dir]) => { + if (key === "_count") return []; + const col = cols[key]; + if (!col) return []; + return dir === "desc" ? [desc(col)] : [asc(col)]; + }); + }); +} + +function projection(table: any, select: any): Record | undefined { + if (!select || typeof select !== "object") return undefined; + const cols = tableColumns(table); + const out: Record = {}; + for (const [key, enabled] of Object.entries(select)) { + if (key === "_count") continue; + if (enabled && cols[key]) out[key] = cols[key]; + } + return Object.keys(out).length ? out : undefined; +} + +function pickRow(row: any, select: any): any { + if (!select || typeof select !== "object") return row; + const out: any = {}; + for (const [key, enabled] of Object.entries(select)) { + if (enabled && key in row) out[key] = row[key]; + } + return out; +} + +interface Join { + fkCols: any[]; // columns on the *target* used to filter/group + refCols: any[]; // columns on the *source* used to match +} + +function resolveJoin(table: any, rel: any): Join { + if (rel instanceof One) { + const cfg = rel.config ?? { fields: [], references: [] }; + return { fkCols: cfg.references, refCols: cfg.fields }; + } + if (rel.config?.fields && rel.config.references) { + return { fkCols: rel.config.fields, refCols: rel.config.references }; + } + // Implicit many: find the reverse one-relation on the target. + const targetRels = getRelations(rel.referencedTable); + for (const r of Object.values(targetRels)) { + if (r instanceof One && r.referencedTable === table) { + const cfg = r.config ?? { fields: [], references: [] }; + return { fkCols: cfg.fields, refCols: cfg.references }; + } + } + throw new Error( + `[prisma] cannot resolve join for relation "${rel.fieldName}" on "${tableName(table)}"`, + ); +} + +function colKey(col: any): string { + return col.key ?? col.name; +} + +async function queryRelationRows( + client: Client, + table: any, + rel: any, + opts: any, + keyValues: unknown[], +): Promise> { + const join = resolveJoin(table, rel); + const byCol = join.fkCols[0]; + const condition = inArray(byCol, coerceArray(byCol, keyValues)); + const sel = projection(rel.referencedTable, opts.select); + const orderBy = buildOrderBy(rel.referencedTable, opts.orderBy); + + let stmt: any = sel + ? client.select({ ...sel, __fk: byCol }).from(rel.referencedTable) + : client.select().from(rel.referencedTable); + stmt = stmt.where(condition); + if (orderBy.length) stmt = stmt.orderBy(...orderBy); + + const rows = (await stmt) as any[]; + const map = new Map(); + for (const row of rows) { + const key = row.__fk ?? row[colKey(byCol)]; + map.set(String(key), sel ? pickRow(row, opts.select) : row); + } + return map; +} + +function attachCounts( + rows: any[], + table: any, + rel: any, + counts: Map, +): void { + const join = resolveJoin(table, rel); + const refCol = join.refCols[0]; + for (const row of rows) { + if (!row._count) row._count = {}; + const refValue = row[colKey(refCol)]; + row._count[rel.fieldName] = + refValue == null ? 0 : (counts.get(String(refValue)) ?? 0); + } +} + +async function runCounts( + client: Client, + table: any, + rows: any[], + countSpec: Record, +): Promise { + const rels = getRelations(table); + for (const [relName, enabled] of Object.entries(countSpec)) { + if (!enabled) continue; + const rel = rels[relName]; + if (!rel) continue; + const join = resolveJoin(table, rel); + const refCol = join.refCols[0]; + const values = rows + .map((r) => r[colKey(refCol)]) + .filter((v) => v !== null && v !== undefined); + const counts = new Map(); + if (values.length > 0) { + const byCol = join.fkCols[0]; + const grouped = (await client + .select({ + __fk: byCol, + __c: sql`COUNT(*)`, + }) + .from(rel.referencedTable) + .where(inArray(byCol, coerceArray(byCol, values))) + .groupBy(byCol)) as any[]; + for (const g of grouped) counts.set(String(g.__fk), Number(g.__c)); + } + attachCounts(rows, table, rel, counts); + } +} + +async function attachIncludes( + client: Client, + table: any, + rows: any[], + includes: any, +): Promise { + if (!includes || typeof includes !== "object" || rows.length === 0) return; + const rels = getRelations(table); + + const oneJobs: Array<{ rel: any; opts: any; values: unknown[] }> = []; + const manyJobs: Array<{ rel: any; opts: any; values: unknown[] }> = []; + const countSpec: Record = {}; + + for (const [name, optsRaw] of Object.entries(includes)) { + if (name === "_count") { + if ( + optsRaw !== null && + typeof optsRaw === "object" && + (optsRaw as any).select + ) { + Object.assign(countSpec, (optsRaw as any).select); + } + continue; + } + const rel = rels[name]; + if (!rel) continue; + const opts = optsRaw === true ? {} : (optsRaw ?? {}); + const join = resolveJoin(table, rel); + const refCol = join.refCols[0]; + const values = rows + .map((r) => r[colKey(refCol)]) + .filter((v) => v !== null && v !== undefined); + if (rel instanceof One) { + oneJobs.push({ rel, opts, values }); + } else { + manyJobs.push({ rel, opts, values }); + } + } + + for (const job of oneJobs) { + const { rel, opts, values } = job; + if (values.length === 0) { + for (const row of rows) row[rel.fieldName] = null; + continue; + } + const map = await queryRelationRows(client, table, rel, opts, values); + const join = resolveJoin(table, rel); + const refCol = join.refCols[0]; + for (const row of rows) { + const refValue = row[colKey(refCol)]; + row[rel.fieldName] = + refValue == null ? null : (map.get(String(refValue)) ?? null); + } + } + + for (const job of manyJobs) { + const { rel, opts, values } = job; + if (values.length === 0) { + for (const row of rows) row[rel.fieldName] = []; + continue; + } + const map = await queryRelationRows(client, table, rel, opts, values); + const join = resolveJoin(table, rel); + const refCol = join.refCols[0]; + for (const row of rows) { + const refValue = row[colKey(refCol)]; + row[rel.fieldName] = + refValue == null ? [] : (map.get(String(refValue)) ?? []); + } + } + + if (Object.keys(countSpec).length) { + await runCounts(client, table, rows, countSpec); + } +} + +// ---------- Query execution ---------- + +interface ReadArgs { + where?: any; + select?: any; + include?: any; + orderBy?: any; + take?: number; + skip?: number; +} + +async function runFindMany( + client: Client, + model: string, + args: ReadArgs, +): Promise { + const table = getTable(model); + const condition = buildCondition(table, args.where); + const orderBy = buildOrderBy(table, args.orderBy); + const sel = projection(table, args.select); + + let stmt: any = sel + ? client.select({ ...sel }).from(table) + : client.select().from(table); + if (condition) stmt = stmt.where(condition); + if (orderBy.length) stmt = stmt.orderBy(...orderBy); + if (args.take !== undefined) stmt = stmt.limit(args.take); + else if (args.skip) stmt = stmt.limit(18446744073709551615); + if (args.skip) stmt = stmt.offset(args.skip); + + const rows = (await stmt) as any[]; + + await attachIncludes(client, table, rows, args.include); + + if (args.select && typeof args.select === "object" && args.select._count) { + const countSpec: Record = {}; + for (const [relName, enabled] of Object.entries( + args.select._count.select ?? {}, + )) { + if (enabled) countSpec[relName] = true; + } + if (Object.keys(countSpec).length) { + await runCounts(client, table, rows, countSpec); + } + const projected = rows.map((row) => pickRow(row, args.select)); + return projected; + } + + return rows; +} + +async function runFindOne( + client: Client, + model: string, + args: ReadArgs, + orThrow: boolean, +): Promise { + const rows = await runFindMany(client, model, { ...args, take: 1 }); + if (rows.length === 0) { + if (orThrow) throw new Error(`[prisma] ${model} not found`); + return null; + } + return rows[0]; +} + +// ---------- Mutations ---------- + +function pkColumns(table: any): any[] { + const cols = tableColumns(table); + return Object.values(cols).filter((c) => c.primary); +} + +function coerceData(table: any, data: any): Record { + const cols = tableColumns(table); + const coerced: Record = {}; + for (const [key, value] of Object.entries(data ?? {})) { + coerced[key] = coerceValue(cols[key], value); + } + return coerced; +} + +async function runCreate( + client: Client, + model: string, + args: any, +): Promise { + const table = getTable(model); + const [result] = (await client + .insert(table) + .values(coerceData(table, args.data))) as any; + + const pks = pkColumns(table); + const where: any = {}; + if (pks.length === 1 && pks[0].autoIncrement) { + where[colKey(pks[0])] = result.insertId; + } else { + for (const pk of pks) { + where[colKey(pk)] = args.data?.[colKey(pk)]; + } + } + return runFindOne( + client, + model, + { where, select: args.select, include: args.include }, + false, + ); +} + +async function runUpdate( + client: Client, + model: string, + args: any, +): Promise { + const table = getTable(model); + const condition = buildCondition(table, args.where); + if (condition) { + await client + .update(table) + .set(coerceData(table, args.data)) + .where(condition); + } + return runFindOne( + client, + model, + { where: args.where, select: args.select, include: args.include }, + false, + ); +} + +async function runDelete( + client: Client, + model: string, + args: any, +): Promise { + const table = getTable(model); + const row = await runFindOne(client, model, { where: args.where }, false); + if (!row) return null; + const condition = buildCondition(table, args.where); + if (condition) { + await client.delete(table).where(condition); + } + return row; +} + +async function runDeleteMany( + client: Client, + model: string, + args: any, +): Promise<{ count: number }> { + const table = getTable(model); + const condition = buildCondition(table, args.where); + if (!condition) { + const [result] = (await client.delete(table)) as any; + return { count: Number(result.affectedRows) }; + } + const [result] = (await client.delete(table).where(condition)) as any; + return { count: Number(result.affectedRows) }; +} + +async function runUpdateMany( + client: Client, + model: string, + args: any, +): Promise<{ count: number }> { + const table = getTable(model); + const condition = buildCondition(table, args.where); + const [result] = condition + ? ((await client + .update(table) + .set(coerceData(table, args.data)) + .where(condition)) as any) + : ((await client.update(table).set(coerceData(table, args.data))) as any); + return { count: Number(result.affectedRows) }; +} + +async function runCreateMany( + client: Client, + model: string, + args: any, +): Promise<{ count: number }> { + const table = getTable(model); + const rowsData = Array.isArray(args.data) ? args.data : [args.data]; + const values = rowsData.map((data: any) => coerceData(table, data)); + const [result] = (await client.insert(table).values(values)) as any; + return { count: Number(result.affectedRows) }; +} + +async function runUpsert( + client: Client, + model: string, + args: any, +): Promise { + const existing = await runFindOne( + client, + model, + { where: args.where }, + false, + ); + if (existing) { + return runUpdate(client, model, { + where: args.where, + data: args.update, + select: args.select, + include: args.include, + }); + } + return runCreate(client, model, { + data: args.create, + select: args.select, + include: args.include, + }); +} + +async function runCount( + client: Client, + model: string, + args: any, +): Promise { + const table = getTable(model); + const condition = buildCondition(table, args.where); + let stmt: any = client.select({ __c: sql`COUNT(*)` }).from(table); + if (condition) stmt = stmt.where(condition); + const [row] = (await stmt) as any; + return Number(row?.__c ?? 0); +} + +const AGG_FNS = { + _sum: "SUM", + _min: "MIN", + _max: "MAX", + _avg: "AVG", +} as const; + +async function runAggregate( + client: Client, + model: string, + args: any, +): Promise { + const table = getTable(model); + const cols = tableColumns(table); + const condition = buildCondition(table, args.where); + + const sel: Record = {}; + const meta: Record< + string, + { agg: keyof typeof AGG_FNS; field: string; col: any } + > = {}; + for (const agg of ["_sum", "_min", "_max", "_avg"] as const) { + if (!args[agg]) continue; + for (const [field, enabled] of Object.entries(args[agg])) { + if (!enabled) continue; + const col = cols[field]; + if (!col) continue; + const alias = `__${agg}${field}`; + sel[alias] = sql`${sql.raw(AGG_FNS[agg])}(${col})`; + meta[alias] = { agg, field, col }; + } + } + + const out: Record = {}; + if (Object.keys(sel).length) { + let stmt: any = client.select(sel).from(table); + if (condition) stmt = stmt.where(condition); + if (args.take) stmt = stmt.limit(args.take); + if (args.skip) stmt = stmt.limit(Number.MAX_SAFE_INTEGER).offset(args.skip); + const [row] = (await stmt) as any; + for (const [alias, { agg, field, col }] of Object.entries(meta)) { + if (!out[agg]) out[agg] = {}; + const raw = row?.[alias]; + if (raw === null || raw === undefined) { + out[agg][field] = null; + } else if (col.dataType === "bigint") { + out[agg][field] = BigInt(raw); + } else { + out[agg][field] = Number(raw); + } + } + } + + if (args._count) { + let stmt: any = client.select({ __c: sql`COUNT(*)` }).from(table); + if (condition) stmt = stmt.where(condition); + const [row] = (await stmt) as any; + out._count = Number(row?.__c ?? 0); + } + + return out; +} + +async function runGroupBy( + client: Client, + model: string, + args: any, +): Promise { + const table = getTable(model); + const cols = tableColumns(table); + const condition = buildCondition(table, args.where); + const byCols: any[] = (args.by ?? []).map((f: string) => { + const col = cols[f]; + if (!col) + throw new Error(`[prisma] groupBy unknown field "${f}" on "${model}"`); + return col; + }); + + const sel: Record = {}; + for (const col of byCols) sel[colKey(col)] = col; + + const aggMeta: Record< + string, + Record + > = {}; + for (const agg of ["_count", "_sum", "_min", "_max", "_avg"] as const) { + const spec = args[agg]; + if (!spec) continue; + aggMeta[agg] = {}; + for (const [field, enabled] of Object.entries(spec)) { + if (!enabled) continue; + if (field === "_all") { + const alias = `__${agg}_all`; + sel[alias] = sql`COUNT(*)`; + aggMeta[agg]._all = { alias, all: true }; + continue; + } + const col = cols[field]; + if (!col) continue; + const fn = + agg === "_count" + ? "COUNT" + : agg === "_sum" + ? "SUM" + : agg === "_min" + ? "MIN" + : agg === "_max" + ? "MAX" + : "AVG"; + const alias = `__${agg}${field}`; + sel[alias] = sql`${sql.raw(fn)}(${col})`; + aggMeta[agg][field] = { alias, col, all: false }; + } + } + + let stmt: any = client.select(sel).from(table); + if (condition) stmt = stmt.where(condition); + if (byCols.length) stmt = stmt.groupBy(...byCols); + + if (args.having) { + const havings: SQL[] = []; + for (const [field, ops] of Object.entries(args.having as any)) { + for (const [agg, op] of Object.entries(ops as any)) { + if (agg !== "_count") continue; + const col = cols[field]; + const countExpr = col ? sql`COUNT(${col})` : sql`COUNT(*)`; + for (const [cmp, val] of Object.entries(op as any)) { + if (cmp === "gte") havings.push(sql`${countExpr} >= ${val}`); + else if (cmp === "gt") havings.push(sql`${countExpr} > ${val}`); + else if (cmp === "lte") havings.push(sql`${countExpr} <= ${val}`); + else if (cmp === "lt") havings.push(sql`${countExpr} < ${val}`); + else if (cmp === "equals") havings.push(sql`${countExpr} = ${val}`); + } + } + } + if (havings.length) stmt = stmt.having(...(havings as [SQL])); + } + + if (args.orderBy) { + const entries = Array.isArray(args.orderBy) ? args.orderBy : [args.orderBy]; + const orderSql: SQL[] = []; + for (const entry of entries) { + for (const [field, dir] of Object.entries(entry as any)) { + if (field === "_count") { + for (const [f, d] of Object.entries(dir as any)) { + const col = f === "_all" ? undefined : cols[f]; + const target = col ? sql`COUNT(${col})` : sql`COUNT(*)`; + orderSql.push( + d === "desc" ? desc(target as any) : asc(target as any), + ); + } + } else { + const col = cols[field]; + if (col) orderSql.push(dir === "desc" ? desc(col) : asc(col)); + } + } + } + if (orderSql.length) stmt = stmt.orderBy(...orderSql); + } + + if (args.take !== undefined) stmt = stmt.limit(args.take); + else if (args.skip) stmt = stmt.limit(18446744073709551615); + if (args.skip) stmt = stmt.offset(args.skip); + + const rows = (await stmt) as any[]; + return rows.map((row) => { + const out: any = {}; + for (const col of byCols) out[colKey(col)] = row[colKey(col)]; + for (const [agg, fields] of Object.entries(aggMeta)) { + out[agg] = {}; + for (const [field, { alias, col, all }] of Object.entries(fields)) { + const raw = row[alias]; + if (all) { + out[agg]._all = Number(raw); + } else if (agg === "_count") { + out[agg][field] = Number(raw); + } else if (col?.dataType === "bigint" && raw != null) { + out[agg][field] = BigInt(raw); + } else { + out[agg][field] = raw == null ? null : Number(raw); + } + } + } + return out; + }); +} + +// ---------- Raw queries ---------- + +async function runQuery(client: Client, chunk: SQL): Promise { + const [rows] = (await client.execute(chunk)) as any; + return rows; +} + +async function runExecute(client: Client, chunk: SQL): Promise { + const [result] = (await client.execute(chunk)) as any; + return Number(result?.affectedRows ?? 0); +} + +function toSqlChunk(strings: TemplateStringsArray, values: unknown[]): SQL { + return sql(strings, ...values); +} + +function unsafeSql(query: string, values: unknown[]): SQL { + if (values.length === 0) return sql.raw(query); + const parts = query.split("?"); + if (parts.length !== values.length + 1) { + throw new Error( + `[prisma] $queryRawUnsafe: expected ${parts.length - 1} params, got ${values.length}`, + ); + } + const chunks: SQL[] = [sql.raw(parts[0])]; + for (let i = 0; i < values.length; i++) { + const value = values[i]; + chunks.push(value instanceof SQL ? value : sql`${value}`); + chunks.push(sql.raw(parts[i + 1])); + } + return sql.join(chunks); +} + +// ---------- PrismaPromise (lazy, batchable) ---------- + +class PrismaPromise implements PromiseLike { + constructor( + private readonly runner: (client: Client) => Promise, + private readonly client: Client, + ) {} + + then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return this.runner(this.client).then(onFulfilled, onRejected); + } + + catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null, + ): PromiseLike { + return this.runner(this.client).catch(onRejected); + } + + finally(onFinally?: (() => void) | null): PromiseLike { + return this.runner(this.client).finally(onFinally); + } + + run(client: Client): Promise { + return this.runner(client); + } +} + +function prismaPromise( + runner: (client: Client) => Promise, + client: Client, +): PrismaPromise { + return new PrismaPromise(runner, client); +} + +// ---------- Model delegate ---------- + +function makeDelegate(model: string, client: Client) { + return { + findMany: (args: ReadArgs = {}) => + prismaPromise((c) => runFindMany(c, model, args), client), + findUnique: (args: ReadArgs) => + prismaPromise((c) => runFindOne(c, model, args, false), client), + findUniqueOrThrow: (args: ReadArgs) => + prismaPromise((c) => runFindOne(c, model, args, true), client), + findFirst: (args: ReadArgs = {}) => + prismaPromise((c) => runFindOne(c, model, args, false), client), + findFirstOrThrow: (args: ReadArgs = {}) => + prismaPromise((c) => runFindOne(c, model, args, true), client), + create: (args: any) => + prismaPromise((c) => runCreate(c, model, args), client), + createMany: (args: any) => + prismaPromise((c) => runCreateMany(c, model, args), client), + update: (args: any) => + prismaPromise((c) => runUpdate(c, model, args), client), + updateMany: (args: any) => + prismaPromise((c) => runUpdateMany(c, model, args), client), + delete: (args: any) => + prismaPromise((c) => runDelete(c, model, args), client), + deleteMany: (args: any) => + prismaPromise((c) => runDeleteMany(c, model, args), client), + upsert: (args: any) => + prismaPromise((c) => runUpsert(c, model, args), client), + count: (args: any = {}) => + prismaPromise((c) => runCount(c, model, args), client), + aggregate: (args: any = {}) => + prismaPromise((c) => runAggregate(c, model, args), client), + groupBy: (args: any) => + prismaPromise((c) => runGroupBy(c, model, args), client), + }; +} + +// ---------- Facade ---------- + +interface ModelDelegate { + findMany(args?: any): PrismaPromise; + findUnique(args: any): PrismaPromise; + findUniqueOrThrow(args: any): PrismaPromise; + findFirst(args?: any): PrismaPromise; + findFirstOrThrow(args?: any): PrismaPromise; + create(args: any): PrismaPromise; + createMany(args: any): PrismaPromise<{ count: number }>; + update(args: any): PrismaPromise; + updateMany(args: any): PrismaPromise<{ count: number }>; + delete(args: any): PrismaPromise; + deleteMany(args: any): PrismaPromise<{ count: number }>; + upsert(args: any): PrismaPromise; + count(args?: any): PrismaPromise; + aggregate(args?: any): PrismaPromise; + groupBy(args: any): PrismaPromise; +} + +interface PrismaFacadeSpecific { + $queryRaw: (...args: any[]) => PrismaPromise; + $executeRaw: (...args: any[]) => PrismaPromise; + $queryRawUnsafe: (...args: any[]) => PrismaPromise; + $executeRawUnsafe: (...args: any[]) => PrismaPromise; + $transaction: (arg: any, options?: any) => Promise; + $connect: () => Promise; + $disconnect: () => Promise; + $on: (...args: any[]) => void; + $use: (...args: any[]) => void; +} + +type PrismaFacade = PrismaFacadeSpecific & Record; + +function isSqlChunk(value: any): value is SQL { + return Boolean(value && typeof value === "object" && "queryChunks" in value); +} + +export function makePrisma(client: Client): PrismaFacade { + const methods: Record = { + $queryRaw: (strings: TemplateStringsArray | SQL, ...values: unknown[]) => { + const chunk = isSqlChunk(strings) + ? strings + : toSqlChunk(strings as TemplateStringsArray, values); + return prismaPromise((c) => runQuery(c, chunk), client); + }, + $executeRaw: ( + strings: TemplateStringsArray | SQL, + ...values: unknown[] + ) => { + const chunk = isSqlChunk(strings) + ? strings + : toSqlChunk(strings as TemplateStringsArray, values); + return prismaPromise((c) => runExecute(c, chunk), client); + }, + $queryRawUnsafe: (query: string, ...values: unknown[]) => + prismaPromise((c) => runQuery(c, unsafeSql(query, values)), client), + $executeRawUnsafe: (query: string, ...values: unknown[]) => + prismaPromise((c) => runExecute(c, unsafeSql(query, values)), client), + $transaction: (arg: any, options?: any) => { + if (typeof arg === "function") { + return client.transaction(async (tx) => { + const txClient = tx as unknown as Client; + return arg(makePrisma(txClient)); + }, options); + } + if (Array.isArray(arg)) { + return client.transaction(async (tx) => { + const txClient = tx as unknown as Client; + const results: any[] = []; + for (const op of arg) { + results.push( + op instanceof PrismaPromise + ? await op.run(txClient) + : await (op as any), + ); + } + return results; + }, options); + } + return Promise.resolve(arg); + }, + $connect: async () => {}, + $disconnect: async () => {}, + $on: () => {}, + $use: () => {}, + }; + + return new Proxy(methods, { + get(target, prop) { + if (typeof prop !== "string") return undefined; + if (prop in target) return (target as any)[prop]; + return makeDelegate(prop, client); + }, + }) as PrismaFacade; +} + +export const prisma = makePrisma(db); + +export const sqlHelper = sql; +export const rawHelper = (query: string): SQL => sql.raw(query); +export const joinHelper = (values: unknown[]): SQL => + sql.join( + values.map((v) => (v instanceof SQL ? v : sql`${v}`)), + sql`, `, + ); +export const emptyHelper = sql.raw(""); + +export const Prisma: Record = { + sql: sqlHelper, + raw: rawHelper, + join: joinHelper, + empty: emptyHelper, +}; diff --git a/src/lib/prisma-pool.test.ts b/src/lib/prisma-pool.test.ts deleted file mode 100644 index 8c868619..00000000 --- a/src/lib/prisma-pool.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolvePrismaConnectionLimit } from "./prisma-pool"; - -describe("resolvePrismaConnectionLimit", () => { - it("caps the pool during production build phases", () => { - expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5); - expect(resolvePrismaConnectionLimit(40, "phase-production-compile")).toBe( - 5, - ); - expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3); - }); - - it("keeps the configured pool outside build", () => { - expect(resolvePrismaConnectionLimit(40, undefined)).toBe(40); - expect(resolvePrismaConnectionLimit(40, "phase-development-server")).toBe( - 40, - ); - }); -}); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index dcf0322b..d1665a4a 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,37 +1,6 @@ -import { PrismaMariaDb } from "@prisma/adapter-mariadb"; -import { env } from "@/env"; -import { PrismaClient } from "@/generated/prisma/client"; -import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade"; +import { db } from "@/lib/db"; -const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; - -function createPrismaClient(): PrismaClient { - const url = new URL(env.DATABASE_URL); - const isBuild = - process.env.NEXT_PHASE === "phase-production-build" || - process.env.NEXT_PHASE === "phase-production-compile"; - const connectionLimit = resolvePrismaConnectionLimit(env.DATABASE_POOL_SIZE); - // Fail fast during SSG so Next can retry/fallback instead of hanging 30s+. - const connectTimeout = isBuild - ? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000) - : env.DATABASE_CONNECT_TIMEOUT_MS; - const adapter = new PrismaMariaDb({ - host: url.hostname, - port: Number(url.port) || 3306, - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - connectionLimit, - connectTimeout, - acquireTimeout: connectTimeout, - idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, - }); - return new PrismaClient({ - adapter, - log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], - }); -} - -export const prisma = globalForPrisma.prisma ?? createPrismaClient(); - -if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; +export const prisma = makePrisma(db) as unknown as PrismaClient; +export { PrismaHelper as Prisma }; diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index 0462ce4a..5afa0f87 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; -import { Prisma } from "@/generated/prisma/client"; -import { prisma } from "@/lib/prisma"; +import { asc, sql } from "drizzle-orm"; +import { CatalogPages, db } from "@/lib/db"; import { getFurnitureDataPath } from "@/lib/services/furni-data"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; @@ -139,11 +139,11 @@ export async function loadCatalogItemsData( // Load items via raw query to work around pageId Int vs VARCHAR mismatch const pageIdStr = String(pageId); // CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int. - const rawItems = await prisma.$queryRaw>>` + const [rawItems] = (await db.execute(sql` SELECT * FROM catalog_items WHERE CAST(page_id AS CHAR) = ${pageIdStr} ORDER BY order_number ASC, id ASC - `; + `)) as unknown as [Record[], unknown[]]; const items: RawItem[] = rawItems.map((r: Record) => ({ id: Number(r.id), itemIds: String(r.item_ids ?? ""), @@ -164,13 +164,18 @@ export async function loadCatalogItemsData( })); const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([ - prisma.catalogPages.findMany({ - orderBy: { caption: "asc" }, - select: { id: true, caption: true }, - }), - prisma.$queryRaw>` + db + .select({ id: CatalogPages.id, caption: CatalogPages.caption }) + .from(CatalogPages) + .orderBy(asc(CatalogPages.caption)), + db + .execute(sql` SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC - `, + `) + .then( + (result) => + (result as unknown as [{ interaction_type: string }[], unknown[]])[0], + ), getHabboGamedataHotel(), ]); @@ -192,46 +197,46 @@ export async function loadCatalogItemsData( ]; // Raw query to work around ENUM('0','1') columns returning numeric values. - // baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join. - const baseItems: BaseItem[] = + // baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join. + const [baseItemRows] = baseItemIds.length > 0 - ? ( - await prisma.$queryRaw>>` + ? ((await db.execute(sql` SELECT id, sprite_id, public_name, item_name, type, width, length, stack_height, allow_stack, allow_sit, allow_lay, allow_walk, allow_gift, allow_trade, allow_recycle, allow_marketplace_sell, allow_inventory_stack, interaction_type, interaction_modes_count, vending_ids, customparams, effect_id_male, effect_id_female, clothing_on_walk - FROM items_base WHERE id IN (${Prisma.join(baseItemIds)}) - ` - ).map((r) => ({ - id: Number(r.id), - spriteId: Number(r.sprite_id), - publicName: String(r.public_name ?? ""), - itemName: String(r.item_name ?? ""), - type: String(r.type ?? "s"), - width: Number(r.width) || 1, - length: Number(r.length) || 1, - stackHeight: Number(r.stack_height) || 0, - allowStack: Number(r.allow_stack ?? 0), - allowSit: Number(r.allow_sit ?? 0), - allowLay: Number(r.allow_lay ?? 0), - allowWalk: Number(r.allow_walk ?? 0), - allowGift: Number(r.allow_gift ?? 1), - allowTrade: Number(r.allow_trade ?? 1), - allowRecycle: Number(r.allow_recycle ?? 0), - allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0), - allowInventoryStack: Number(r.allow_inventory_stack ?? 1), - interactionType: String(r.interaction_type ?? "default"), - interactionModesCount: Number(r.interaction_modes_count) || 2, - vendingIds: String(r.vending_ids ?? "0"), - customparams: String(r.customparams ?? ""), - effectIdMale: Number(r.effect_id_male) || 0, - effectIdFemale: Number(r.effect_id_female) || 0, - clothingOnWalk: String(r.clothing_on_walk ?? ""), - })) - : []; + FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)}) + `)) as unknown as [Record[], unknown[]]) + : [[], []]; + + const baseItems: BaseItem[] = baseItemRows.map((r) => ({ + id: Number(r.id), + spriteId: Number(r.sprite_id), + publicName: String(r.public_name ?? ""), + itemName: String(r.item_name ?? ""), + type: String(r.type ?? "s"), + width: Number(r.width) || 1, + length: Number(r.length) || 1, + stackHeight: Number(r.stack_height) || 0, + allowStack: Number(r.allow_stack ?? 0), + allowSit: Number(r.allow_sit ?? 0), + allowLay: Number(r.allow_lay ?? 0), + allowWalk: Number(r.allow_walk ?? 0), + allowGift: Number(r.allow_gift ?? 1), + allowTrade: Number(r.allow_trade ?? 1), + allowRecycle: Number(r.allow_recycle ?? 0), + allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0), + allowInventoryStack: Number(r.allow_inventory_stack ?? 1), + interactionType: String(r.interaction_type ?? "default"), + interactionModesCount: Number(r.interaction_modes_count) || 2, + vendingIds: String(r.vending_ids ?? "0"), + customparams: String(r.customparams ?? ""), + effectIdMale: Number(r.effect_id_male) || 0, + effectIdFemale: Number(r.effect_id_female) || 0, + clothingOnWalk: String(r.clothing_on_walk ?? ""), + })); const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b])); diff --git a/src/lib/services/catalog-tree.test.ts b/src/lib/services/catalog-tree.test.ts index 52b18427..ab80d5e3 100644 --- a/src/lib/services/catalog-tree.test.ts +++ b/src/lib/services/catalog-tree.test.ts @@ -1,27 +1,41 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const findUnique = vi.hoisted(() => vi.fn()); -const findMany = vi.hoisted(() => vi.fn()); -const update = vi.hoisted(() => vi.fn()); -const create = vi.hoisted(() => vi.fn()); -const queryRaw = vi.hoisted(() => vi.fn()); -const executeRaw = vi.hoisted(() => vi.fn()); -const catalogDelete = vi.hoisted(() => vi.fn()); -const catalogUpdateMany = vi.hoisted(() => vi.fn()); +const insertValues = vi.hoisted(() => vi.fn()); +const selectLimit = vi.hoisted(() => vi.fn()); +const updateSetData = vi.hoisted(() => vi.fn()); +const updateWhere = vi.hoisted(() => vi.fn()); +const deleteWhere = vi.hoisted(() => vi.fn()); +const executeMock = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/prisma", () => ({ - prisma: { - catalogPages: { - findUnique, - findMany, - update, - create, - delete: catalogDelete, - updateMany: catalogUpdateMany, - }, - $queryRaw: queryRaw, - $executeRaw: executeRaw, +vi.mock("@/lib/db", () => ({ + db: { + insert: () => ({ values: insertValues }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve(selectLimit()), + }), + orderBy: () => ({ + where: () => ({ + limit: () => Promise.resolve(selectLimit()), + }), + }), + }), + }), + update: () => ({ + set: (data: Record) => { + updateSetData(data); + return { + where: () => updateWhere(data), + }; + }, + }), + delete: () => ({ + where: () => Promise.resolve(deleteWhere()), + }), + execute: executeMock, }, + CatalogPages: { id: "CatalogPages.id", parentId: "CatalogPages.parentId" }, })); vi.mock("@/env", () => ({ env: {} })); @@ -121,10 +135,10 @@ describe("buildNestedTree", () => { describe("createPage", () => { it("creates page with default values", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue([{ insertId: 1 }]); await createPage({ parentId: 0, caption: "Test" }); - const data = create.mock.calls[0][0].data; + const data = insertValues.mock.calls[0][0]; expect(data.caption).toBe("Test"); expect(data.pageLayout).toBe("default_3x3"); expect(data.visible).toBe("1"); @@ -134,57 +148,51 @@ describe("createPage", () => { }); it("truncates captionSave to 25 chars", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue([{ insertId: 1 }]); await createPage({ parentId: 0, caption: "a".repeat(50) }); - expect(create.mock.calls[0][0].data.captionSave).toBe("a".repeat(25)); + expect(insertValues.mock.calls[0][0].captionSave).toBe("a".repeat(25)); }); it("converts boolean visible/enabled to string", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue([{ insertId: 1 }]); await createPage({ parentId: 0, caption: "x", visible: false, enabled: false, }); - expect(create.mock.calls[0][0].data.visible).toBe("0"); - expect(create.mock.calls[0][0].data.enabled).toBe("0"); + expect(insertValues.mock.calls[0][0].visible).toBe("0"); + expect(insertValues.mock.calls[0][0].enabled).toBe("0"); }); }); describe("movePage", () => { it("throws on circular hierarchy", async () => { - findUnique.mockResolvedValue({ parentId: 1 }); + selectLimit.mockResolvedValue([{ parentId: 1 }]); await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy"); }); it("allows valid move to root", async () => { - update.mockResolvedValue({}); + updateWhere.mockResolvedValue([{ affectedRows: 1 }]); await movePage(1, 0); - expect(update).toHaveBeenCalledWith({ - where: { id: 1 }, - data: { parentId: 0 }, - }); + expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 }); }); it("allows valid move to other parent", async () => { - findUnique.mockResolvedValue({ parentId: 0 }); - update.mockResolvedValue({}); + selectLimit.mockResolvedValue([{ parentId: 0 }]); + updateWhere.mockResolvedValue([{ affectedRows: 1 }]); await movePage(1, 2); - expect(update).toHaveBeenCalledWith({ - where: { id: 1 }, - data: { parentId: 2 }, - }); + expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 }); }); }); describe("getAncestors", () => { it("returns breadcrumb from root to page", async () => { - findUnique - .mockResolvedValueOnce({ id: 3, caption: "Grandchild", parentId: 2 }) - .mockResolvedValueOnce({ id: 2, caption: "Child", parentId: 1 }) - .mockResolvedValueOnce({ id: 1, caption: "Root", parentId: 0 }); + selectLimit + .mockResolvedValueOnce([{ id: 3, caption: "Grandchild", parentId: 2 }]) + .mockResolvedValueOnce([{ id: 2, caption: "Child", parentId: 1 }]) + .mockResolvedValueOnce([{ id: 1, caption: "Root", parentId: 0 }]); const ancestors = await getAncestors(3); expect(ancestors).toHaveLength(3); @@ -194,22 +202,19 @@ describe("getAncestors", () => { }); it("stops when page not found", async () => { - findUnique.mockResolvedValue(null); + selectLimit.mockResolvedValue([]); expect(await getAncestors(999)).toEqual([]); }); }); describe("deletePage", () => { it("reparent mode moves children to parent", async () => { - findUnique.mockResolvedValue({ parentId: 1 }); - catalogDelete.mockResolvedValue({}); - catalogUpdateMany.mockResolvedValue({ count: 2 }); + selectLimit.mockResolvedValue([{ parentId: 1 }]); + updateWhere.mockResolvedValue([{ affectedRows: 2 }]); + deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); const result = await deletePage(5, "reparent"); expect(result).toEqual({ deletedPages: 1, movedChildren: 2 }); - expect(catalogUpdateMany).toHaveBeenCalledWith({ - where: { parentId: 5 }, - data: { parentId: 1 }, - }); + expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 }); }); }); diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index 4922a060..c44292ad 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -1,5 +1,5 @@ -import { Prisma } from "@/generated/prisma/client"; -import { prisma } from "@/lib/prisma"; +import { asc, eq, inArray, sql } from "drizzle-orm"; +import { CatalogPages, db } from "@/lib/db"; export type { NestedTreeNode, TreeNode } from "@/types/catalog"; @@ -23,25 +23,27 @@ export async function getCatalogItemCounts( if (pageIds) { const idStrs = pageIds.map(String); - const rows = await prisma.$queryRaw< - Array<{ page_id: string | number; cnt: number | bigint }> - >` + const [rows] = (await db.execute(sql` SELECT page_id, COUNT(*) as cnt FROM catalog_items - WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)}) + WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)}) GROUP BY page_id - `; + `)) as unknown as [ + { page_id: string | number; cnt: number | bigint }[], + unknown[], + ]; for (const r of rows) { map.set(toInt(r.page_id), toInt(r.cnt)); } return map; } - const rows = await prisma.$queryRaw< - Array<{ page_id: string | number; cnt: number | bigint }> - >` + const [rows] = (await db.execute(sql` SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id - `; + `)) as unknown as [ + { page_id: string | number; cnt: number | bigint }[], + unknown[], + ]; for (const r of rows) { map.set(toInt(r.page_id), toInt(r.cnt)); } @@ -55,9 +57,10 @@ export async function getCatalogItemCounts( * Get the full catalog tree as a flat list with depth info. */ export async function getTreeFlat(): Promise { - const allPages = await prisma.catalogPages.findMany({ - orderBy: { orderNum: "asc" }, - }); + const allPages = await db + .select() + .from(CatalogPages) + .orderBy(asc(CatalogPages.orderNum)); const itemCountMap = await getCatalogItemCounts(); const childCountMap = new Map(); @@ -69,15 +72,23 @@ export async function getTreeFlat(): Promise { const pageMap = new Map(allPages.map((p) => [p.id, p])); const depthCache = new Map(); - function computeDepth(pageId: number): number { + function computeDepth(pageId: number, visiting = new Set()): number { if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0; - const page = pageMap.get(pageId); - const parentId = page ? toInt(page.parentId, -1) : -1; - if (!page || parentId <= 0) { + // Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing. + if (visiting.has(pageId)) { depthCache.set(pageId, 0); return 0; } - const d = computeDepth(parentId) + 1; + visiting.add(pageId); + const page = pageMap.get(pageId); + const parentId = page ? toInt(page.parentId, -1) : -1; + if (!page || parentId <= 0) { + visiting.delete(pageId); + depthCache.set(pageId, 0); + return 0; + } + const d = computeDepth(parentId, visiting) + 1; + visiting.delete(pageId); depthCache.set(pageId, d); return d; } @@ -154,24 +165,22 @@ export async function createPage(data: { : "0" : (data.enabled ?? "1"); - return prisma.catalogPages.create({ - data: { - caption: data.caption, - parentId: data.parentId, - pageLayout: data.pageLayout ?? "default_3x3", - captionSave: (data.captionSave ?? data.caption).slice(0, 25), - iconColor: data.iconColor ?? 0, - iconImage: data.iconImage ?? 0, - minRank: data.minRank ?? 1, - orderNum: data.orderNum ?? 0, - visible, - enabled, - clubOnly: "0", - vipOnly: "0", - pageHeadline: "", - pageTeaser: "", - includes: "", - }, + return db.insert(CatalogPages).values({ + caption: data.caption, + parentId: data.parentId, + pageLayout: data.pageLayout ?? "default_3x3", + captionSave: (data.captionSave ?? data.caption).slice(0, 25), + iconColor: data.iconColor ?? 0, + iconImage: data.iconImage ?? 0, + minRank: data.minRank ?? 1, + orderNum: data.orderNum ?? 0, + visible, + enabled, + clubOnly: "0", + vipOnly: "0", + pageHeadline: "", + pageTeaser: "", + includes: "", }); } @@ -189,19 +198,20 @@ export async function movePage( if (currentId === pageId) { throw new Error("Cannot move page: would create a circular hierarchy"); } - const parent = await prisma.catalogPages.findUnique({ - where: { id: currentId }, - select: { parentId: true }, - }); + const [parent] = await db + .select({ parentId: CatalogPages.parentId }) + .from(CatalogPages) + .where(eq(CatalogPages.id, currentId)) + .limit(1); if (!parent || parent.parentId <= 0) break; currentId = parent.parentId; } } - await prisma.catalogPages.update({ - where: { id: pageId }, - data: { parentId: newParentId }, - }); + await db + .update(CatalogPages) + .set({ parentId: newParentId }) + .where(eq(CatalogPages.id, pageId)); } /** @@ -211,10 +221,10 @@ export async function movePage( async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise { if (pageIds.length === 0) return; const idStrs = pageIds.map(String); - await prisma.$executeRaw` + await db.execute(sql` DELETE FROM catalog_items - WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)}) - `; + WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)}) + `); } /** @@ -224,22 +234,23 @@ export async function deletePage( pageId: number, mode: "cascade" | "reparent" = "reparent", ): Promise<{ deletedPages: number; movedChildren: number }> { - const page = await prisma.catalogPages.findUnique({ - where: { id: pageId }, - select: { parentId: true }, - }); + const [page] = await db + .select({ parentId: CatalogPages.parentId }) + .from(CatalogPages) + .where(eq(CatalogPages.id, pageId)) + .limit(1); if (!page) return { deletedPages: 0, movedChildren: 0 }; if (mode === "reparent") { - const result = await prisma.catalogPages.updateMany({ - where: { parentId: pageId }, - data: { parentId: page.parentId }, - }); + const [result] = await db + .update(CatalogPages) + .set({ parentId: page.parentId }) + .where(eq(CatalogPages.parentId, pageId)); await deleteCatalogItemsByPageIds([pageId]); - await prisma.catalogPages.delete({ where: { id: pageId } }); + await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId)); - return { deletedPages: 1, movedChildren: result.count }; + return { deletedPages: 1, movedChildren: result.affectedRows }; } const deleted = await cascadeDelete(pageId); @@ -251,10 +262,10 @@ async function cascadeDelete(pageId: number): Promise { const queue: number[] = [pageId]; while (queue.length > 0) { - const children = await prisma.catalogPages.findMany({ - where: { parentId: { in: queue } }, - select: { id: true }, - }); + const children = await db + .select({ id: CatalogPages.id }) + .from(CatalogPages) + .where(inArray(CatalogPages.parentId, queue)); queue.length = 0; for (const child of children) { toDelete.push(child.id); @@ -264,7 +275,7 @@ async function cascadeDelete(pageId: number): Promise { await deleteCatalogItemsByPageIds(toDelete); for (let i = toDelete.length - 1; i >= 0; i--) { - await prisma.catalogPages.delete({ where: { id: toDelete[i] } }); + await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i])); } return toDelete.length; @@ -279,10 +290,15 @@ export async function getAncestors( const ancestors: Array<{ id: number; caption: string }> = []; let currentId = pageId; for (let i = 0; i < 50; i++) { - const page = await prisma.catalogPages.findUnique({ - where: { id: currentId }, - select: { id: true, caption: true, parentId: true }, - }); + const [page] = await db + .select({ + id: CatalogPages.id, + caption: CatalogPages.caption, + parentId: CatalogPages.parentId, + }) + .from(CatalogPages) + .where(eq(CatalogPages.id, currentId)) + .limit(1); if (!page) break; ancestors.unshift({ id: page.id, caption: page.caption }); if (page.parentId <= 0) break; diff --git a/src/lib/services/permission-ranks.ts b/src/lib/services/permission-ranks.ts index aaa89dec..bb1e60e6 100644 --- a/src/lib/services/permission-ranks.ts +++ b/src/lib/services/permission-ranks.ts @@ -1,4 +1,6 @@ -import { Prisma } from "@/generated/prisma/client"; +import { type SQL, sql } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; +import type { Db } from "@/lib/db"; const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/; @@ -41,11 +43,18 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary { permissionMaxValues: Record; } -type RawDb = { - $queryRaw: (query: Prisma.Sql) => Promise; - $executeRaw: (query: Prisma.Sql) => Promise; - $transaction: (queries: Promise[]) => Promise; -}; +/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */ +type RawDb = Pick; + +async function rawRows(db: RawDb, query: SQL | string): Promise { + const [rows] = await db.execute(query); + return rows as T; +} + +async function rawExec(db: RawDb, query: SQL | string): Promise { + const [result] = await db.execute(query); + return (result as ResultSetHeader).affectedRows; +} export function getRankPermissionColumn(rankId: number) { if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) { @@ -79,8 +88,9 @@ export function splitRankUpdateFields(fields: Record) { export async function fetchEmulatorRankSummaries( db: RawDb, ): Promise { - const rows = await db.$queryRaw[]>( - Prisma.sql` + const rows = await rawRows[]>( + db, + sql` SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank FROM permission_ranks ORDER BY id ASC @@ -102,8 +112,9 @@ export async function fetchEmulatorRankForEdit( db: RawDb, rankId: number, ): Promise { - const rankRows = await db.$queryRaw[]>( - Prisma.sql` + const rankRows = await rawRows[]>( + db, + sql` SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank, log_commands, room_effect, auto_credits_amount, auto_pixels_amount, auto_gotw_amount, auto_points_amount @@ -114,15 +125,16 @@ export async function fetchEmulatorRankForEdit( if (rankRows.length === 0) return null; const rankColumn = getRankPermissionColumn(rankId); - const permissionRows = await db.$queryRaw< + const permissionRows = await rawRows< { permission_key: string; max_value: number; value: number | string | null; }[] >( - Prisma.sql` - SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value + db, + sql` + SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value FROM permission_definitions ORDER BY permission_key ASC `, @@ -159,40 +171,41 @@ export async function createEmulatorRank( db: RawDb, data: { rank_name: string; level: number }, ): Promise { - const rows = await db.$queryRaw<{ next_id: number }[]>( - Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`, + const rows = await rawRows<{ next_id: number }[]>( + db, + sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`, ); const id = Number(rows[0]?.next_id ?? 1); await ensurePermissionRankColumn(db, id); await resetPermissionRankColumn(db, id); - await db.$transaction([ - db.$executeRaw( - Prisma.sql` + await db.transaction(async (tx) => { + await rawExec( + tx, + sql` INSERT INTO permission_ranks (id, rank_name, level) VALUES (${id}, ${data.rank_name}, ${data.level}) `, - ), - db.$executeRaw( - Prisma.sql` + ); + await rawExec( + tx, + sql` INSERT INTO permissions (id, rank_name, level) VALUES (${id}, ${data.rank_name}, ${data.level}) ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level) `, - ), - ]); + ); + }); return id; } export async function deleteEmulatorRank(db: RawDb, rankId: number) { await resetPermissionRankColumn(db, rankId); - await db.$transaction([ - db.$executeRaw( - Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`, - ), - db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`), - ]); + await db.transaction(async (tx) => { + await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`); + await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`); + }); } export async function updateEmulatorRank( @@ -220,13 +233,13 @@ async function updateTableFields( fields: Record, ) { const assignments = Object.entries(fields).map( - ([key, value]) => - Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`, + ([key, value]) => sql`${sql.raw(quoteIdentifier(key))} = ${value}`, ); - return db.$executeRaw( - Prisma.sql` - UPDATE ${Prisma.raw(quoteIdentifier(table))} - SET ${Prisma.join(assignments, ", ")} + return rawExec( + db, + sql` + UPDATE ${sql.raw(quoteIdentifier(table))} + SET ${sql.join(assignments, sql`, `)} WHERE id = ${rankId} `, ); @@ -240,19 +253,20 @@ async function updatePermissionDefinitionValues( const rankColumn = getRankPermissionColumn(rankId); const entries = Object.entries(permissionFields); const cases = entries.map( - ([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`, + ([key, value]) => sql`WHEN ${key} THEN LEAST(${value}, max_value)`, ); const keys = entries.map(([key]) => key); - return db.$executeRaw( - Prisma.sql` + return rawExec( + db, + sql` UPDATE permission_definitions - SET ${Prisma.raw(quoteIdentifier(rankColumn))} = + SET ${sql.raw(quoteIdentifier(rankColumn))} = CASE permission_key - ${Prisma.join(cases, " ")} - ELSE ${Prisma.raw(quoteIdentifier(rankColumn))} + ${sql.join(cases, sql` `)} + ELSE ${sql.raw(quoteIdentifier(rankColumn))} END - WHERE permission_key IN (${Prisma.join(keys)}) + WHERE permission_key IN (${sql.join(keys, sql`, `)}) `, ); } @@ -272,8 +286,9 @@ async function updateLegacyPermissionFields( async function ensurePermissionRankColumn(db: RawDb, rankId: number) { const rankColumn = getRankPermissionColumn(rankId); - const exists = await db.$queryRaw<{ c: bigint | number }[]>( - Prisma.sql` + const exists = await rawRows<{ c: number }[]>( + db, + sql` SELECT COUNT(*) AS c FROM information_schema.columns WHERE table_schema = DATABASE() @@ -283,27 +298,30 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) { ); if (Number(exists[0]?.c ?? 0) > 0) return; - await db.$executeRaw( - Prisma.sql` + await rawExec( + db, + sql` ALTER TABLE permission_definitions - ADD COLUMN ${Prisma.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0 + ADD COLUMN ${sql.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0 `, ); } async function resetPermissionRankColumn(db: RawDb, rankId: number) { const rankColumn = getRankPermissionColumn(rankId); - await db.$executeRaw( - Prisma.sql` + await rawExec( + db, + sql` UPDATE permission_definitions - SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0 + SET ${sql.raw(quoteIdentifier(rankColumn))} = 0 `, ); } async function getTableColumns(db: RawDb, table: string) { - const rows = await db.$queryRaw<{ column_name: string }[]>( - Prisma.sql` + const rows = await rawRows<{ column_name: string }[]>( + db, + sql` SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() diff --git a/src/types/prisma.ts b/src/types/prisma.ts deleted file mode 100644 index 1eeca222..00000000 --- a/src/types/prisma.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Ban, User, UsersCurrency } from "@/generated/prisma/client"; - -export type UserWithCurrencies = User & { currencies: UsersCurrency[] }; -export type UserWithPermission = User & { permission: { rankName: string } }; -export type UserPublicPreview = Pick< - User, - "id" | "username" | "look" | "motto" ->; -export interface NotificationRow { - id: number; - userId: number; - type: string; - title: string; - message: string; - link: string | null; - readAt: string | null; - createdAt: string; -} -export type BanWithStaff = Ban & { staff: Pick };