refactor: replace Prisma ORM runtime with Drizzle ORM facade
- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
This commit is contained in:
1 parent
9d1c71d926
commit
56061e41d4
52 files changed
+5779
-702
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# EpicNext-CMS v1.0.1
|
# EpicNext-CMS v1.0.1
|
||||||
|
|
||||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||||
|
|
||||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||||
|
|
||||||
@@ -37,7 +37,9 @@ The CMS shares a database with the Polaris/Arcturus emulator. Use an existing da
|
|||||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them.
|
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
||||||
|
|
||||||
|
> **Note:** The CMS does **not** own the database schema — it maps to tables that are managed by the emulator. All Drizzle schema definitions use `drizzle-orm`'s runtime mapping (no `drizzle-kit push/migrate` is ever run against the emulator schema). CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL files in `prisma/migrations/`.
|
||||||
|
|
||||||
### 3. Configure Environment
|
### 3. Configure Environment
|
||||||
|
|
||||||
@@ -56,13 +58,21 @@ APP_URL=http://localhost:3000
|
|||||||
|
|
||||||
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
||||||
|
|
||||||
### 4. Generate Prisma Client
|
### 4. Type Generation (Optional for Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates TypeScript types in `src/generated/prisma/`.
|
Generates TypeScript types in `src/generated/prisma/`. This is only needed for developer type-checking of the `prisma` facade — it does not add any Prisma runtime overhead in production. The facade (`src/lib/prisma-facade.ts`) routes all queries through Drizzle ORM at runtime.
|
||||||
|
|
||||||
|
Alternatively, you can work with Drizzle directly via the typed schema in `src/db/schema.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
const result = await db.select().from(users).where(eq(users.username, "hello"));
|
||||||
|
```
|
||||||
|
|
||||||
### 5. Run CMS Migrations
|
### 5. Run CMS Migrations
|
||||||
|
|
||||||
@@ -161,20 +171,21 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only
|
||||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
│ └── migrations/ # 16 SQL migrations for CMS tables
|
||||||
├── scripts/
|
|
||||||
│ ├── apply-migrations.ts # Custom migration runner
|
|
||||||
│ ├── jobs-worker.ts # Background task scheduler
|
|
||||||
│ └── sql-statements.ts # SQL parsing utilities
|
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
|
||||||
|
│ │ └── migrations/ # Drizzle migration files (if used)
|
||||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||||
│ ├── actions/ # Server Actions
|
│ ├── actions/ # Server Actions
|
||||||
│ ├── components/ # UI components
|
│ ├── components/ # UI components
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||||
│ │ ├── prisma.ts # Database connection singleton
|
│ │ ├── prisma.ts # Prisma-compatible facade (routes to Drizzle at runtime)
|
||||||
|
│ │ ├── prisma-facade.ts # Drizzle-backed implementation of Prisma API surface
|
||||||
|
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||||
│ │ ├── redis.ts # Redis client (ioredis)
|
│ │ ├── redis.ts # Redis client (ioredis)
|
||||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||||
│ │ ├── cache.ts # In-memory cache fallback
|
│ │ ├── cache.ts # In-memory cache fallback
|
||||||
@@ -198,6 +209,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
||||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||||
|
|
||||||
|
### ORM Architecture
|
||||||
|
|
||||||
|
The CMS uses a dual-layer approach:
|
||||||
|
|
||||||
|
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton connected to the MySQL/MariaDB database. All new code should use this directly. The schema is defined in `src/db/schema.ts` with 176 tables typed against the existing database columns.
|
||||||
|
- **Legacy Compatibility (Prisma Facade)**: `@/lib/prisma` provides a Prisma-compatible API surface backed by Drizzle. This allows existing code to continue working without refactoring. The facade (`@/lib/prisma-facade.ts`) implements the Prisma client API (`findMany`, `findUnique`, `create`, `$transaction`, `$queryRaw`, etc.) but routes all queries through Drizzle at runtime — **no Prisma client engine or query engine is loaded in production**.
|
||||||
|
- **Type Generation**: `src/generated/prisma/` (regenerated via `pnpm prisma:generate`) exists solely for TypeScript type-checking. It is `gitignore`d and is never bundled in the production build.
|
||||||
|
|
||||||
|
Migration path: new database access should use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`. The facade is maintained for backwards compatibility but is not recommended for new code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Optional Integrations
|
## Optional Integrations
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
// The schema is generated by scripts/generate-drizzle-schema.mjs from
|
||||||
|
// prisma/schema.prisma + live DB introspection. This DB is shared live with the
|
||||||
|
// Arcturus emulator, so we NEVER run `drizzle-kit migrate`/`push` against it —
|
||||||
|
// CMS-only schema changes stay in prisma/migrations/*.sql via `pnpm db:migrate`.
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "mysql",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? "",
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
+3
-2
@@ -37,14 +37,13 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@prisma/adapter-mariadb": "^7.9.1",
|
|
||||||
"@prisma/client": "^7.9.1",
|
|
||||||
"@sentry/nextjs": "^10.69.0",
|
"@sentry/nextjs": "^10.69.0",
|
||||||
"@tanstack/react-virtual": "^3.14.9",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"isomorphic-dompurify": "^3.20.0",
|
"isomorphic-dompurify": "^3.20.0",
|
||||||
@@ -76,6 +75,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@next/bundle-analyzer": "^16.2.12",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
|
"@prisma/client": "^7.9.1",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.30.0",
|
"knip": "^6.30.0",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^17.3.0",
|
||||||
|
|||||||
Generated
+640
-53
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { $Enums } from "@/generated/prisma/client";
|
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
type BanType = $Enums.bans_type;
|
|
||||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||||
"account",
|
"account",
|
||||||
"ip",
|
"ip",
|
||||||
@@ -45,7 +43,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
banExpire,
|
banExpire,
|
||||||
banReason: reason,
|
banReason: reason,
|
||||||
type: type as BanType,
|
type: type as any,
|
||||||
cfhTopic: -1,
|
cfhTopic: -1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -58,7 +57,7 @@ export async function updateBcPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await prisma.catalogPagesBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.CatalogPagesBcUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -104,7 +103,7 @@ export async function updateBcItem({
|
|||||||
}
|
}
|
||||||
await prisma.catalogItemsBc.update({
|
await prisma.catalogItemsBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safe as Prisma.CatalogItemsBcUpdateInput,
|
data: safe as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -252,7 +252,7 @@ export async function moveCatalogItems({
|
|||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
UPDATE catalog_items
|
UPDATE catalog_items
|
||||||
SET page_id = ${pageIdStr}
|
SET page_id = ${pageIdStr}
|
||||||
WHERE id IN (${Prisma.join(ids)})
|
WHERE id IN (${sql.join(ids, sql`, `)})
|
||||||
`;
|
`;
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
@@ -301,7 +301,7 @@ export async function updateCatalogItem({
|
|||||||
if (Object.keys(safeCatalog).length > 0) {
|
if (Object.keys(safeCatalog).length > 0) {
|
||||||
await prisma.catalogItems.update({
|
await prisma.catalogItems.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safeCatalog as Prisma.CatalogItemsUpdateInput,
|
data: safeCatalog as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (baseItem) {
|
if (baseItem) {
|
||||||
@@ -309,7 +309,7 @@ export async function updateCatalogItem({
|
|||||||
if (Object.keys(safeBase).length > 0) {
|
if (Object.keys(safeBase).length > 0) {
|
||||||
await prisma.itemsBase.update({
|
await prisma.itemsBase.update({
|
||||||
where: { id: baseItem.id },
|
where: { id: baseItem.id },
|
||||||
data: safeBase as Prisma.ItemsBaseUpdateInput,
|
data: safeBase as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -57,7 +56,7 @@ export async function updateCatalogPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPages.update({
|
await prisma.catalogPages.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.CatalogPagesUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -90,7 +89,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
// between the two operations cannot orphan the user.
|
// between the two operations cannot orphan the user.
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx: Prisma.TransactionClient) => {
|
async (tx) => {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { credits: { decrement: price } },
|
data: { credits: { decrement: price } },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
@@ -92,12 +91,12 @@ export const updateItemsBase = adminAction(
|
|||||||
if (data[key] !== undefined) data[key] = Number(data[key]);
|
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||||
}
|
}
|
||||||
if (data.stackHeight !== undefined) {
|
if (data.stackHeight !== undefined) {
|
||||||
data.stackHeight = new Prisma.Decimal(String(data.stackHeight));
|
data.stackHeight = Number(data.stackHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.itemsBase.update({
|
await prisma.itemsBase.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.ItemsBaseUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog().catch(() => false);
|
await rcon.updateCatalog().catch(() => false);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
+74
-47
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -17,7 +16,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
await prisma.items.update({
|
await prisma.items.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
data: data as Prisma.ItemsUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -111,7 +110,7 @@ export async function updateRoom({
|
|||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await prisma.rooms.update({
|
await prisma.rooms.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.RoomsUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
|
|||||||
+1
-2
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -116,7 +115,7 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
const badgeCodes = parseBadgeCodes(article.badges);
|
const badgeCodes = parseBadgeCodes(article.badges);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -81,10 +80,14 @@ export const createUser = adminAction(
|
|||||||
return actionOk({ id: user.id, username: user.username });
|
return actionOk({ id: user.id, username: user.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"code" in err &&
|
||||||
err.code === "P2002"
|
err.code === "P2002"
|
||||||
) {
|
) {
|
||||||
const target = (err.meta?.target as string[]) ?? [];
|
const target = (
|
||||||
|
(err as { meta?: { target?: string[] } }).meta?.target as string[]
|
||||||
|
) ?? [];
|
||||||
if (target.includes("username"))
|
if (target.includes("username"))
|
||||||
throw new ActionError("Username already taken");
|
throw new ActionError("Username already taken");
|
||||||
if (target.includes("mail"))
|
if (target.includes("mail"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/lib/prisma";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { count, desc, eq, like } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { EventsTable } from "./events-table";
|
import { EventsTable } from "./events-table";
|
||||||
|
|
||||||
@@ -16,29 +18,44 @@ export default async function EventsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.EVENTS_VIEW,
|
permission: PERMS.EVENTS_VIEW,
|
||||||
model: "websiteEvent",
|
fetch: async ({ search, page, perPage }) => {
|
||||||
searchFields: ["title"],
|
const [rows, totals] = await Promise.all([
|
||||||
defaultSort: { startsAt: "desc" },
|
db
|
||||||
include: { type: { select: { name: true, color: true } } },
|
.select({
|
||||||
mapRow: (e: {
|
id: WebsiteEvent.id,
|
||||||
id: number;
|
title: WebsiteEvent.title,
|
||||||
title: string;
|
startsAt: WebsiteEvent.startsAt,
|
||||||
type: { name: string; color: string };
|
endsAt: WebsiteEvent.endsAt,
|
||||||
startsAt: Date;
|
status: WebsiteEvent.status,
|
||||||
endsAt: Date | null;
|
hostUserId: WebsiteEvent.hostUserId,
|
||||||
status: string;
|
typeName: WebsiteEventType.name,
|
||||||
hostUserId: number;
|
typeColor: WebsiteEventType.color,
|
||||||
_count?: { registrations: number };
|
})
|
||||||
}) => ({
|
.from(WebsiteEvent)
|
||||||
|
.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,
|
id: e.id,
|
||||||
title: e.title,
|
title: e.title,
|
||||||
typeName: e.type.name,
|
typeName: e.typeName ?? "",
|
||||||
typeColor: e.type.color,
|
typeColor: e.typeColor ?? "",
|
||||||
startsAt: e.startsAt,
|
startsAt: e.startsAt,
|
||||||
endsAt: e.endsAt,
|
endsAt: e.endsAt,
|
||||||
status: e.status,
|
status: e.status,
|
||||||
hostUserId: e.hostUserId,
|
hostUserId: e.hostUserId,
|
||||||
}),
|
})),
|
||||||
|
total: totals[0]?.total ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
@@ -25,7 +24,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || "open";
|
const statusFilter = raw.filter_status || "open";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
conditions.push({ open: true });
|
||||||
@@ -56,7 +55,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
const where: any =
|
||||||
conditions.length === 0
|
conditions.length === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -65,7 +64,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
@@ -79,7 +78,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { canAccess, PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
||||||
|
|
||||||
@@ -15,32 +17,56 @@ export default async function ItemsBasePage({
|
|||||||
const result = await fetchAdminList<ItemsBaseRow>(
|
const result = await fetchAdminList<ItemsBaseRow>(
|
||||||
{
|
{
|
||||||
permission: PERMS.CATALOG_VIEW,
|
permission: PERMS.CATALOG_VIEW,
|
||||||
model: "itemsBase",
|
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||||
searchFields: ["itemName", "publicName"],
|
const conditions = [];
|
||||||
defaultSort: { id: "desc" },
|
if (search) {
|
||||||
select: {
|
const ors = [
|
||||||
id: true,
|
like(ItemsBase.itemName, `%${search}%`),
|
||||||
spriteId: true,
|
like(ItemsBase.publicName, `%${search}%`),
|
||||||
publicName: true,
|
];
|
||||||
itemName: true,
|
const asId = Number(search);
|
||||||
type: true,
|
if (Number.isFinite(asId) && asId > 0) {
|
||||||
interactionType: true,
|
ors.push(eq(ItemsBase.id, asId));
|
||||||
width: true,
|
}
|
||||||
length: true,
|
conditions.push(or(...ors));
|
||||||
|
}
|
||||||
|
if (rawParams.filter_type) {
|
||||||
|
conditions.push(eq(ItemsBase.type, rawParams.filter_type));
|
||||||
|
}
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
const [rows, totals] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: ItemsBase.id,
|
||||||
|
spriteId: ItemsBase.spriteId,
|
||||||
|
publicName: ItemsBase.publicName,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
type: ItemsBase.type,
|
||||||
|
interactionType: ItemsBase.interactionType,
|
||||||
|
width: ItemsBase.width,
|
||||||
|
length: ItemsBase.length,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(ItemsBase.id))
|
||||||
|
.limit(perPage)
|
||||||
|
.offset((page - 1) * perPage),
|
||||||
|
db.select({ total: count() }).from(ItemsBase).where(where),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
spriteId: r.spriteId,
|
||||||
|
publicName: r.publicName || "",
|
||||||
|
itemName: r.itemName || "",
|
||||||
|
type: r.type || "s",
|
||||||
|
interactionType: r.interactionType || "default",
|
||||||
|
width: r.width,
|
||||||
|
length: r.length,
|
||||||
|
})),
|
||||||
|
total: totals[0]?.total ?? 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
filterMap: {
|
|
||||||
filter_type: (v) => ({ type: v }),
|
|
||||||
},
|
|
||||||
mapRow: (r) => ({
|
|
||||||
id: r.id as number,
|
|
||||||
spriteId: r.spriteId as number,
|
|
||||||
publicName: (r.publicName as string) || "",
|
|
||||||
itemName: (r.itemName as string) || "",
|
|
||||||
type: (r.type as string) || "s",
|
|
||||||
interactionType: (r.interactionType as string) || "default",
|
|
||||||
width: r.width as number,
|
|
||||||
length: r.length as number,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
@@ -24,7 +23,7 @@ export default async function CfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
conditions.push({ state: stateFilter });
|
||||||
}
|
}
|
||||||
@@ -59,7 +58,7 @@ export default async function CfhListPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.SupportTicketsWhereInput =
|
const where: any =
|
||||||
conditions.length === 0
|
conditions.length === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -68,7 +67,7 @@ export default async function CfhListPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.SupportTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
@@ -82,7 +81,7 @@ export default async function CfhListPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { and, count, desc, eq, like, or, type SQL } from "drizzle-orm";
|
||||||
import { Wifi } from "lucide-react";
|
import { Wifi } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { OnlineTable } from "./online-table";
|
import { OnlineTable } from "./online-table";
|
||||||
|
|
||||||
@@ -15,15 +17,34 @@ export default async function OnlineUsersPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_VIEW,
|
permission: PERMS.USERS_VIEW,
|
||||||
model: "user",
|
|
||||||
searchFields: ["username", "motto"],
|
|
||||||
baseWhere: { online: "1" },
|
|
||||||
defaultPerPage: 50,
|
defaultPerPage: 50,
|
||||||
select: {
|
fetch: async ({ search, page, perPage }) => {
|
||||||
id: true,
|
const conditions: (SQL | undefined)[] = [eq(User.online, "1")];
|
||||||
username: true,
|
if (search) {
|
||||||
motto: true,
|
conditions.push(
|
||||||
look: true,
|
or(
|
||||||
|
like(User.username, `%${search}%`),
|
||||||
|
like(User.motto, `%${search}%`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = and(...conditions);
|
||||||
|
const [rows, totals] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: User.id,
|
||||||
|
username: User.username,
|
||||||
|
motto: User.motto,
|
||||||
|
look: User.look,
|
||||||
|
})
|
||||||
|
.from(User)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(User.id))
|
||||||
|
.limit(perPage)
|
||||||
|
.offset((page - 1) * perPage),
|
||||||
|
db.select({ total: count() }).from(User).where(where),
|
||||||
|
]);
|
||||||
|
return { rows, total: totals[0]?.total ?? 0 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { db } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||||
import { PermissionsList } from "./permissions-list";
|
import { PermissionsList } from "./permissions-list";
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ export default async function PermissionsPage({
|
|||||||
const sp = (await searchParams) ?? {};
|
const sp = (await searchParams) ?? {};
|
||||||
const fromHousekeeping = sp.from === "housekeeping";
|
const fromHousekeeping = sp.from === "housekeeping";
|
||||||
|
|
||||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
const ranks = await fetchEmulatorRankSummaries(db);
|
||||||
|
|
||||||
// User counts per rank
|
// User counts per rank
|
||||||
const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw(
|
const [rawCounts] = (await db.execute(
|
||||||
Prisma.sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
||||||
);
|
)) as unknown as [{ rank: number; cnt: number }[], unknown[]];
|
||||||
const countMap: Record<number, number> = {};
|
const countMap: Record<number, number> = {};
|
||||||
for (const row of rawCounts) {
|
for (const row of rawCounts) {
|
||||||
countMap[Number(row.rank)] = Number(row.cnt);
|
countMap[Number(row.rank)] = Number(row.cnt);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { count, desc, like } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, WebsitePoll } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { PollsTable } from "./polls-table";
|
import { PollsTable } from "./polls-table";
|
||||||
|
|
||||||
@@ -16,28 +18,31 @@ export default async function PollsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.POLLS_VIEW,
|
permission: PERMS.POLLS_VIEW,
|
||||||
model: "websitePoll",
|
fetch: async ({ search, page, perPage }) => {
|
||||||
searchFields: ["title"],
|
const where = search
|
||||||
defaultSort: { createdAt: "desc" },
|
? like(WebsitePoll.title, `%${search}%`)
|
||||||
mapRow: (p: {
|
: undefined;
|
||||||
id: number;
|
const [rows, totals] = await Promise.all([
|
||||||
title: string;
|
db
|
||||||
status: string;
|
.select({
|
||||||
showResults: number;
|
id: WebsitePoll.id,
|
||||||
multipleChoice: number;
|
title: WebsitePoll.title,
|
||||||
startsAt: Date | null;
|
status: WebsitePoll.status,
|
||||||
endsAt: Date | null;
|
showResults: WebsitePoll.showResults,
|
||||||
createdAt: Date;
|
multipleChoice: WebsitePoll.multipleChoice,
|
||||||
}) => ({
|
startsAt: WebsitePoll.startsAt,
|
||||||
id: p.id,
|
endsAt: WebsitePoll.endsAt,
|
||||||
title: p.title,
|
createdAt: WebsitePoll.createdAt,
|
||||||
status: p.status,
|
})
|
||||||
showResults: p.showResults,
|
.from(WebsitePoll)
|
||||||
multipleChoice: p.multipleChoice,
|
.where(where)
|
||||||
startsAt: p.startsAt,
|
.orderBy(desc(WebsitePoll.createdAt))
|
||||||
endsAt: p.endsAt,
|
.limit(perPage)
|
||||||
createdAt: p.createdAt,
|
.offset((page - 1) * perPage),
|
||||||
}),
|
db.select({ total: count() }).from(WebsitePoll).where(where),
|
||||||
|
]);
|
||||||
|
return { rows, total: totals[0]?.total ?? 0 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||||
import { DoorOpen } from "lucide-react";
|
import { DoorOpen } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, Rooms } from "@/lib/db";
|
||||||
import { canAccess, PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { RoomsTable } from "./rooms-table";
|
import { RoomsTable } from "./rooms-table";
|
||||||
|
|
||||||
@@ -15,33 +17,56 @@ export default async function RoomsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.ROOMS_VIEW,
|
permission: PERMS.ROOMS_VIEW,
|
||||||
// Prisma model is `Rooms` → client accessor `rooms`.
|
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||||
// Owner is denormalized on the row (ownerId / ownerName), not a relation.
|
const conditions = [];
|
||||||
model: "rooms",
|
if (search) {
|
||||||
searchFields: ["name", "ownerName"],
|
const ors = [
|
||||||
select: {
|
like(Rooms.name, `%${search}%`),
|
||||||
id: true,
|
like(Rooms.ownerName, `%${search}%`),
|
||||||
name: true,
|
];
|
||||||
ownerId: true,
|
const asId = Number(search);
|
||||||
ownerName: true,
|
if (Number.isFinite(asId) && asId > 0) {
|
||||||
state: true,
|
ors.push(eq(Rooms.id, asId));
|
||||||
users: true,
|
}
|
||||||
usersMax: true,
|
conditions.push(or(...ors));
|
||||||
description: true,
|
}
|
||||||
|
if (rawParams.filter_state) {
|
||||||
|
conditions.push(eq(Rooms.state, rawParams.filter_state));
|
||||||
|
}
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
const [rows, totals] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: Rooms.id,
|
||||||
|
name: Rooms.name,
|
||||||
|
ownerId: Rooms.ownerId,
|
||||||
|
ownerName: Rooms.ownerName,
|
||||||
|
state: Rooms.state,
|
||||||
|
users: Rooms.users,
|
||||||
|
usersMax: Rooms.usersMax,
|
||||||
|
description: Rooms.description,
|
||||||
|
})
|
||||||
|
.from(Rooms)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(Rooms.id))
|
||||||
|
.limit(perPage)
|
||||||
|
.offset((page - 1) * perPage),
|
||||||
|
db.select({ total: count() }).from(Rooms).where(where),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
ownerName: r.ownerName || "—",
|
||||||
|
ownerId: r.ownerId ?? 0,
|
||||||
|
state: r.state,
|
||||||
|
users: r.users,
|
||||||
|
usersMax: r.usersMax,
|
||||||
|
description: r.description || "",
|
||||||
|
})),
|
||||||
|
total: totals[0]?.total ?? 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
filterMap: {
|
|
||||||
filter_state: (v) => ({ state: v }),
|
|
||||||
},
|
|
||||||
mapRow: (r) => ({
|
|
||||||
id: r.id as number,
|
|
||||||
name: r.name as string,
|
|
||||||
ownerName: (r.ownerName as string) || "—",
|
|
||||||
ownerId: (r.ownerId as number) ?? 0,
|
|
||||||
state: r.state as string,
|
|
||||||
users: r.users as number,
|
|
||||||
usersMax: r.usersMax as number,
|
|
||||||
description: (r.description as string) || "",
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
paramsPromise as Promise<{ locale: string }>,
|
paramsPromise as Promise<{ locale: string }>,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
@@ -25,7 +24,7 @@ export default async function AdminTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || raw.status || "active";
|
const statusFilter = raw.filter_status || raw.status || "active";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "active") {
|
if (statusFilter === "active") {
|
||||||
conditions.push({ status: { not: "closed" } });
|
conditions.push({ status: { not: "closed" } });
|
||||||
@@ -47,14 +46,14 @@ export default async function AdminTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.WebsiteTicketWhereInput =
|
const where: any =
|
||||||
conditions.length === 0
|
conditions.length === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
: { AND: conditions };
|
: { AND: conditions };
|
||||||
|
|
||||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
const SORT_MAP: Record<string, any> =
|
||||||
{
|
{
|
||||||
subject: { subject: "asc" },
|
subject: { subject: "asc" },
|
||||||
status: { status: "asc" },
|
status: { status: "asc" },
|
||||||
@@ -68,7 +67,7 @@ export default async function AdminTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
@@ -36,7 +35,7 @@ function formatAmount(amount: number, currency: string): string {
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.WebsitePaypalTransactionsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
amount: { amount: "desc" },
|
amount: { amount: "desc" },
|
||||||
@@ -61,7 +60,7 @@ export default async function AdminTransactions({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status ?? "";
|
const statusFilter = raw.filter_status ?? "";
|
||||||
|
|
||||||
const where: Prisma.WebsitePaypalTransactionsWhereInput = {};
|
const where: any = {};
|
||||||
|
|
||||||
if (statusFilter === "paid") {
|
if (statusFilter === "paid") {
|
||||||
where.status = { in: [...PAID_STATUS] };
|
where.status = { in: [...PAID_STATUS] };
|
||||||
@@ -113,7 +112,7 @@ export default async function AdminTransactions({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
let transactions: Awaited<
|
let transactions: Awaited<
|
||||||
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
|
import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination } from "@/lib/admin-helpers";
|
import { calcPagination } from "@/lib/admin-helpers";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -30,21 +29,21 @@ export default async function EmulatorTranslationsPage({
|
|||||||
const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200);
|
const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200);
|
||||||
const page = Math.max(parseInt(sp.page || "1", 10), 1);
|
const page = Math.max(parseInt(sp.page || "1", 10), 1);
|
||||||
|
|
||||||
const patternFilters: Prisma.EmulatorSettingsWhereInput[] =
|
const patternFilters: any[] =
|
||||||
TRANSLATION_KEY_PATTERNS.map((p) => ({
|
TRANSLATION_KEY_PATTERNS.map((p) => ({
|
||||||
key: { contains: p.replace(/%/g, "") },
|
key: { contains: p.replace(/%/g, "") },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters };
|
const baseFilter: any = { OR: patternFilters };
|
||||||
|
|
||||||
const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter];
|
const conditions: any[] = [baseFilter];
|
||||||
if (group) conditions.push({ key: { startsWith: `${group}.` } });
|
if (group) conditions.push({ key: { startsWith: `${group}.` } });
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push({
|
conditions.push({
|
||||||
OR: [{ key: { contains: search } }, { value: { contains: search } }],
|
OR: [{ key: { contains: search } }, { value: { contains: search } }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions };
|
const where: any = { AND: conditions };
|
||||||
|
|
||||||
const [settings, total, allKeys] = await Promise.all([
|
const [settings, total, allKeys] = await Promise.all([
|
||||||
prisma.emulatorSettings.findMany({
|
prisma.emulatorSettings.findMany({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/lib/prisma";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getWatchedUserIds } from "@/lib/services/watch";
|
import { getWatchedUserIds } from "@/lib/services/watch";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { and, count, desc, eq, gte, like, or } from "drizzle-orm";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
import { canAccess, PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { CreateUserDialog } from "./_components/create-user-dialog";
|
import { CreateUserDialog } from "./_components/create-user-dialog";
|
||||||
import { UsersTable } from "./users-table";
|
import { UsersTable } from "./users-table";
|
||||||
@@ -16,30 +18,54 @@ export default async function UsersPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_VIEW,
|
permission: PERMS.USERS_VIEW,
|
||||||
model: "user",
|
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||||
searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
|
const conditions = [];
|
||||||
select: {
|
if (search) {
|
||||||
id: true,
|
const ors = [
|
||||||
username: true,
|
like(User.username, `%${search}%`),
|
||||||
mail: true,
|
like(User.mail, `%${search}%`),
|
||||||
rank: true,
|
like(User.ipRegister, `%${search}%`),
|
||||||
credits: true,
|
like(User.ipCurrent, `%${search}%`),
|
||||||
pixels: true,
|
];
|
||||||
online: true,
|
const asId = Number(search);
|
||||||
motto: true,
|
if (Number.isFinite(asId) && asId > 0) {
|
||||||
look: true,
|
ors.push(eq(User.id, asId));
|
||||||
ipRegister: true,
|
}
|
||||||
accountCreated: true,
|
conditions.push(or(...ors));
|
||||||
lastLogin: true,
|
}
|
||||||
},
|
const rank = Number(rawParams.filter_rank);
|
||||||
filterMap: {
|
if (Number.isFinite(rank)) {
|
||||||
filter_rank: (v) => {
|
|
||||||
const n = Number(v);
|
|
||||||
if (!Number.isFinite(n)) return null;
|
|
||||||
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
||||||
return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
|
conditions.push(rank >= 7 ? gte(User.rank, 7) : eq(User.rank, rank));
|
||||||
},
|
}
|
||||||
filter_online: (v) => ({ online: v }),
|
if (rawParams.filter_online) {
|
||||||
|
conditions.push(eq(User.online, rawParams.filter_online));
|
||||||
|
}
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
const [rows, totals] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: User.id,
|
||||||
|
username: User.username,
|
||||||
|
mail: User.mail,
|
||||||
|
rank: User.rank,
|
||||||
|
credits: User.credits,
|
||||||
|
pixels: User.pixels,
|
||||||
|
online: User.online,
|
||||||
|
motto: User.motto,
|
||||||
|
look: User.look,
|
||||||
|
ipRegister: User.ipRegister,
|
||||||
|
accountCreated: User.accountCreated,
|
||||||
|
lastLogin: User.lastLogin,
|
||||||
|
})
|
||||||
|
.from(User)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(User.id))
|
||||||
|
.limit(perPage)
|
||||||
|
.offset((page - 1) * perPage),
|
||||||
|
db.select({ total: count() }).from(User).where(where),
|
||||||
|
]);
|
||||||
|
return { rows, total: totals[0]?.total ?? 0 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
|||||||
interface UserRow {
|
interface UserRow {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
mail: string;
|
mail: string | null;
|
||||||
rank: number;
|
rank: number;
|
||||||
credits: number;
|
credits: number;
|
||||||
pixels: number;
|
pixels: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/lib/prisma";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiError, apiOk } from "@/lib/api-response";
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/lib/prisma";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { bearerUserId } from "@/lib/api-auth";
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
|
import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
@@ -20,7 +19,7 @@ export default async function ModCfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
conditions.push({ state: stateFilter });
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,7 @@ export default async function ModCfhListPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.SupportTicketsWhereInput =
|
const where: any =
|
||||||
conditions.length === 0
|
conditions.length === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -64,7 +63,7 @@ export default async function ModCfhListPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.SupportTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
@@ -78,7 +77,7 @@ export default async function ModCfhListPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "@/app/admin/help-tickets/help-tickets-table";
|
} from "@/app/admin/help-tickets/help-tickets-table";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -25,7 +24,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || "open";
|
const statusFilter = raw.filter_status || "open";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
conditions.push({ open: true });
|
||||||
@@ -56,7 +55,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
const where: any =
|
||||||
conditions.length === 0
|
conditions.length === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
@@ -65,7 +64,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<
|
||||||
string,
|
string,
|
||||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
any
|
||||||
> = {
|
> = {
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
@@ -79,7 +78,7 @@ export default async function ModHelpTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "@/app/admin/tickets/tickets-table";
|
} from "@/app/admin/tickets/tickets-table";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -25,7 +24,7 @@ export default async function ModTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || raw.status || "active";
|
const statusFilter = raw.filter_status || raw.status || "active";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "active") {
|
if (statusFilter === "active") {
|
||||||
conditions.push({ status: { not: "closed" } });
|
conditions.push({ status: { not: "closed" } });
|
||||||
@@ -47,14 +46,14 @@ export default async function ModTicketsPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.WebsiteTicketWhereInput =
|
const where: any =
|
||||||
conditions.length === 0
|
conditions.length === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
: { AND: conditions };
|
: { AND: conditions };
|
||||||
|
|
||||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
const SORT_MAP: Record<string, any> =
|
||||||
{
|
{
|
||||||
subject: { subject: "asc" },
|
subject: { subject: "asc" },
|
||||||
status: { status: "asc" },
|
status: { status: "asc" },
|
||||||
@@ -68,7 +67,7 @@ export default async function ModTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+2569
File diff suppressed because it is too large.
Load diff
+18
-113
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ describe("ACL management contract", () => {
|
|||||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||||
expect(source).toContain("adminAction");
|
expect(source).toContain("adminAction");
|
||||||
expect(source).toContain("aclModelPermission");
|
expect(source).toContain("AclModelPermission");
|
||||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||||
expect(source).not.toContain("websiteTeams");
|
expect(source).not.toContain("websiteTeams");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
export interface StaffActivityFilters {
|
export interface StaffActivityFilters {
|
||||||
q?: string;
|
q?: string;
|
||||||
staffId?: number | null;
|
staffId?: number | null;
|
||||||
@@ -9,8 +7,8 @@ export interface StaffActivityFilters {
|
|||||||
|
|
||||||
export function buildStaffActivityWhere(
|
export function buildStaffActivityWhere(
|
||||||
filters: StaffActivityFilters,
|
filters: StaffActivityFilters,
|
||||||
): Prisma.StaffActivitiesWhereInput {
|
): any {
|
||||||
const where: Prisma.StaffActivitiesWhereInput = {};
|
const where: any = {};
|
||||||
if (filters.q?.trim())
|
if (filters.q?.trim())
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ action: { contains: filters.q.trim() } },
|
{ action: { contains: filters.q.trim() } },
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveConnectionLimit } from "./db-pool";
|
||||||
|
|
||||||
|
describe("resolveConnectionLimit", () => {
|
||||||
|
it("caps the pool during production build phases", () => {
|
||||||
|
expect(resolveConnectionLimit(40, "phase-production-build")).toBe(5);
|
||||||
|
expect(resolveConnectionLimit(40, "phase-production-compile")).toBe(5);
|
||||||
|
expect(resolveConnectionLimit(3, "phase-production-build")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the configured pool outside build", () => {
|
||||||
|
expect(resolveConnectionLimit(40, undefined)).toBe(40);
|
||||||
|
expect(resolveConnectionLimit(40, "phase-development-server")).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Cap pool size during `next build` so N workers × pool don't exhaust MySQL.
|
* Cap pool size during `next build` so N workers × pool don't exhaust MySQL.
|
||||||
* Kept free of Prisma/env imports so unit tests can exercise it in isolation.
|
* Kept free of driver/env imports so unit tests can exercise it in isolation.
|
||||||
*/
|
*/
|
||||||
export function resolvePrismaConnectionLimit(
|
export function resolveConnectionLimit(
|
||||||
poolSize: number,
|
poolSize: number,
|
||||||
phase = process.env.NEXT_PHASE,
|
phase = process.env.NEXT_PHASE,
|
||||||
): number {
|
): number {
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import * as relations from "@/db/relations";
|
||||||
|
import * as schema from "@/db/schema";
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { resolveConnectionLimit } from "@/lib/db-pool";
|
||||||
|
|
||||||
|
const fullSchema = { ...schema, ...relations };
|
||||||
|
|
||||||
|
const globalForDb = globalThis as unknown as {
|
||||||
|
db?: MySql2Database<typeof fullSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDb(): MySql2Database<typeof fullSchema> {
|
||||||
|
const url = new URL(env.DATABASE_URL);
|
||||||
|
const isBuild =
|
||||||
|
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||||
|
process.env.NEXT_PHASE === "phase-production-compile";
|
||||||
|
const connectionLimit = resolveConnectionLimit(env.DATABASE_POOL_SIZE);
|
||||||
|
// Fail fast during SSG so Next can retry/fallback instead of hanging 30s+.
|
||||||
|
const connectTimeout = isBuild
|
||||||
|
? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000)
|
||||||
|
: env.DATABASE_CONNECT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: url.hostname,
|
||||||
|
port: Number(url.port) || 3306,
|
||||||
|
user: decodeURIComponent(url.username),
|
||||||
|
password: decodeURIComponent(url.password),
|
||||||
|
database: url.pathname.replace(/^\//, ""),
|
||||||
|
charset: "utf8mb4",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit,
|
||||||
|
queueLimit: 0,
|
||||||
|
connectTimeout,
|
||||||
|
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||||
|
// Keep BIGINT precision; safe values come back as numbers, huge ones as
|
||||||
|
// strings (drizzle bigint mode maps both to JS bigint).
|
||||||
|
supportBigNumbers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return drizzle(pool, {
|
||||||
|
schema: fullSchema,
|
||||||
|
mode: "default",
|
||||||
|
logger:
|
||||||
|
env.NODE_ENV === "development"
|
||||||
|
? {
|
||||||
|
logQuery(query, params) {
|
||||||
|
console.log("[db]", query, params);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = globalForDb.db ?? createDb();
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== "production") globalForDb.db = db;
|
||||||
|
|
||||||
|
export type Db = MySql2Database<typeof fullSchema>;
|
||||||
|
|
||||||
|
export * from "@/db/schema";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,19 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { resolvePrismaConnectionLimit } from "./prisma-pool";
|
|
||||||
|
|
||||||
describe("resolvePrismaConnectionLimit", () => {
|
|
||||||
it("caps the pool during production build phases", () => {
|
|
||||||
expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5);
|
|
||||||
expect(resolvePrismaConnectionLimit(40, "phase-production-compile")).toBe(
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the configured pool outside build", () => {
|
|
||||||
expect(resolvePrismaConnectionLimit(40, undefined)).toBe(40);
|
|
||||||
expect(resolvePrismaConnectionLimit(40, "phase-development-server")).toBe(
|
|
||||||
40,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+5
-36
@@ -1,37 +1,6 @@
|
|||||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { env } from "@/env";
|
import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { db } from "@/lib/db";
|
||||||
import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool";
|
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
export const prisma = makePrisma(db) as unknown as PrismaClient;
|
||||||
|
export { PrismaHelper as Prisma };
|
||||||
function createPrismaClient(): PrismaClient {
|
|
||||||
const url = new URL(env.DATABASE_URL);
|
|
||||||
const isBuild =
|
|
||||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
|
||||||
process.env.NEXT_PHASE === "phase-production-compile";
|
|
||||||
const connectionLimit = resolvePrismaConnectionLimit(env.DATABASE_POOL_SIZE);
|
|
||||||
// Fail fast during SSG so Next can retry/fallback instead of hanging 30s+.
|
|
||||||
const connectTimeout = isBuild
|
|
||||||
? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000)
|
|
||||||
: env.DATABASE_CONNECT_TIMEOUT_MS;
|
|
||||||
const adapter = new PrismaMariaDb({
|
|
||||||
host: url.hostname,
|
|
||||||
port: Number(url.port) || 3306,
|
|
||||||
user: decodeURIComponent(url.username),
|
|
||||||
password: decodeURIComponent(url.password),
|
|
||||||
database: url.pathname.replace(/^\//, ""),
|
|
||||||
connectionLimit,
|
|
||||||
connectTimeout,
|
|
||||||
acquireTimeout: connectTimeout,
|
|
||||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
return new PrismaClient({
|
|
||||||
adapter,
|
|
||||||
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
|
||||||
|
|
||||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { asc, sql } from "drizzle-orm";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||||
|
|
||||||
@@ -139,11 +139,11 @@ export async function loadCatalogItemsData(
|
|||||||
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||||
const pageIdStr = String(pageId);
|
const pageIdStr = String(pageId);
|
||||||
// CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int.
|
// CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int.
|
||||||
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
const [rawItems] = (await db.execute(sql`
|
||||||
SELECT * FROM catalog_items
|
SELECT * FROM catalog_items
|
||||||
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
||||||
ORDER BY order_number ASC, id ASC
|
ORDER BY order_number ASC, id ASC
|
||||||
`;
|
`)) as unknown as [Record<string, unknown>[], unknown[]];
|
||||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||||
id: Number(r.id),
|
id: Number(r.id),
|
||||||
itemIds: String(r.item_ids ?? ""),
|
itemIds: String(r.item_ids ?? ""),
|
||||||
@@ -164,13 +164,18 @@ export async function loadCatalogItemsData(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||||
prisma.catalogPages.findMany({
|
db
|
||||||
orderBy: { caption: "asc" },
|
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
||||||
select: { id: true, caption: true },
|
.from(CatalogPages)
|
||||||
}),
|
.orderBy(asc(CatalogPages.caption)),
|
||||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
db
|
||||||
|
.execute(sql`
|
||||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||||
`,
|
`)
|
||||||
|
.then(
|
||||||
|
(result) =>
|
||||||
|
(result as unknown as [{ interaction_type: string }[], unknown[]])[0],
|
||||||
|
),
|
||||||
getHabboGamedataHotel(),
|
getHabboGamedataHotel(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -192,20 +197,21 @@ export async function loadCatalogItemsData(
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
||||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
|
// baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join.
|
||||||
const baseItems: BaseItem[] =
|
const [baseItemRows] =
|
||||||
baseItemIds.length > 0
|
baseItemIds.length > 0
|
||||||
? (
|
? ((await db.execute(sql`
|
||||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
|
||||||
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
||||||
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
||||||
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
||||||
allow_inventory_stack, interaction_type, interaction_modes_count,
|
allow_inventory_stack, interaction_type, interaction_modes_count,
|
||||||
vending_ids, customparams, effect_id_male, effect_id_female,
|
vending_ids, customparams, effect_id_male, effect_id_female,
|
||||||
clothing_on_walk
|
clothing_on_walk
|
||||||
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)})
|
||||||
`
|
`)) as unknown as [Record<string, unknown>[], unknown[]])
|
||||||
).map((r) => ({
|
: [[], []];
|
||||||
|
|
||||||
|
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
|
||||||
id: Number(r.id),
|
id: Number(r.id),
|
||||||
spriteId: Number(r.sprite_id),
|
spriteId: Number(r.sprite_id),
|
||||||
publicName: String(r.public_name ?? ""),
|
publicName: String(r.public_name ?? ""),
|
||||||
@@ -230,8 +236,7 @@ export async function loadCatalogItemsData(
|
|||||||
effectIdMale: Number(r.effect_id_male) || 0,
|
effectIdMale: Number(r.effect_id_male) || 0,
|
||||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||||
}))
|
}));
|
||||||
: [];
|
|
||||||
|
|
||||||
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,19 +0,0 @@
|
|||||||
import type { Ban, User, UsersCurrency } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
export type UserWithCurrencies = User & { currencies: UsersCurrency[] };
|
|
||||||
export type UserWithPermission = User & { permission: { rankName: string } };
|
|
||||||
export type UserPublicPreview = Pick<
|
|
||||||
User,
|
|
||||||
"id" | "username" | "look" | "motto"
|
|
||||||
>;
|
|
||||||
export interface NotificationRow {
|
|
||||||
id: number;
|
|
||||||
userId: number;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
link: string | null;
|
|
||||||
readAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
export type BanWithStaff = Ban & { staff: Pick<User, "id" | "username"> };
|
|
||||||
Reference in new issue
Block a user