1 Commits
Author SHA1 Message Date
remco efc10c168b chore(deps): update All dependencies
CI / check (pull_request) Successful in 25s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-01 10:00:33 +00:00
454 changed files with 13144 additions and 12678 deletions

No files matched your search

+4 -9
View File
@@ -32,20 +32,13 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here=
CONVERT_PASSWORDS=true
ARGON2_MEMORY_KB=65536
ARGON2_ITERATIONS=4
ARGON2_PARALLELISM=1
BCRYPT_ROUNDS=12
# --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
EMULATOR_BACKUP_DIR=./backups/emulator
EMULATOR_BACKUP_KEEP=7
# Optional mysqldump (jobs-worker daily 03:30). Requires mysqldump on PATH.
DB_BACKUP_DIR=
DB_BACKUP_KEEP=7
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
HEALTH_ALERT_COOLDOWN_MIN=15
# --- RCON (Low Latency Loop) ---
RCON_HOST=127.0.0.1
@@ -70,5 +63,7 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
# --- LOGGING ---
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
LOG_LEVEL=error
SENTRY_DSN=https://[email protected]/0
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
+30 -10
View File
@@ -43,7 +43,9 @@ jobs:
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
export BCRYPT_ROUNDS=4
pnpm install --frozen-lockfile
# Types come from the committed Drizzle schema (src/db/schema.ts).
# Generate Prisma type stubs for facade type-checking (dev only).
pnpm prisma:generate
# Run lint + typecheck + tests on the Drizzle-backed codebase.
pnpm biome:lint
pnpm typecheck
pnpm test
@@ -105,6 +107,7 @@ jobs:
fi
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
echo "APP_VERSION=${APP_VERSION}"
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
@@ -138,7 +141,11 @@ jobs:
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
pnpm install --frozen-lockfile
# Types come from the committed Drizzle schema (src/db/schema.ts).
# Generate Prisma type stubs (for facade type-checking) — no DB connection needed.
# Drizzle schema (src/db/schema.ts) is committed and does not require generation.
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
pnpm prisma:generate
unset DATABASE_URL
export BCRYPT_ROUNDS=4
pnpm typecheck
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
@@ -198,6 +205,9 @@ jobs:
rm -rf node_modules
mv "${STAGE}/node_modules" node_modules
# Regenerate Prisma type stubs into live src/generated/ (gitignored build artifact).
pnpm prisma:generate
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
# Next.js prefers an already-set process PORT over .env. PM2 may still
@@ -291,7 +301,10 @@ jobs:
export NODE_ENV=production
export SKIP_ENV_VALIDATION=1
pnpm install --frozen-lockfile
# Types come from the committed Drizzle schema (src/db/schema.ts).
# prisma generate only needs a resolvable URL — no live DB connection.
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
pnpm prisma:generate
unset DATABASE_URL
# Tag releases must apply CMS SQL migrations against the live DB
# (same path as push-to-main deploy), using the production .env.
LIVE="/var/www/atom-nexst"
@@ -403,14 +416,21 @@ jobs:
echo ""
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
echo ""
echo "### 4. Run CMS Migrations"
echo "### 4. Generate Prisma Type Stubs (Dev Only)"
echo '```bash'
echo "pnpm prisma:generate"
echo '```'
echo ""
echo "Generates TypeScript types in src/generated/prisma/ for the Prisma compatibility facade (types only — no runtime Prisma engine in production). New code should use Drizzle ORM directly via '@/lib/db'."
echo ""
echo "### 5. Run CMS Migrations"
echo '```bash'
echo "pnpm db:migrate"
echo '```'
echo ""
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status. Runtime types come from the committed Drizzle schema (src/db/schema.ts) via '@/lib/db'."
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
echo ""
echo "### 5. Polaris Emulator"
echo "### 6. Polaris Emulator"
echo ""
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
echo '```bash'
@@ -424,7 +444,7 @@ jobs:
echo "./update-Nitrov3.sh"
echo '```'
echo ""
echo "### 6. Nitro V3 & Renderer"
echo "### 7. Nitro V3 & Renderer"
echo ""
echo "Clone both Nitro repos and build the client:"
echo '```bash'
@@ -436,14 +456,14 @@ jobs:
echo ""
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
echo ""
echo "### 7. Catalogus (catalog & gamedata)"
echo "### 8. Catalogus (catalog & gamedata)"
echo ""
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
echo '```bash'
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
echo '```'
echo ""
echo "### 8. Build & Start the CMS"
echo "### 9. Build & Start the CMS"
echo '```bash'
echo "# Development (hot reload)"
echo "pnpm dev"
@@ -454,7 +474,7 @@ jobs:
echo ""
echo "Open http://localhost:3000 in your browser."
echo ""
echo "### 9. First Login"
echo "### 10. First Login"
echo ""
echo "1. Register at /register, or log in with an existing emulator account."
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
+1
View File
@@ -5,6 +5,7 @@ node_modules/
next-env.d.ts
.env
*.tsbuildinfo
# Prisma client is generated by `prisma generate`
src/generated/
# Runtime avatar/badge imaging disk cache
public/cache/
+45 -257
View File
@@ -2,7 +2,7 @@
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 (argon2id hashing with legacy md5/bcrypt auto-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.
---
@@ -39,7 +39,7 @@ CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4
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 emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/` (`pnpm db:migrate`).
> **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
@@ -78,14 +78,25 @@ const found = await db.select()
| Command | What it does |
| ------- | ------------ |
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema names + live DB |
| `npx drizzle-kit generate --dialect mysql --schema src/db/schema.ts --out src/db/migrations` | Inspect the Drizzle schema and emit migration SQL |
| `npx drizzle-kit studio` | Open a local DB browser (dev only) |
| `npx drizzle-kit introspect` | Reverse-engineer an existing DB into a Drizzle schema |
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
> The CMS does **not** use `drizzle-kit push` — the database is owned by the emulator and is never auto-migrated. CMS-owned tables are created via the SQL migration runner (step 5).
Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.ts` for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time.
#### Prisma Compatibility Facade (Backwards Compatibility)
A Prisma-compatible facade at `@/lib/prisma` allows existing code to keep calling `prisma.users.findMany()` without refactoring. At runtime, the facade routes every query through Drizzle. **There is zero Prisma client or query-engine overhead in production.**
Generate the Prisma type stubs used for type-checking the facade:
```bash
pnpm prisma:generate
```
This creates `src/generated/prisma/` (a local, `gitignore`d build artifact) with TypeScript types only. It is never shipped in the production bundle.
> **Legacy CLI command removed:** The `prisma` CLI is now a devDependency used **only** for type generation. Old commands such as `prisma migrate dev`, `prisma studio`, or `prisma db push` are no longer applicable — use the SQL migration runner (`pnpm db:migrate`) or Drizzle CLI instead.
### 5. Run CMS Migrations
@@ -93,7 +104,7 @@ Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.t
pnpm db:migrate
```
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `prisma/migrations/`. Emulator tables are never touched.
Check migration status:
@@ -121,235 +132,6 @@ Open `http://localhost:3000` in your browser.
---
## Nginx Configuration
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
### Prerequisites
- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt)
- Next.js running on `127.0.0.1:3000` (default) or your configured port
- Habbo imager (optional) running on `127.0.0.1:3030`
### Reference Configuration
Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`:
```nginx
# ==========================================
# GLOBAL SETTINGS
# ==========================================
server_tokens off;
gzip on;
gzip_vary on;
gzip_proxied off;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css text/javascript application/json
application/javascript application/xml application/xml+rss
image/svg+xml font/opentype font/ttf font/woff font/woff2;
# ==========================================
# REDIRECT HTTP → HTTPS
# ==========================================
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
location /.well-known/acme-challenge/ {
root /var/www/epicnext/public;
}
location / {
return 301 https://$host$request_uri;
}
}
# ==========================================
# MAIN HTTPS SERVER
# ==========================================
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name yourdomain.com www.yourdomain.com;
root /var/www/epicnext/public;
index index.html;
# SSL Certificates
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20m;
client_body_timeout 30s;
client_header_timeout 10s;
keepalive_timeout 15s;
send_timeout 10s;
# Shared Proxy Settings
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
# ------------------------------------------
# Static Files
# ------------------------------------------
location ^~ /nitro-client/ {
alias /var/www/Nitro-V3/dist/;
expires 7d;
add_header Cache-Control "public";
access_log off;
}
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ------------------------------------------
# Next.js Assets (immutable, long cache)
# ------------------------------------------
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /_next/data/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# ------------------------------------------
# API Routes (never cached)
# ------------------------------------------
location /api/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ------------------------------------------
# Habbo Imager (optional)
# ------------------------------------------
# Proxies to a Docker container that renders Habbo avatars.
# The imager caches renders to disk, so a long s-maxage is safe.
location /imaging {
proxy_pass http://127.0.0.1:3030;
add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
}
# ------------------------------------------
# WebSocket (Radio / SSE)
# ------------------------------------------
location /ws {
proxy_pass http://127.0.0.1:3030;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
# ------------------------------------------
# Main Page Proxy (with HTML caching)
# ------------------------------------------
# The CMS middleware sets:
# Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous)
# Cache-Control: private, no-store (authenticated)
#
# nginx caches anonymous responses and serves them directly, bypassing
# the Node.js process entirely. Authenticated responses are never cached.
#
# proxy_cache_valid: cache 200 responses for 60 seconds
# proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
# next-router-*, Accept-Encoding) that would fragment the cache key.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_http_version 1.1;
proxy_buffering on;
proxy_cache html_cache;
proxy_cache_valid 200 60s;
proxy_cache_key "$host$request_uri";
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_revalidate on;
add_header X-Cache-Status $upstream_cache_status always;
}
# ------------------------------------------
# Health Check
# ------------------------------------------
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
# Block hidden files
location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
deny all;
access_log off;
log_not_found off;
}
}
```
### HTML Caching
The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means:
- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
- **Authenticated visitors** always hit Node.js (personalized content).
- The cache is **auto-invalidated** after 60 seconds and revalidates in the background.
The proxy cache zone is defined in the `http` block (above any `server` block):
```nginx
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
```
Verify caching works by checking the `X-Cache-Status` response header:
```bash
# First request (MISS = fetched from Node.js, now cached)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: MISS
# Second request (HIT = served from nginx cache)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: HIT
```
### Key Points
| Setting | Value | Why |
| ------- | ----- | --- |
| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer |
| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses |
| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache |
| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance |
| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages |
---
## Production Deployment (PM2)
```bash
@@ -382,15 +164,18 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
| `pnpm test` | Run all tests (Vitest) |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from Prisma + live DB (needs `DATABASE_URL`) |
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
| `pnpm biome:check` | Lint and format code |
**Drizzle Kit notes:** `db:generate` / `db:introspect` write drafts only. Reviewed SQL must be copied into `drizzle/migrations/` as a new numbered file, then applied with `pnpm db:migrate`. Never run `drizzle-kit push` or `drizzle-kit migrate` against production.
**Drizzle CLI (dev only, run with `npx`):**
| Command | Description |
| ------- | ----------- |
| `drizzle-kit generate` | Generate migration SQL from Drizzle schema |
| `drizzle-kit studio` | Local Drizzle Studio database browser |
| `drizzle-kit introspect` | Reverse-engineer DB → Drizzle schema |
---
@@ -418,26 +203,27 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
## Architecture
```
├── drizzle/
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
├── prisma/
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only (legacy)
│ └── migrations/ # 19 SQL migrations for CMS tables (idempotent, never re-run)
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── jobs-worker.ts # Background task scheduler
│ ├── merge-config.cjs # Utility: merge split config files
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
│ └── generate-drizzle-schema.mjs # One-off: generate src/db/schema.ts from schema.prisma
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ └── relations.ts # Drizzle relations
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
│ │ └── migrations/ # Drizzle migration files (local dev only)
│ ├── app/ # Next.js App Router (pages & API routes)
│ ├── actions/ # Server Actions
│ ├── components/ # UI components
│ ├── lib/
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── 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)
│ │ ├── cached-db.ts # Redis-backed query cache helpers
│ │ ├── redis.ts # Redis client (ioredis)
│ │ ├── redis-cache.ts # Redis caching utility for API routes
│ │ ├── cache.ts # In-memory cache fallback
@@ -458,16 +244,18 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
| Component | Type | Migrations |
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` |
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (19 files) |
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
### ORM Architecture
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. Schema lives in `src/db/schema.ts`.
- **Schema regeneration**: `pnpm db:schema:generate` reuses field/table names from the previous `src/db/schema.ts` and refreshes column types from the live DB.
- **Drizzle Kit**: studio / generate / introspect for local tooling; CMS apply path remains `pnpm db:migrate`.
The CMS uses a dual-layer approach:
Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
- **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.
---
@@ -531,9 +319,9 @@ pnpm biome:check # Lint and format
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
3. Use the `src/lib/motion.ts` animation variants for consistent animations
4. SQL migrations in `drizzle/migrations/` must be idempotent
4. SQL migrations in `prisma/migrations/` must be idempotent
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable (e.g., Prisma facade compatibility)
---
+4 -7
View File
@@ -1,16 +1,13 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
// Schema source of truth for the query builder: src/db/schema.ts
// (regenerated via `pnpm db:schema:generate` from the previous schema + live DB).
//
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
// 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",
out: "./drizzle/drafts",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
View File
Whitespace-only changes.
-1
View File
@@ -1 +0,0 @@
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
+2 -1
View File
@@ -4,7 +4,8 @@
"src/app/**/page.{ts,tsx}",
"src/app/**/layout.{ts,tsx}",
"src/app/**/route.{ts,tsx}",
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}"
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
"sentry.{server,edge}.config.ts"
],
"project": ["src/**/*.{ts,tsx}"],
"ignoreDependencies": [
+25 -17
View File
@@ -1,22 +1,8 @@
import { execFileSync } from "node:child_process";
import withBundleAnalyzer from "@next/bundle-analyzer";
import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
function resolveDeploymentId(): string | undefined {
const configuredId = process.env.NEXT_DEPLOYMENT_ID?.trim();
if (configuredId) return configuredId;
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return process.env.APP_VERSION?.trim() || undefined;
}
}
const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" },
{
@@ -34,7 +20,6 @@ const securityHeaders = [
];
const nextConfig: NextConfig = {
deploymentId: resolveDeploymentId(),
turbopack: {},
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
@@ -47,6 +32,9 @@ const nextConfig: NextConfig = {
// Disable Next.js telemetry and browser sourcemaps in production
productionBrowserSourceMaps: false,
// Standalone output for smaller, faster Docker deployments and cold starts
output: "standalone",
experimental: {
useTypeScriptCli: true,
},
@@ -81,8 +69,28 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const config = withNextIntl(nextConfig);
// Source-map upload + release creation need SENTRY_AUTH_TOKEN.
// Without it, keep the SDK wrapper but skip remote Sentry build steps
// so CI/prod compile stays quiet (runtime DSN still works independently).
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
const withBA = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
export default withBA(config);
export default withBA(
withSentryConfig(config, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: sentryAuthToken,
silent: !process.env.CI || !sentryAuthToken,
widenClientFileUpload: true,
sourcemaps: {
disable: !sentryAuthToken,
},
release: {
create: Boolean(sentryAuthToken),
},
telemetry: false,
}),
);
+5 -4
View File
@@ -10,6 +10,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"prisma:generate": "prisma generate",
"typecheck": "tsc --noEmit --incremental",
"biome:check": "biome check --write .",
"biome:lint": "biome lint .",
@@ -20,9 +21,6 @@
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio",
"db:introspect": "drizzle-kit introspect",
"jobs:worker": "tsx scripts/jobs-worker.ts",
"prepare": "husky"
},
@@ -31,7 +29,7 @@
"biome check --write"
],
"*.{json,md,css,scss,html}": [
"biome format --write --no-errors-on-unmatched"
"biome format --write"
]
},
"dependencies": {
@@ -40,6 +38,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.6.0",
"@sentry/nextjs": "^10.69.0",
"@tanstack/react-virtual": "^3.14.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -77,6 +76,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",
@@ -92,6 +92,7 @@
"lint-staged": "^17.3.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.25",
"prisma": "^7.9.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
+1818 -190
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -3,9 +3,13 @@
# pnpm v11 vervanger voor onlyBuiltDependencies
allowBuilds:
esbuild: true
prisma: true
"@prisma/client": true
"@prisma/engines": true
sharp: true
"@parcel/watcher": true
"@swc/core": true
"@sentry/cli": true
bcrypt: true
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
@@ -28,4 +32,4 @@ peerDependencyRules:
allowedVersions:
nodemailer: "9.0.3"
ignoreMissing:
- nodemailer
- nodemailer
+16
View File
@@ -0,0 +1,16 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
// Prisma 7 config. The datasource URL lives here (not in schema.prisma).
// We NEVER run `prisma migrate`/`db push` against this database — it is shared
// live with the Arcturus emulator. CMS-only schema changes go in
// prisma/migrations/*.sql (idempotent) applied via `pnpm db:migrate`.
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
File renamed without changes.
File renamed without changes.
+2457
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -6,7 +6,7 @@ import { mysqlConnectionUrl } from "./db-url";
import { splitSqlStatements } from "./sql-statements";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = resolve(__dirname, "../drizzle/migrations");
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
const TRACKING_TABLE = "cms_migrations";
interface MigrationFile {
+195 -143
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env node
// Generates src/db/schema.ts from:
// 1. existing src/db/schema.ts -> TS export names, camelCase fields, column maps, keys
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
// 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.
// CMS DDL lands in drizzle/migrations/*.sql via `pnpm db:migrate`.
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
// This schema is only used for the query builder + TypeScript types.
//
// Usage: pnpm db:schema:generate
import "dotenv/config";
@@ -28,124 +27,84 @@ function mysqlConnectionUrl(value) {
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
const OUT = SCHEMA_TS;
const SCHEMA_PRISMA = resolve(ROOT, "prisma/schema.prisma");
const OUT = resolve(ROOT, "src/db/schema.ts");
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8");
/**
* @returns {{ name: string, table: string, fields: object[], ids: string[]|null, uniques: string[][] }}
*/
function parseDrizzleTables(source) {
const models = [];
const re2 =
/^export const (\w+) = mysqlTable\(\s*"([^"]+)"\s*,\s*\{([\s\S]*?)\n\}(?:,\s*\(t\)\s*=>\s*\[([\s\S]*?)\])?\s*\);/gm;
// ---------- Parse prisma/schema.prisma ----------
const modelBlocks = [
...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm),
];
const modelNames = new Set(modelBlocks.map((m) => m[1]));
let match = re2.exec(source);
const seen = new Set();
while (match !== null) {
const [, name, table, body, extras] = match;
if (!seen.has(name)) {
seen.add(name);
models.push(parseTableBody(name, table, body, extras ?? ""));
}
match = re2.exec(source);
}
/** 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();
if (models.length === 0) {
throw new Error(
`[schema-gen] Failed to parse any mysqlTable exports from ${SCHEMA_TS}`,
);
}
return models;
}
function parseTableBody(name, table, body, extras) {
const fields = [];
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) continue;
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\s*$/);
if (
!trimmed ||
trimmed.startsWith("//") ||
trimmed.startsWith("@@") ||
trimmed.startsWith("///")
) {
continue;
}
const m = trimmed.match(/^(\w+)\s+(.+)$/);
if (!m) continue;
const fieldName = m[1];
const expr = m[2];
const colMatch = expr.match(/\(\s*"([^"]+)"/);
if (!colMatch) continue;
const column = colMatch[1];
const isBoolean = /\bboolean\s*\(/.test(expr);
const enumMatch = expr.match(/mysqlEnum\s*\(\s*"[^"]+"\s*,\s*(\[[^\]]*\])/);
let enumValues = null;
if (enumMatch) {
try {
enumValues = JSON.parse(enumMatch[1].replace(/'/g, '"'));
} catch {
enumValues = [...enumMatch[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
}
}
let defaultContent = null;
const defIdx = expr.indexOf(".default(");
if (defIdx >= 0) {
const start = defIdx + ".default(".length;
let depth = 0;
for (let i = start; i < expr.length; i++) {
const ch = expr[i];
if (ch === "(") depth++;
else if (ch === ")") {
if (depth === 0) {
defaultContent = expr.slice(start, i).trim();
break;
}
depth--;
}
}
}
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,
optional: !/\.notNull\s*\(/.test(expr),
isId: /\.primaryKey\s*\(/.test(expr),
isUnique: /\.unique\s*\(/.test(expr),
autoIncrement: /\.autoincrement\s*\(/.test(expr),
isBoolean,
enumValues,
defaultContent,
// legacy shape used by columnExpr / fallback
prismaType: isBoolean
? "Boolean"
: enumValues
? fieldName === "gender"
? "users_gender"
: fieldName === "type" && table === "bans"
? "bans_type"
: "String"
: "String",
attrs: defaultContent ? `@default(${defaultContent})` : "",
dbHint: null,
column: map,
prismaType: baseType,
optional,
isList,
isId,
isUnique,
autoIncrement,
updatedAt,
attrs,
dbHint: dbHint
? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] }
: null,
});
}
let ids = 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 = [];
if (extras) {
const pk = extras.match(
/primaryKey\(\s*\{\s*columns:\s*\[([^\]]+)\]\s*\}\s*\)/,
);
if (pk) {
ids = [...pk[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]);
}
for (const u of extras.matchAll(/uniqueIndex\([^)]*\)\.on\(([^)]+)\)/g)) {
uniques.push([...u[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]));
}
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 = parseDrizzleTables(schemaSource);
const models = modelBlocks.map(parseModel);
// ---------- Introspect live MySQL ----------
let url;
@@ -193,20 +152,21 @@ function quote(v) {
/** 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.enumValues ??
(field.prismaType === "users_gender"
? ["M", "F"]
: ["account", "ip", "machine", "super"]));
: field.prismaType === "users_gender"
? ["M", "F"]
: ["account", "ip", "machine", "super"];
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
}
@@ -219,8 +179,7 @@ function columnExpr(field, dbCol) {
break;
case "tinyint": {
const isBool =
dbCol.column_type === "tinyint(1)" &&
(field.isBoolean || field.prismaType === "Boolean");
dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean";
if (isBool) {
use("boolean");
type = `boolean(${quote(col)})`;
@@ -250,6 +209,8 @@ function columnExpr(field, dbCol) {
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(
@@ -323,13 +284,14 @@ function columnExpr(field, dbCol) {
break;
default:
console.warn(
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col}`,
`[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 type;
return expr;
}
function modifiers(field, dbCol) {
@@ -344,38 +306,55 @@ function modifiers(field, dbCol) {
} 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 += emitDefault(field, dbCol);
expr += parseDefault(field, dbCol);
return expr;
}
function emitDefault(field, dbCol) {
const content = field.defaultContent;
/** 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 === "sql`CURRENT_TIMESTAMP`" ||
content.includes("CURRENT_TIMESTAMP")
) {
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+n$/.test(content)) 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})`;
if (
(content.startsWith('"') && content.endsWith('"')) ||
(content.startsWith("'") && content.endsWith("'"))
) {
return `.default(${JSON.stringify(content.slice(1, -1))})`;
}
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) {
@@ -383,6 +362,8 @@ function modelTable(model) {
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(
@@ -426,23 +407,96 @@ ${uniqueRows.join("\n")}
],
);`;
}
return `export const ${model.name} = mysqlTable("${model.table}", {
return `export const ${model.name} = mysqlTable(
"${model.table}",
{
${rows.join("\n")}
});`;
},
);`;
}
function fallbackColumn(field) {
const hint = field.dbHint;
const name = field.column;
if (field.enumValues) {
use("mysqlEnum");
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
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;
}
if (field.isBoolean || field.prismaType === "Boolean") {
use("boolean");
return `boolean(${quote(name)})`;
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 })`;
}
use("varchar");
return `varchar(${quote(name)}, { length: 255 })`;
}
// ---------- Generate file ----------
@@ -478,8 +532,8 @@ const IMPORTABLE = [
const importList = IMPORTABLE.filter((b) => used.has(b));
const helpers = [
"// MySQL TIME / DATE columns are hydrated as JS Date (epoch 1970-01-01",
"// for TIME). Keep this so existing call sites stay unchanged.",
"// 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";',
@@ -509,15 +563,13 @@ const helpers = [
"",
].join("\n");
const sqlImport = usedSql ? 'import { sql } from "drizzle-orm";\n' : "";
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
// TS field names come from the previous src/db/schema.ts; column types from the
// live MySQL DB. Run \`pnpm db:schema:generate\` after schema/map changes.
${sqlImport}import {
// 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}
`;
+24 -166
View File
@@ -1,81 +1,29 @@
import * as Sentry from "@sentry/nextjs";
import { Cron } from "croner";
import { lt, sql } from "drizzle-orm";
import { env } from "../src/env";
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis";
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
import { rcon } from "../src/lib/services/rcon";
import { prisma } from "../src/lib/prisma";
function initWorkerSentry(): void {
const dsn = process.env.SENTRY_DSN;
if (!dsn || process.env.NODE_ENV !== "production") return;
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
tracesSampleRate: 0.05,
});
logger.info("Sentry initialized for jobs worker", { module: "jobs" });
}
function captureWorkerError(err: unknown, context: string): void {
logger.error(context, {
module: "jobs",
err: err instanceof Error ? err.message : String(err),
});
}
/** In-process cooldown so a flapping probe does not spam Discord/email. */
const alertCooldownMs = (env.HEALTH_ALERT_COOLDOWN_MIN ?? 15) * 60_000;
const lastHealthAlertAt = new Map<string, number>();
function canAlert(key: string): boolean {
const now = Date.now();
const prev = lastHealthAlertAt.get(key) ?? 0;
if (now - prev < alertCooldownMs) return false;
lastHealthAlertAt.set(key, now);
return true;
}
async function probeHealth(): Promise<{
database: boolean;
redis: boolean | null;
emulator: boolean;
}> {
const database = await db
.execute(sql`SELECT 1`)
.then(() => true)
.catch(() => false);
let redisOk: boolean | null = null;
if (env.REDIS_URL) {
if (!redis) {
redisOk = false;
} else {
try {
redisOk = (await redis.ping()) === "PONG";
} catch {
redisOk = false;
}
}
}
const emulator = await rcon.send("ping", null).catch(() => false);
return {
database,
redis: redisOk,
emulator: Boolean(emulator),
};
}
async function checkOpsHealth(): Promise<void> {
try {
const health = await probeHealth();
const degraded =
!health.database || health.redis === false || !health.emulator;
if (!degraded) return;
if (!health.emulator && health.database && health.redis !== false) {
if (canAlert("emulator")) {
await emulatorOffline("jobs-worker RCON ping failed");
}
return;
}
if (canAlert("health")) {
await healthDegraded(health);
}
} catch (err) {
captureWorkerError(err, "Health probe failed");
if (process.env.SENTRY_DSN) {
Sentry.captureException(err);
}
}
@@ -121,90 +69,12 @@ async function backupEmulatorJar(): Promise<void> {
}
}
/** Optional mysqldump when DB_BACKUP_DIR is set (host must have mysqldump on PATH). */
async function backupDatabase(): Promise<void> {
const backupDir = env.DB_BACKUP_DIR;
if (!backupDir || !env.DATABASE_URL) return;
const { mkdirSync, readdirSync, unlinkSync, existsSync, createWriteStream } =
await import("node:fs");
const { resolve } = await import("node:path");
const { spawn } = await import("node:child_process");
let parsed: URL;
try {
parsed = new URL(env.DATABASE_URL);
} catch {
logger.error("Invalid DATABASE_URL for DB backup", { module: "jobs" });
return;
}
if (!existsSync(backupDir)) {
mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const dbName =
decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "cms";
const outFile = resolve(backupDir, `db-${dbName}-${timestamp}.sql`);
const args = [
`-h${parsed.hostname}`,
`-P${parsed.port || "3306"}`,
`-u${decodeURIComponent(parsed.username)}`,
`--single-transaction`,
`--routines`,
`--databases`,
dbName,
];
if (parsed.password) {
args.splice(3, 0, `-p${decodeURIComponent(parsed.password)}`);
}
await new Promise<void>((resolvePromise) => {
const child = spawn("mysqldump", args, {
stdio: ["ignore", "pipe", "pipe"],
});
const out = createWriteStream(outFile);
child.stdout.pipe(out);
let stderr = "";
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (err) => {
captureWorkerError(err, "mysqldump spawn failed (is it on PATH?)");
resolvePromise();
});
child.on("close", (code) => {
out.end();
if (code !== 0) {
captureWorkerError(
new Error(stderr || `mysqldump exit ${code}`),
"DB backup failed",
);
} else {
logger.info("Backed up database", { module: "jobs", outFile });
const keep = env.DB_BACKUP_KEEP ?? 7;
const files = readdirSync(backupDir)
.filter((f) => f.startsWith("db-") && f.endsWith(".sql"))
.sort()
.reverse();
for (let i = keep; i < files.length; i++) {
const file = files[i];
if (file) unlinkSync(resolve(backupDir, file));
}
}
resolvePromise();
});
});
}
async function cleanupOldLogs(): Promise<void> {
try {
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await db
.delete(WebsiteLoginLogs)
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
await prisma.websiteLoginLogs.deleteMany({
where: { createdAt: { lt: cutoff } },
});
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
} catch (err) {
captureWorkerError(err, "Log cleanup failed");
@@ -214,7 +84,9 @@ async function cleanupOldLogs(): Promise<void> {
async function cleanupOldSessions(): Promise<void> {
try {
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
await prisma.passwordReset.deleteMany({
where: { createdAt: { lt: cutoff } },
});
logger.info("Cleaned up expired password reset tokens", {
module: "jobs",
});
@@ -224,6 +96,7 @@ async function cleanupOldSessions(): Promise<void> {
}
async function main() {
initWorkerSentry();
logger.info("Worker started", { module: "jobs" });
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
@@ -235,15 +108,6 @@ async function main() {
});
}
if (env.DB_BACKUP_DIR) {
new Cron("30 3 * * *", () => {
backupDatabase().catch((e) => captureWorkerError(e, "DB backup error"));
});
logger.info("Scheduled: mysqldump DB backup (daily 03:30)", {
module: "jobs",
});
}
new Cron("0 4 * * *", () => {
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
captureWorkerError(e, "Cleanup error"),
@@ -251,16 +115,10 @@ async function main() {
});
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
new Cron("*/5 * * * *", () => {
checkOpsHealth().catch((e) => captureWorkerError(e, "Health check error"));
});
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
await Promise.all([
backupEmulatorJar(),
cleanupOldLogs(),
cleanupOldSessions(),
checkOpsHealth(),
]);
}
+15 -14
View File
@@ -16,8 +16,7 @@ import {
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { eq, isNotNull } from "drizzle-orm";
import { db, User } from "../src/lib/db";
import { prisma } from "../src/lib/prisma";
function getKey(appKey: string): Buffer {
const raw = appKey.startsWith("base64:")
@@ -85,10 +84,10 @@ async function main() {
}
const key = getKey(appKey);
const users = await db
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
.from(User)
.where(isNotNull(User.twoFactorSecret));
const users = await prisma.user.findMany({
where: { twoFactorSecret: { not: null } },
select: { id: true, twoFactorSecret: true },
});
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
@@ -108,10 +107,10 @@ async function main() {
try {
const plaintext = decryptCbc(user.twoFactorSecret, key);
const reEncrypted = encryptGcm(plaintext, key);
await db
.update(User)
.set({ twoFactorSecret: reEncrypted })
.where(eq(User.id, user.id));
await prisma.user.update({
where: { id: user.id },
data: { twoFactorSecret: reEncrypted },
});
console.log(` [OK] User ${user.id} — migrated`);
migrated++;
} catch (err) {
@@ -126,10 +125,12 @@ async function main() {
if (errors > 0) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
+1 -1
View File
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
describe("radio columns migration", () => {
it("adds every column idempotently for partially migrated databases", () => {
const sql = readFileSync(
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
"utf8",
);
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
+16
View File
@@ -0,0 +1,16 @@
import * as Sentry from "@sentry/nextjs";
import { redactSentryEvent } from "@/lib/sentry-redact";
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
enabled: process.env.NODE_ENV === "production",
ignoreErrors: ["AbortError", "NEXT_REDIRECT", "NEXT_NOT_FOUND"],
beforeSend: redactSentryEvent,
});
}
+21
View File
@@ -0,0 +1,21 @@
import * as Sentry from "@sentry/nextjs";
import { redactSentryEvent } from "@/lib/sentry-redact";
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
enabled: process.env.NODE_ENV === "production",
ignoreErrors: [
"Network request failed",
"AbortError",
"NEXT_REDIRECT",
"NEXT_NOT_FOUND",
],
beforeSend: redactSentryEvent,
});
}
+11 -23
View File
@@ -4,29 +4,15 @@ import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { ActionError } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { createAd, deleteAd } from "./admin-ads";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
})),
})),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteAds: { id: "id" },
vi.mock("@/lib/prisma", () => ({
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } },
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/safe-action", () => ({
@@ -48,27 +34,28 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createAd", () => {
it("creates ad and redirects", async () => {
vi.mocked(prisma.websiteAds.create).mockResolvedValue({
id: BigInt(1),
} as never);
await createAd(
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalled();
expect(prisma.websiteAds.create).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/ads");
});
it("returns early when image empty", async () => {
await createAd(fakeForm({ image: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
expect(prisma.websiteAds.create).not.toHaveBeenCalled();
});
it("logs error on db failure", async () => {
insertValues.mockRejectedValue(new Error("db"));
vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("db"));
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
expect(logger.error).toHaveBeenCalled();
});
@@ -76,6 +63,7 @@ describe("createAd", () => {
describe("deleteAd", () => {
it("deletes ad and returns ok", async () => {
vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never);
const h = deleteAd as unknown as (ctx: {
data: { id: bigint };
session: { user: { id: string } };
@@ -86,7 +74,7 @@ describe("deleteAd", () => {
});
it("throws ActionError when not found", async () => {
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
vi.mocked(prisma.websiteAds.delete).mockRejectedValue(new Error("nf"));
const h = deleteAd as unknown as (ctx: {
data: { id: bigint };
session: { user: { id: string } };
+13 -23
View File
@@ -1,15 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteAds } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -27,17 +25,15 @@ export async function createAd(formData: FormData): Promise<void> {
const now = new Date();
try {
const [result] = (await db.insert(WebsiteAds).values({
image,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const ad = await prisma.websiteAds.create({
data: { image, createdAt: now, updatedAt: now },
});
await logStaffActivity({
staffId: staff.id,
action: "ad_create",
description: `Created advertisement #${result.insertId} (${image})`,
description: `Created advertisement #${ad.id} (${image})`,
targetType: "website_ad",
targetId: Number(result.insertId),
targetId: Number(ad.id),
});
} catch (err) {
logger.error("Action failed: createAd", {
@@ -62,10 +58,10 @@ export async function updateAd(formData: FormData): Promise<void> {
if (!image) return;
try {
await db
.update(WebsiteAds)
.set({ image, updatedAt: new Date() })
.where(eq(WebsiteAds.id, id));
await prisma.websiteAds.update({
where: { id },
data: { image, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: "ad_update",
@@ -96,14 +92,8 @@ export const deleteAd = adminAction(
async (ctx) => {
const id = ctx.data.id;
try {
const [result] = (await db
.delete(WebsiteAds)
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
if (!result.affectedRows) {
throw new ActionError("Advertisement not found");
}
} catch (err) {
if (err instanceof ActionError) throw err;
await prisma.websiteAds.delete({ where: { id } });
} catch {
throw new ActionError("Advertisement not found");
}
await logStaffActivity({
@@ -125,7 +115,7 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
if (!id) return;
try {
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await prisma.websiteAds.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "ad_delete",
-6
View File
@@ -9,12 +9,6 @@ vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue([]) })),
},
AlertLogs: {},
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
-16
View File
@@ -1,9 +1,7 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { AlertLogs, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
@@ -31,17 +29,3 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
revalidatePath("/admin/alerts");
}
/** Mark every unread ops alert as read. */
export async function markAllAlertsRead(): Promise<void> {
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
try {
await db
.update(AlertLogs)
.set({ isRead: true, updatedAt: new Date() })
.where(eq(AlertLogs.isRead, false));
} catch {
/* ignore */
}
revalidatePath("/admin/alerts");
}
+2 -5
View File
@@ -1,11 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
@@ -13,9 +12,7 @@ export async function dismissApplication(formData: FormData): Promise<void> {
if (!id) return;
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
await prisma.websiteStaffApplications.delete({ where: { id } });
} catch {
// already gone / no DB — nothing to do
}
+29 -37
View File
@@ -1,31 +1,25 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import {
db,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
let slug = base;
let n = 2;
for (;;) {
const [existing] = await db
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.limit(1);
if (!existing) return slug;
while (
await prisma.websiteArticles.findUnique({
where: { slug },
select: { id: true },
})
) {
slug = `${base}-${n++}`;
}
return slug;
}
export async function createArticle(formData: FormData): Promise<void> {
@@ -47,15 +41,17 @@ export async function createArticle(formData: FormData): Promise<void> {
try {
const now = new Date();
await db.insert(WebsiteArticles).values({
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
await prisma.websiteArticles.create({
data: {
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
},
});
} catch {
// Database error — re-render unchanged with error.
@@ -71,9 +67,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim();
try {
await db
.update(WebsiteArticles)
.set({
await prisma.websiteArticles.update({
where: { id },
data: {
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
@@ -91,8 +87,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
.trim()
.slice(0, 255),
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
},
});
} catch {
redirect("/admin/articles?error=Update failed");
}
@@ -104,15 +100,11 @@ export async function deleteArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
try {
await db.transaction(async (tx) => {
await tx
.delete(WebsiteArticleReactions)
.where(eq(WebsiteArticleReactions.articleId, id));
await tx
.delete(WebsiteArticleComments)
.where(eq(WebsiteArticleComments.articleId, id));
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
});
await prisma.$transaction([
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
prisma.websiteArticles.delete({ where: { id } }),
]);
} catch {
redirect("/admin/articles?error=Delete failed");
}
+13 -15
View File
@@ -1,10 +1,9 @@
"use server";
import { and, eq, max } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, UsersBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
@@ -24,20 +23,19 @@ export async function giveBadge(formData: FormData): Promise<void> {
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
+13 -28
View File
@@ -2,32 +2,17 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { createBan, liftBan } from "./admin-bans";
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
const selectLimit = vi.fn();
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { selectLimit, insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
vi.mock("@/lib/prisma", () => ({
prisma: {
user: { findUnique: vi.fn() },
ban: { create: vi.fn(), delete: vi.fn() },
},
Ban: { id: "id", userId: "userId" },
User: { id: "id", username: "username" },
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
@@ -41,13 +26,13 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
selectLimit.mockResolvedValue([{ username: "baduser" }]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createBan", () => {
it("creates a ban for valid inputs", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "baduser",
} as never);
await createBan(
fakeForm({
userId: "42",
@@ -56,9 +41,9 @@ describe("createBan", () => {
type: "account",
}) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, type: "account" }),
);
expect(prisma.ban.create).toHaveBeenCalledWith({
data: expect.objectContaining({ userId: 42, type: "account" }),
});
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
@@ -71,14 +56,14 @@ describe("createBan", () => {
type: "account",
}) as unknown as FormData,
);
expect(insertValues).not.toHaveBeenCalled();
expect(prisma.ban.create).not.toHaveBeenCalled();
});
});
describe("liftBan", () => {
it("deletes ban and revalidates", async () => {
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(prisma.ban.delete).toHaveBeenCalledWith({ where: { id: 42 } });
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
});
+18 -18
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -30,22 +29,23 @@ export async function createBan(formData: FormData): Promise<void> {
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const [user] = await db
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { username: true },
});
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
await prisma.ban.create({
data: {
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as any,
cfhTopic: -1,
},
});
if (user) await rcon.disconnectUser(userId, user.username);
@@ -63,7 +63,7 @@ export async function liftBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id"));
if (id > 0) {
await db.delete(Ban).where(eq(Ban.id, id));
await prisma.ban.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "ban_lift",
+15 -14
View File
@@ -1,11 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmailTemplates } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
@@ -24,12 +23,14 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return;
await db.insert(EmailTemplates).values({
name,
subject,
body,
variables: variablesRaw || null,
isActive,
await prisma.emailTemplates.create({
data: {
name,
subject,
body,
variables: variablesRaw || null,
isActive,
},
});
revalidatePath("/admin/email-templates");
}
@@ -55,15 +56,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") != null;
if (!subject || !body) return;
await db
.update(EmailTemplates)
.set({
await prisma.emailTemplates.update({
where: { id },
data: {
subject,
body,
variables: variablesRaw || null,
isActive,
})
.where(eq(EmailTemplates.id, id));
},
});
revalidatePath("/admin/email-templates");
}
@@ -71,6 +72,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
await prisma.emailTemplates.delete({ where: { id } });
revalidatePath("/admin/email-templates");
}
+11 -9
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
@@ -20,10 +20,11 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.slice(0, 512);
if (!key) return;
await db
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await prisma.emulatorSettings.upsert({
where: { key },
update: { value },
create: { key, value },
});
revalidatePath("/admin/emulator");
}
@@ -37,9 +38,10 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
.normalize("NFC")
.slice(0, 4096);
if (!key) return;
await db
.insert(EmulatorTexts)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await prisma.emulatorTexts.upsert({
where: { key },
update: { value },
create: { key, value },
});
revalidatePath("/admin/emulator");
}
+35 -56
View File
@@ -2,41 +2,23 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { disbandGuild } from "./admin-guilds";
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
() => {
const selectLimit = vi.fn();
const transactionFn = vi.fn();
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
return { selectLimit, transactionFn, deleteWhere, updateSet };
},
);
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
transaction: transactionFn,
delete: vi.fn(() => ({ where: deleteWhere })),
update: vi.fn(() => ({ set: updateSet })),
vi.mock("@/lib/prisma", () => ({
prisma: {
guilds: { findUnique: vi.fn(), delete: vi.fn() },
guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() },
guildsForumsComments: { deleteMany: vi.fn() },
guildForumViews: { deleteMany: vi.fn() },
guildsMembers: { deleteMany: vi.fn() },
rooms: { updateMany: vi.fn() },
items: { updateMany: vi.fn() },
$transaction: vi.fn(),
},
Guilds: { id: "id", name: "name", userId: "userId" },
GuildsForumsThreads: { id: "id", guildId: "guildId" },
GuildsForumsComments: { threadId: "threadId" },
GuildForumViews: { guildId: "guildId" },
GuildsMembers: { guildId: "guildId" },
Rooms: { guildId: "guildId" },
Items: { guildId: "guildId" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -53,32 +35,29 @@ beforeEach(() => {
describe("disbandGuild", () => {
it("disbands guild and cleans related data", async () => {
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
transactionFn.mockImplementation(
async (fn: (tx: unknown) => Promise<void>) => {
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: txSelectLimit,
})),
})),
})),
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
})),
};
// For threads findMany (no limit) — make where resolve to array
tx.select = vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ id: 10 }]),
})),
}));
await fn(tx);
},
);
vi.mocked(prisma.guilds.findUnique).mockResolvedValue({
id: 1,
name: "TestGuild",
userId: 42,
} as never);
vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([
{ id: 10 },
] as never);
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => {
const tx = {
guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) },
guildsForumsThreads: {
findMany: vi.fn().mockResolvedValue([{ id: 10 }]),
deleteMany: vi.fn().mockResolvedValue({}),
},
guildForumViews: { deleteMany: vi.fn().mockResolvedValue({}) },
guildsMembers: { deleteMany: vi.fn().mockResolvedValue({}) },
rooms: { updateMany: vi.fn().mockResolvedValue({}) },
items: { updateMany: vi.fn().mockResolvedValue({}) },
guilds: { delete: vi.fn().mockResolvedValue({}) },
} as never;
await (fn as (tx: never) => Promise<void>)(tx);
});
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
@@ -86,6 +65,6 @@ describe("disbandGuild", () => {
it("returns early when id is not positive", async () => {
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
expect(selectLimit).not.toHaveBeenCalled();
expect(prisma.guilds.findUnique).not.toHaveBeenCalled();
});
});
+19 -36
View File
@@ -1,19 +1,9 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */
@@ -22,36 +12,29 @@ export async function disbandGuild(formData: FormData): Promise<void> {
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const [guild] = await db
.select({
id: Guilds.id,
name: Guilds.name,
userId: Guilds.userId,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
const guild = await prisma.guilds.findUnique({
where: { id },
select: { id: true, name: true, userId: true },
});
if (!guild) return;
await db.transaction(async (tx) => {
const threads = await tx
.select({ id: GuildsForumsThreads.id })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
await prisma.$transaction(async (tx) => {
const threads = await tx.guildsForumsThreads.findMany({
where: { guildId: id },
select: { id: true },
});
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx
.delete(GuildsForumsComments)
.where(inArray(GuildsForumsComments.threadId, threadIds));
await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
await tx.guildsForumsComments.deleteMany({
where: { threadId: { in: threadIds } },
});
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
}
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.delete(Guilds).where(eq(Guilds.id, id));
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
await tx.guilds.delete({ where: { id } });
});
await logStaffActivity({
+49 -72
View File
@@ -1,15 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
Ban,
db,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -44,32 +38,25 @@ export const liftBanFromHelpTicket = adminAction(
},
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true, title: true },
});
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.userId == null) {
throw new ActionError("Ticket has no requester to unban");
}
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const removed = await prisma.ban.deleteMany({
where: { userId: ticket.userId },
});
const now = new Date();
if (ticket.open) {
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
}
logAudit({
@@ -79,7 +66,7 @@ export const liftBanFromHelpTicket = adminAction(
targetId: ticket.userId,
after: {
ticketId: String(ticketId),
removedBans: removed,
removedBans: removed.count,
title: ticket.title,
},
});
@@ -87,7 +74,7 @@ export const liftBanFromHelpTicket = adminAction(
revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/admin/bans");
revalidatePath(`/admin/users/show/${ticket.userId}`);
return actionOk({ removed, userId: ticket.userId });
return actionOk({ removed: removed.count, userId: ticket.userId });
},
);
@@ -97,33 +84,31 @@ export const replyHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
if (!ticket) throw new ActionError("Ticket not found");
const now = new Date();
const staffId = Number(ctx.session.user.id);
await db.transaction(async (tx) => {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
});
await prisma.$transaction([
prisma.websiteHelpCenterTicketReplies.create({
data: {
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
},
}),
prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt: now },
}),
]);
logAudit({
userId: staffId,
@@ -141,23 +126,19 @@ export const closeHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
if (!ticket) throw new ActionError("Ticket not found");
if (!ticket.open) throw new ActionError("Ticket is already closed");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
logAudit({
userId: Number(ctx.session.user.id),
@@ -177,23 +158,19 @@ export const reopenHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.open) throw new ActionError("Ticket is already open");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: true, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: true, updatedAt: now },
});
logAudit({
userId: Number(ctx.session.user.id),
+20 -19
View File
@@ -1,28 +1,23 @@
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import {
createHelpQuestion,
deleteHelpQuestion,
updateHelpQuestion,
} from "./admin-help";
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, updateWhere, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
delete: vi.fn(() => ({ where: deleteWhere })),
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteHelpCenterCategories: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
WebsiteHelpCenterCategories: { id: "id" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -36,26 +31,29 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 5 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createHelpQuestion", () => {
it("creates a help question and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({
id: BigInt(5),
} as never);
await createHelpQuestion(
fakeForm({
name: "FAQ",
content: "<p>Answer</p>",
}) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalled();
expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
describe("updateHelpQuestion", () => {
it("updates and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue(
{} as never,
);
await updateHelpQuestion(
fakeForm({
id: "42",
@@ -63,15 +61,18 @@ describe("updateHelpQuestion", () => {
content: "New",
}) as unknown as FormData,
);
expect(updateWhere).toHaveBeenCalled();
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
describe("deleteHelpQuestion", () => {
it("deletes and redirects", async () => {
vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue(
{} as never,
);
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
+22 -24
View File
@@ -1,14 +1,12 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
@@ -34,23 +32,25 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
})) as unknown as [ResultSetHeader];
const entry = await prisma.websiteHelpCenterCategories.create({
data: {
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
},
});
await logStaffActivity({
staffId: staff.id,
action: "help_create",
description: `Created help-center entry #${result.insertId} (${name})`,
description: `Created help-center entry #${entry.id} (${name})`,
targetType: "help_center_category",
targetId: Number(result.insertId),
targetId: Number(entry.id),
});
} catch {
// Unique name collision or DB error — re-render unchanged with error.
@@ -81,9 +81,9 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
await db
.update(WebsiteHelpCenterCategories)
.set({
await prisma.websiteHelpCenterCategories.update({
where: { id },
data: {
name,
content,
position: parsePosition(formData.get("position")),
@@ -93,8 +93,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
})
.where(eq(WebsiteHelpCenterCategories.id, id));
},
});
await logStaffActivity({
staffId: staff.id,
action: "help_update",
@@ -116,9 +116,7 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
if (!id) return;
try {
await db
.delete(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.id, id));
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "help_delete",
+11 -15
View File
@@ -1,10 +1,9 @@
"use server";
import { asc } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
import { redirectSafe } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
/**
* Housekeeping table writes are retired. Runtime access uses ACL only.
@@ -35,19 +34,16 @@ export async function bulkImportPermissions(
export async function exportPermissions(): Promise<string> {
await requirePermission(PERMS.SETTINGS_VIEW);
const perms = await db
.select({
permission: WebsiteHousekeepingPermissions.permission,
minRank: WebsiteHousekeepingPermissions.minRank,
description: WebsiteHousekeepingPermissions.description,
groupName: WebsiteHousekeepingPermissions.groupName,
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
})
.from(WebsiteHousekeepingPermissions)
.orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
);
const perms = await prisma.websiteHousekeepingPermissions.findMany({
orderBy: [{ groupName: "asc" }, { permission: "asc" }],
select: {
permission: true,
minRank: true,
description: true,
groupName: true,
dependsOn: true,
},
});
return JSON.stringify(perms, null, 2);
}
+16 -25
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import {
addBlacklist,
addWhitelist,
@@ -9,23 +10,15 @@ import {
deleteWhitelist,
} from "./admin-ip";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() },
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() },
},
WebsiteIpWhitelist: { id: "id" },
WebsiteIpBlacklist: { id: "id" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -37,8 +30,6 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("addWhitelist", () => {
@@ -46,24 +37,24 @@ describe("addWhitelist", () => {
await addWhitelist(
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "192.168.1.1",
asn: null,
whitelistAsn: false,
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false },
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
});
it("returns early when ip is empty", async () => {
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled();
});
});
describe("deleteWhitelist", () => {
it("deletes whitelist entry", async () => {
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
});
});
@@ -72,10 +63,8 @@ describe("addBlacklist", () => {
await addBlacklist(
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "203.0.113.1",
asn: null,
blacklistAsn: false,
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false },
});
});
});
@@ -83,6 +72,8 @@ describe("addBlacklist", () => {
describe("deleteBlacklist", () => {
it("deletes blacklist entry", async () => {
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({
where: { id: BigInt(99) },
});
});
});
+7 -16
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "")
@@ -26,10 +25,8 @@ export async function addWhitelist(formData: FormData): Promise<void> {
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await db.insert(WebsiteIpWhitelist).values({
ipAddress,
asn,
whitelistAsn: asn != null,
await prisma.websiteIpWhitelist.create({
data: { ipAddress, asn, whitelistAsn: asn != null },
});
revalidatePath("/admin/ip");
}
@@ -40,9 +37,7 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
if (!raw) return;
await db
.delete(WebsiteIpWhitelist)
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
revalidatePath("/admin/ip");
}
@@ -51,10 +46,8 @@ export async function addBlacklist(formData: FormData): Promise<void> {
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await db.insert(WebsiteIpBlacklist).values({
ipAddress,
asn,
blacklistAsn: asn != null,
await prisma.websiteIpBlacklist.create({
data: { ipAddress, asn, blacklistAsn: asn != null },
});
revalidatePath("/admin/ip");
}
@@ -65,8 +58,6 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
if (!raw) return;
await db
.delete(WebsiteIpBlacklist)
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
revalidatePath("/admin/ip");
}
+41 -47
View File
@@ -1,24 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
mockValues,
mockOnDuplicateKeyUpdate,
mockRequirePermission,
mockReload,
mockRevalidatePath,
} = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return {
mockValues,
mockOnDuplicateKeyUpdate,
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
vi.hoisted(() => ({
mockUpsert: vi.fn(),
mockRequirePermission: vi.fn(),
mockReload: vi.fn(),
mockRevalidatePath: vi.fn(),
};
});
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
@@ -28,11 +16,10 @@ vi.mock("@/lib/permissions", () => ({
},
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteSetting: { upsert: mockUpsert },
},
WebsiteSetting: { key: "key", value: "value" },
}));
vi.mock("@/lib/admin/guard", () => ({
@@ -51,10 +38,6 @@ import { saveMaintenance } from "./admin-maintenance";
beforeEach(() => {
vi.clearAllMocks();
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
});
describe("saveMaintenance", () => {
@@ -74,26 +57,37 @@ describe("saveMaintenance", () => {
expect(mockRequirePermission).toHaveBeenCalled();
expect(mockValues).toHaveBeenCalledTimes(3);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledTimes(3);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "maintenance_enabled",
value: "1",
where: { key: "maintenance_enabled" },
update: { value: "1" },
create: expect.objectContaining({
key: "maintenance_enabled",
value: "1",
}),
}),
);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "maintenance_message",
value: "We will be back soon!",
where: { key: "maintenance_message" },
update: { value: "We will be back soon!" },
create: expect.objectContaining({
key: "maintenance_message",
value: "We will be back soon!",
}),
}),
);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "3",
where: { key: "min_maintenance_login_rank" },
update: { value: "3" },
create: expect.objectContaining({
key: "min_maintenance_login_rank",
value: "3",
}),
}),
);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
@@ -112,16 +106,16 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "maintenance_enabled",
value: "0",
where: { key: "maintenance_enabled" },
update: { value: "0" },
}),
);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "5",
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
@@ -140,10 +134,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "5",
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
@@ -162,10 +156,10 @@ describe("saveMaintenance", () => {
await saveMaintenance(fd);
expect(mockValues).toHaveBeenCalledWith(
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
key: "min_maintenance_login_rank",
value: "5",
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
+7 -10
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
@@ -27,15 +27,12 @@ const COMMENTS: Record<string, string> = {
};
async function upsertSetting(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
create: { key, value, comment: COMMENTS[key] ?? null },
});
}
export async function saveMaintenance(formData: FormData): Promise<void> {
+9 -17
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, MarketplaceItems } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Cancel an active marketplace listing (state 1 → 0). */
@@ -15,23 +14,16 @@ export async function cancelMarketplaceListing(
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const [listing] = await db
.select({
id: MarketplaceItems.id,
state: MarketplaceItems.state,
userId: MarketplaceItems.userId,
itemId: MarketplaceItems.itemId,
price: MarketplaceItems.price,
})
.from(MarketplaceItems)
.where(eq(MarketplaceItems.id, id))
.limit(1);
const listing = await prisma.marketplaceItems.findUnique({
where: { id },
select: { id: true, state: true, userId: true, itemId: true, price: true },
});
if (listing?.state !== 1) return;
await db
.update(MarketplaceItems)
.set({ state: 0 })
.where(eq(MarketplaceItems.id, id));
await prisma.marketplaceItems.update({
where: { id },
data: { state: 0 },
});
await logStaffActivity({
staffId: staff.id,
+22 -26
View File
@@ -1,12 +1,11 @@
"use server";
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioApiKeys } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
@@ -51,22 +50,23 @@ export async function createApiKey(formData: FormData): Promise<void> {
const now = new Date();
try {
const [result] = await db.insert(RadioApiKeys).values({
name,
key,
allowedIps,
rateLimit,
isActive: true,
createdAt: now,
updatedAt: now,
const created = await prisma.radioApiKeys.create({
data: {
name,
key,
allowedIps,
rateLimit,
isActive: true,
createdAt: now,
updatedAt: now,
},
});
const createdId = BigInt(result.insertId);
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_create",
description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`,
targetType: "radio_api_key",
targetId: Number(createdId),
targetId: Number(created.id),
});
} catch {
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
@@ -84,21 +84,17 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
if (id == null) return;
try {
const [existing] = await db
.select({
name: RadioApiKeys.name,
isActive: RadioApiKeys.isActive,
})
.from(RadioApiKeys)
.where(eq(RadioApiKeys.id, id))
.limit(1);
const existing = await prisma.radioApiKeys.findUnique({
where: { id },
select: { name: true, isActive: true },
});
if (!existing) return;
const next = !existing.isActive;
await db
.update(RadioApiKeys)
.set({ isActive: next, updatedAt: new Date() })
.where(eq(RadioApiKeys.id, id));
await prisma.radioApiKeys.update({
where: { id },
data: { isActive: next, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_toggle",
@@ -120,7 +116,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
if (id == null) return;
try {
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
await prisma.radioApiKeys.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_delete",
+19 -19
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioAutoDjPlaylist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
@@ -66,24 +65,25 @@ export async function createTrack(formData: FormData): Promise<void> {
const now = new Date();
try {
const [result] = await db.insert(RadioAutoDjPlaylist).values({
title,
artist: artist || null,
album: album || null,
artworkUrl: artworkUrl || null,
duration,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
const created = await prisma.radioAutoDjPlaylist.create({
data: {
title,
artist: artist || null,
album: album || null,
artworkUrl: artworkUrl || null,
duration,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
},
});
const createdId = Number(result.insertId);
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_create",
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
targetType: "radio_auto_dj_track",
targetId: createdId,
targetId: Number(created.id),
});
} catch {
// Fail soft — DB unavailable; re-render without throwing.
@@ -100,10 +100,10 @@ export async function toggleTrack(formData: FormData): Promise<void> {
const isActive = bool(formData.get("isActive"));
try {
await db
.update(RadioAutoDjPlaylist)
.set({ isActive, updatedAt: new Date() })
.where(eq(RadioAutoDjPlaylist.id, id));
await prisma.radioAutoDjPlaylist.update({
where: { id },
data: { isActive, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_toggle",
@@ -123,7 +123,7 @@ export async function deleteTrack(formData: FormData): Promise<void> {
if (id === null) return;
try {
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_delete",
+44 -39
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -45,10 +44,11 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
if (!key) return;
try {
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: comment || null },
});
siteSettings.reload();
} catch {
// DB unavailable — fail soft so the action does not throw.
@@ -71,13 +71,14 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
if (keys.length === 0) return;
try {
await Promise.all(
await prisma.$transaction(
keys.map((key) => {
const value = str(formData.get(key));
return db
.insert(WebsiteSetting)
.values({ key, value, comment: null })
.onDuplicateKeyUpdate({ set: { value } });
return prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: null },
});
}),
);
siteSettings.reload();
@@ -104,15 +105,17 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
const now = new Date();
try {
await db.insert(RadioBanners).values({
userId: BigInt(staff.id),
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
await prisma.radioBanners.create({
data: {
userId: BigInt(staff.id),
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
},
});
} catch {
// Fail soft.
@@ -136,17 +139,17 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
if (!imagePath) return;
try {
await db
.update(RadioBanners)
.set({
await prisma.radioBanners.update({
where: { id },
data: {
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
updatedAt: new Date(),
})
.where(eq(RadioBanners.id, id));
},
});
} catch {
// Row may be gone; ignore.
}
@@ -158,7 +161,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
await prisma.radioBanners.delete({ where: { id } });
} catch {
// Already deleted; ignore.
}
@@ -178,13 +181,15 @@ export async function createRadioRank(formData: FormData): Promise<void> {
const now = new Date();
try {
await db.insert(RadioRanks).values({
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
createdAt: now,
updatedAt: now,
await prisma.radioRanks.create({
data: {
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
createdAt: now,
updatedAt: now,
},
});
} catch {
// Fail soft.
@@ -204,16 +209,16 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
if (!name) return;
try {
await db
.update(RadioRanks)
.set({
await prisma.radioRanks.update({
where: { id },
data: {
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
updatedAt: new Date(),
})
.where(eq(RadioRanks.id, id));
},
});
} catch {
// Row may be gone; ignore.
}
@@ -225,7 +230,7 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
await prisma.radioRanks.delete({ where: { id } });
} catch {
// Already deleted; ignore.
}
+2 -3
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioShouts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
@@ -29,7 +28,7 @@ export async function deleteShout(formData: FormData): Promise<void> {
if (id === null) return;
try {
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
await prisma.radioShouts.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "radio.shout.delete",
+8 -9
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -67,16 +67,15 @@ export async function savePoints(formData: FormData): Promise<void> {
};
try {
await Promise.all(
await prisma.$transaction(
POINTS_KEYS.map((key) =>
db
.insert(WebsiteSetting)
prisma.websiteSetting.upsert({
where: { key },
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
.values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
set: { value: values[key] },
}),
update: { value: values[key] },
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
create: { key, value: values[key], comment: "Radio points" },
}),
),
);
siteSettings.reload();
+16 -21
View File
@@ -1,11 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
@@ -25,10 +24,8 @@ export async function createCategory(formData: FormData): Promise<void> {
if (!name || !badge) return;
try {
await db.insert(WebsiteRareValueCategories).values({
name,
badge,
priority,
await prisma.websiteRareValueCategories.create({
data: { name, badge, priority },
});
} catch {
// Unique name collision or DB error — ignore, page will re-render unchanged.
@@ -43,12 +40,8 @@ export async function deleteCategory(formData: FormData): Promise<void> {
try {
// Remove the category's values first to avoid orphaned rows.
await db
.delete(WebsiteRareValues)
.where(eq(WebsiteRareValues.categoryId, id));
await db
.delete(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, id));
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
await prisma.websiteRareValueCategories.delete({ where: { id } });
} catch {
// Not found or DB error — ignore.
}
@@ -88,14 +81,16 @@ export async function createValue(formData: FormData): Promise<void> {
.slice(0, 255) || "diamonds";
try {
await db.insert(WebsiteRareValues).values({
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
await prisma.websiteRareValues.create({
data: {
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
},
});
} catch {
// DB error — ignore.
@@ -109,7 +104,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
if (!id) return;
try {
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
await prisma.websiteRareValues.delete({ where: { id } });
} catch {
// Not found or DB error — ignore.
}
+13 -15
View File
@@ -1,17 +1,16 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action";
import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings";
@@ -49,10 +48,11 @@ export const saveManagedSettings = adminAction(
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
await Promise.all(
entries.map(([key, value]) =>
db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } }),
prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value },
}),
),
);
await siteSettings.reload();
@@ -76,10 +76,7 @@ export async function updateSetting(formData: FormData): Promise<void> {
String(formData.get("value") ?? "").normalize("NFC"),
);
if (!key) return;
await db
.update(WebsiteSetting)
.set({ value })
.where(eq(WebsiteSetting.key, key));
await prisma.websiteSetting.update({ where: { key }, data: { value } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
@@ -100,10 +97,11 @@ export async function createSetting(formData: FormData): Promise<void> {
.trim()
.slice(0, 255);
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: comment || null },
});
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
@@ -115,7 +113,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
if (!key) return;
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await prisma.websiteSetting.delete({ where: { key } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
+37 -38
View File
@@ -1,13 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteShopArticles } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -42,42 +40,43 @@ export async function createShopArticle(formData: FormData): Promise<void> {
if (!name) return;
const now = new Date();
const costs = reqUInt(formData, "costs");
try {
const [result] = (await db.insert(WebsiteShopArticles).values({
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
costs,
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges:
String(formData.get("badges") ?? "")
const created = await prisma.websiteShopArticles.create({
data: {
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges:
String(formData.get("badges") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
},
});
await logStaffActivity({
staffId: staff.id,
action: "shop_create",
description: `Created shop package "${name}" (${costs} costs)`,
description: `Created shop package "${name}" (${created.costs} costs)`,
targetType: "shop_article",
targetId: Number(result.insertId),
targetId: Number(created.id),
});
} catch (error) {
logServerError("admin.shop_create_failed", error, {
@@ -104,9 +103,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
if (!name) return;
try {
await db
.update(WebsiteShopArticles)
.set({
await prisma.websiteShopArticles.update({
where: { id },
data: {
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
@@ -132,8 +131,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
updatedAt: new Date(),
})
.where(eq(WebsiteShopArticles.id, id));
},
});
await logStaffActivity({
staffId: staff.id,
action: "shop_update",
@@ -160,7 +159,7 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
if (!id) return;
try {
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
await prisma.websiteShopArticles.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "shop_delete",
+48 -42
View File
@@ -2,34 +2,18 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { createTag, deleteTag, updateTag } from "./admin-tags";
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const transaction = vi.fn(async (fn) =>
fn({
delete: vi.fn(() => ({ where: deleteWhere })),
}),
);
return { insertValues, updateWhere, deleteWhere, transaction };
},
);
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
delete: vi.fn(() => ({ where: deleteWhere })),
transaction,
vi.mock("@/lib/prisma", () => ({
prisma: {
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
taggables: { deleteMany: vi.fn() },
$transaction: vi.fn(),
},
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
Taggables: { tagId: "tagId" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -42,18 +26,12 @@ const fakeForm = (data: Record<string, string>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
transaction.mockImplementation(async (fn) =>
fn({
delete: vi.fn(() => ({ where: deleteWhere })),
}),
);
});
describe("createTag", () => {
it("creates a tag and revalidates", async () => {
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
await createTag(
fakeForm({
name: "News",
@@ -61,8 +39,10 @@ describe("createTag", () => {
}) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ name: "News" }),
expect(prisma.tags.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ name: "News" }),
}),
);
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
@@ -70,19 +50,23 @@ describe("createTag", () => {
it("returns early when name is empty", async () => {
await createTag(fakeForm({ name: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
expect(prisma.tags.create).not.toHaveBeenCalled();
});
it("uses default color when not provided", async () => {
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ backgroundColor: "#888888" }),
expect(prisma.tags.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ backgroundColor: "#888888" }),
}),
);
});
it("handles db error gracefully", async () => {
insertValues.mockRejectedValue(new Error("DB error"));
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error"));
await expect(
createTag(fakeForm({ name: "News" }) as unknown as FormData),
@@ -93,6 +77,8 @@ describe("createTag", () => {
describe("updateTag", () => {
it("updates a tag and revalidates", async () => {
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
await updateTag(
fakeForm({
id: "42",
@@ -101,34 +87,54 @@ describe("updateTag", () => {
}) as unknown as FormData,
);
expect(updateWhere).toHaveBeenCalled();
expect(prisma.tags.update).toHaveBeenCalledWith({
where: { id: BigInt(42) },
data: expect.objectContaining({ name: "Updated" }),
});
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("returns early when id is invalid", async () => {
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
expect(updateWhere).not.toHaveBeenCalled();
expect(prisma.tags.update).not.toHaveBeenCalled();
});
it("returns early when name is empty after update", async () => {
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
expect(updateWhere).not.toHaveBeenCalled();
expect(prisma.tags.update).not.toHaveBeenCalled();
});
});
describe("deleteTag", () => {
it("deletes a tag and its taggables", async () => {
vi.mocked(prisma.taggables.deleteMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.tags.delete).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation(async (ops: unknown) => {
const arr = ops as [
typeof prisma.taggables.deleteMany,
typeof prisma.tags.delete,
];
await arr[0];
await arr[1];
});
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
expect(transaction).toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled();
expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({
where: { tagId: BigInt(42) },
});
expect(prisma.tags.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("returns early when id is invalid", async () => {
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
expect(transaction).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
+14 -19
View File
@@ -1,11 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, Taggables, Tags } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -42,18 +40,15 @@ export async function createTag(formData: FormData): Promise<void> {
const now = new Date();
try {
const [result] = (await db.insert(Tags).values({
name,
backgroundColor,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const created = await prisma.tags.create({
data: { name, backgroundColor, createdAt: now, updatedAt: now },
});
await logStaffActivity({
staffId: staff.id,
action: "tag_create",
description: `Created tag "${name}" (#${result.insertId})`,
description: `Created tag "${name}" (#${created.id})`,
targetType: "tag",
targetId: Number(result.insertId),
targetId: Number(created.id),
});
} catch {
// Fail soft — DB unavailable or duplicate.
@@ -71,10 +66,10 @@ export async function updateTag(formData: FormData): Promise<void> {
if (!name) return;
try {
await db
.update(Tags)
.set({ name, backgroundColor, updatedAt: new Date() })
.where(eq(Tags.id, id));
await prisma.tags.update({
where: { id },
data: { name, backgroundColor, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: "tag_update",
@@ -95,10 +90,10 @@ export async function deleteTag(formData: FormData): Promise<void> {
try {
// Remove the tag and any taggable links pointing at it.
await db.transaction(async (tx) => {
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
await tx.delete(Tags).where(eq(Tags.id, id));
});
await prisma.$transaction([
prisma.taggables.deleteMany({ where: { tagId: id } }),
prisma.tags.delete({ where: { id } }),
]);
await logStaffActivity({
staffId: staff.id,
action: "tag_delete",
+11 -19
View File
@@ -2,22 +2,13 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { createTeam, deleteTeam } from "./admin-teams";
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteTeams: { id: "id" },
vi.mock("@/lib/prisma", () => ({
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@@ -29,8 +20,6 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createTeam", () => {
@@ -38,22 +27,25 @@ describe("createTeam", () => {
await createTeam(
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ rankName: "Moderator" }),
);
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({
data: expect.objectContaining({ rankName: "Moderator" }),
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
it("returns early when rankName is empty", async () => {
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
expect(prisma.websiteTeams.create).not.toHaveBeenCalled();
});
});
describe("deleteTeam", () => {
it("deletes a team entry", async () => {
vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never);
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({
where: { id: BigInt(42) },
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
});
+12 -11
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
@@ -27,14 +26,16 @@ export async function createTeam(formData: FormData): Promise<void> {
const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date();
await db.insert(WebsiteTeams).values({
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
await prisma.websiteTeams.create({
data: {
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
},
});
revalidatePath("/admin/teams");
@@ -44,7 +45,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = BigInt(String(formData.get("id")));
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
await prisma.websiteTeams.delete({ where: { id } });
revalidatePath("/admin/teams");
}
+6 -5
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
@@ -24,10 +24,11 @@ const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
const CUSTOM_CSS_MAX = 20000;
async function writeSetting(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: "Theme (housekeeping)" },
});
}
export async function saveTheme(formData: FormData): Promise<void> {
+14 -14
View File
@@ -1,12 +1,10 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { db, WebsiteShopVouchers } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
type ActionResult,
actionError,
@@ -47,17 +45,19 @@ export async function createVoucher(input: {
const now = new Date();
try {
const [result] = (await db.insert(WebsiteShopVouchers).values({
code,
amount: Math.floor(amount),
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const created = await prisma.websiteShopVouchers.create({
data: {
code,
amount: Math.floor(amount),
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
},
});
revalidatePath("/admin/vouchers");
return actionOk({ id: String(result.insertId) });
return actionOk({ id: String(created.id) });
} catch (error) {
logServerError("admin.voucher_create_failed", error);
return actionError("Could not create voucher (code may already exist)");
@@ -73,7 +73,7 @@ export async function deleteVoucher(input: {
if (!id) return actionError("Missing voucher id");
try {
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
await prisma.websiteShopVouchers.delete({ where: { id } });
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
+5 -19
View File
@@ -1,26 +1,16 @@
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { saveVpn } from "./admin-vpn";
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return { mockValues, mockOnDuplicateKeyUpdate };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
vi.mock("@/lib/prisma", () => ({
prisma: { websiteSetting: { upsert: vi.fn() } },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: vi.fn() },
@@ -37,10 +27,7 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never);
});
describe("saveVpn", () => {
@@ -52,8 +39,7 @@ describe("saveVpn", () => {
vpn_api_key: "abc123",
}) as unknown as FormData,
);
expect(mockValues).toHaveBeenCalledTimes(4);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4);
expect(siteSettings.reload).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
});
+6 -5
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -21,10 +21,11 @@ async function writeSetting(
value: string,
comment: string,
): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment },
});
}
export async function saveVpn(formData: FormData): Promise<void> {
+4 -10
View File
@@ -1,11 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
type ActionResult,
actionError,
@@ -25,13 +23,11 @@ export async function addWord(input: {
if (!word) return actionError("Word is required");
try {
const [result] = (await db
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
const created = await prisma.websiteWordfilter.create({ data: { word } });
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.insertId) });
return actionOk({ id: String(created.id) });
} catch {
return actionError("Could not add word (it may already exist)");
}
@@ -43,9 +39,7 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
if (!raw) return actionError("Missing word id");
try {
await db
.delete(WebsiteWordfilter)
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
+29 -30
View File
@@ -1,11 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWriteableBoxes } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
@@ -53,25 +51,28 @@ export async function createBox(formData: FormData): Promise<void> {
const now = new Date();
try {
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
title,
icon:
String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const created = await prisma.websiteWriteableBoxes.create({
data: {
title,
icon:
String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"),
isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now,
updatedAt: now,
},
});
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_create",
description: `Created writeable box "${title}" (#${result.insertId})`,
description: `Created writeable box "${title}" (#${created.id})`,
targetType: "writeable_box",
targetId: Number(result.insertId),
targetId: Number(created.id),
});
} catch {
// DB unavailable — swallow and re-render.
@@ -94,9 +95,9 @@ export async function updateBox(formData: FormData): Promise<void> {
if (!title) return;
try {
await db
.update(WebsiteWriteableBoxes)
.set({
await prisma.websiteWriteableBoxes.update({
where: { id },
data: {
title,
icon:
String(formData.get("icon") ?? "")
@@ -108,8 +109,8 @@ export async function updateBox(formData: FormData): Promise<void> {
isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
updatedAt: new Date(),
})
.where(eq(WebsiteWriteableBoxes.id, id));
},
});
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_update",
@@ -131,9 +132,7 @@ export async function deleteBox(formData: FormData): Promise<void> {
if (id == null) return;
try {
await db
.delete(WebsiteWriteableBoxes)
.where(eq(WebsiteWriteableBoxes.id, id));
await prisma.websiteWriteableBoxes.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_delete",
@@ -158,10 +157,10 @@ export async function toggleBox(formData: FormData): Promise<void> {
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
try {
await db
.update(WebsiteWriteableBoxes)
.set({ isActive: next, updatedAt: new Date() })
.where(eq(WebsiteWriteableBoxes.id, id));
await prisma.websiteWriteableBoxes.update({
where: { id },
data: { isActive: next, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_toggle",
+30 -49
View File
@@ -2,83 +2,64 @@
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { applyStaff, applyTeam } from "./applications";
const { selectLimit, insertValues } = vi.hoisted(() => {
const selectLimit = vi.fn();
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
return { selectLimit, insertValues };
});
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
},
WebsiteStaffApplications: {
id: "id",
userId: "userId",
rankId: "rankId",
},
vi.mock("@/lib/prisma", () => ({
prisma: { websiteStaffApplications: { findFirst: vi.fn(), create: vi.fn() } },
}));
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const fakeForm = (data) => ({
get: (key) => data[key] ?? null,
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } });
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } } as never);
vi.mocked(clientIp).mockResolvedValue("127.0.0.1");
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
selectLimit.mockResolvedValue([]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
});
describe("applyStaff", () => {
it("submits staff application", async () => {
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
null,
);
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
{} as never,
);
await applyStaff(
fakeForm({
rankId: "3",
content: "I want to help!",
}),
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
userId: 42,
rankId: 3,
content: "I want to help!",
}),
}) as unknown as FormData,
);
expect(prisma.websiteStaffApplications.create).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/apply/staff?submitted=1");
});
it("redirects to login when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
await applyStaff(fakeForm({}));
await applyStaff(fakeForm({}) as unknown as FormData);
expect(redirect).toHaveBeenCalledWith("/login");
});
it("returns duplicate status when application exists", async () => {
selectLimit.mockResolvedValue([{ id: 1 }]);
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue({
id: 1,
} as never);
await applyStaff(
fakeForm({
rankId: "3",
content: "I want to help!",
}),
}) as unknown as FormData,
);
expect(insertValues).not.toHaveBeenCalled();
expect(prisma.websiteStaffApplications.create).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=duplicate");
});
@@ -88,7 +69,7 @@ describe("applyStaff", () => {
fakeForm({
rankId: "3",
content: "I want to help!",
}),
}) as unknown as FormData,
);
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit");
});
@@ -96,25 +77,25 @@ describe("applyStaff", () => {
describe("applyTeam", () => {
it("submits team application", async () => {
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
null,
);
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
{} as never,
);
await applyTeam(
fakeForm({
teamId: "2",
content: "I want to join team!",
}),
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
userId: 42,
rankId: 2,
content: "I want to join team!",
}),
}) as unknown as FormData,
);
expect(prisma.websiteStaffApplications.create).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/apply/team?submitted=1");
});
it("redirects to login when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
await applyTeam(fakeForm({}));
await applyTeam(fakeForm({}) as unknown as FormData);
expect(redirect).toHaveBeenCalledWith("/login");
});
});
+13 -34
View File
@@ -1,10 +1,9 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// AtomCMS validates the application body with `min:10`. Mirror that floor and
@@ -68,26 +67,16 @@ export async function applyStaff(formData: FormData): Promise<void> {
if (content.length < CONTENT_MIN) {
outcome = "empty";
} else {
const [existing] = await db
.select({ id: WebsiteStaffApplications.id })
.from(WebsiteStaffApplications)
.where(
and(
eq(WebsiteStaffApplications.userId, userId),
eq(WebsiteStaffApplications.rankId, rankId),
),
)
.limit(1);
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
if (existing) {
outcome = "duplicate";
} else {
const now = new Date();
await db.insert(WebsiteStaffApplications).values({
userId,
rankId,
content,
createdAt: now,
updatedAt: now,
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
});
outcome = "submitted";
}
@@ -131,26 +120,16 @@ export async function applyTeam(formData: FormData): Promise<void> {
if (content.length < CONTENT_MIN) {
outcome = "empty";
} else {
const [existing] = await db
.select({ id: WebsiteStaffApplications.id })
.from(WebsiteStaffApplications)
.where(
and(
eq(WebsiteStaffApplications.userId, userId),
eq(WebsiteStaffApplications.rankId, rankId),
),
)
.limit(1);
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
if (existing) {
outcome = "duplicate";
} else {
const now = new Date();
await db.insert(WebsiteStaffApplications).values({
userId,
rankId,
content,
createdAt: now,
updatedAt: now,
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
});
outcome = "submitted";
}
+13 -13
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, WebsiteArticleComments, WebsiteArticles } from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
@@ -84,22 +83,23 @@ export async function postComment(formData: FormData): Promise<void> {
outcome = "invalid";
} else {
const articleId = BigInt(articleIdRaw);
const [article] = await db
.select({ slug: WebsiteArticles.slug })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, articleId))
.limit(1);
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
const now = new Date();
await db.insert(WebsiteArticleComments).values({
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
await prisma.websiteArticleComments.create({
data: {
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
},
});
outcome = "posted";
}
+23 -44
View File
@@ -1,10 +1,9 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, WebsiteArticleReactions, WebsiteArticles } from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// The reaction set the UI offers. The action rejects anything outside this list
@@ -76,59 +75,39 @@ export async function toggleReaction(formData: FormData): Promise<void> {
} else {
const articleId = BigInt(articleIdRaw);
const [article] = await db
.select({ slug: WebsiteArticles.slug })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, articleId))
.limit(1);
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
const [existing] = await db
.select({
id: WebsiteArticleReactions.id,
active: WebsiteArticleReactions.active,
})
.from(WebsiteArticleReactions)
.where(
and(
eq(WebsiteArticleReactions.userId, userId),
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.reaction, reaction),
),
)
.limit(1);
const existing = await prisma.websiteArticleReactions.findFirst({
where: { userId, articleId, reaction },
select: { id: true, active: true },
});
if (existing?.active) {
await db
.update(WebsiteArticleReactions)
.set({ active: false })
.where(eq(WebsiteArticleReactions.id, existing.id));
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: false },
});
} else {
await db
.update(WebsiteArticleReactions)
.set({ active: false })
.where(
and(
eq(WebsiteArticleReactions.userId, userId),
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.active, true),
),
);
await prisma.websiteArticleReactions.updateMany({
where: { userId, articleId, active: true },
data: { active: false },
});
if (existing) {
await db
.update(WebsiteArticleReactions)
.set({ active: true })
.where(eq(WebsiteArticleReactions.id, existing.id));
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: true },
});
} else {
await db.insert(WebsiteArticleReactions).values({
userId,
articleId,
reaction,
active: true,
await prisma.websiteArticleReactions.create({
data: { userId, articleId, reaction, active: true },
});
}
}
+27 -57
View File
@@ -1,36 +1,15 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
import { precheckLogin } from "./auth-precheck";
const { selectLimit } = vi.hoisted(() => {
const selectLimit = vi.fn().mockResolvedValue([]);
return { selectLimit };
});
vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
},
User: {
password: "password",
twoFactorConfirmedAt: "twoFactorConfirmedAt",
mail: "mail",
mailVerified: "mailVerified",
username: "username",
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: { user: { findUnique: vi.fn() } } }));
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: vi.fn(),
@@ -46,31 +25,26 @@ beforeEach(() => {
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
selectLimit.mockResolvedValue([]);
});
describe("precheckLogin", () => {
it("returns ok for valid login without 2FA", async () => {
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
},
]);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
} as never);
expect(await precheckLogin("user", "pass")).toBe("ok");
});
it("returns twofactor when 2FA is set up", async () => {
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: new Date(),
mail: null,
mailVerified: "0",
},
]);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
password: "hash",
twoFactorConfirmedAt: new Date(),
mail: null,
mailVerified: "0",
} as never);
expect(await precheckLogin("user", "pass")).toBe("twofactor");
});
@@ -83,33 +57,29 @@ describe("precheckLogin", () => {
provider: "hcaptcha",
} as never);
vi.mocked(verifyCaptcha).mockResolvedValue(false);
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
},
]);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
mailVerified: "0",
} as never);
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
});
it("returns invalid when user not found (dummy hash check)", async () => {
selectLimit.mockResolvedValue([]);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const result = await precheckLogin("nonexistent", "pass");
expect(result).toBe("invalid");
expect(checkLogin).toHaveBeenCalled();
});
it("returns unverified when email verification required", async () => {
selectLimit.mockResolvedValue([
{
password: "hash",
twoFactorConfirmedAt: null,
mail: "[email protected]",
mailVerified: "0",
},
]);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
password: "hash",
twoFactorConfirmedAt: null,
mail: "[email protected]",
mailVerified: "0",
} as never);
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("unverified");
});
+10 -13
View File
@@ -1,9 +1,8 @@
"use server";
import { eq } from "drizzle-orm";
import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password";
import { db, User } from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
@@ -46,17 +45,15 @@ export async function precheckLogin(
mailVerified: string;
} | null;
try {
const [row] = await db
.select({
password: User.password,
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
mail: User.mail,
mailVerified: User.mailVerified,
})
.from(User)
.where(eq(User.username, u))
.limit(1);
user = row ?? null;
user = await prisma.user.findUnique({
where: { username: u },
select: {
password: true,
twoFactorConfirmedAt: true,
mail: true,
mailVerified: true,
},
});
} catch {
return "invalid";
}
+13 -24
View File
@@ -1,21 +1,16 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function getBadgeData({ code }: { code: string }) {
await requirePermission(PERMS.CATALOG_EDIT);
const [badge] = await db
.select({
badgeName: WebsiteBadges.badgeName,
badgeDescription: WebsiteBadges.badgeDescription,
})
.from(WebsiteBadges)
.where(eq(WebsiteBadges.badgeKey, code))
.limit(1);
const badge = await prisma.websiteBadges.findUnique({
where: { badgeKey: code },
select: { badgeName: true, badgeDescription: true },
});
if (!badge) return { ok: false as const, data: null };
return {
ok: true as const,
@@ -33,22 +28,16 @@ export async function updateBadge({
desc: string;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const now = new Date();
await db
.insert(WebsiteBadges)
.values({
await prisma.websiteBadges.upsert({
where: { badgeKey: code },
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
create: {
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: now,
updatedAt: now,
})
.onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: desc,
updatedAt: now,
},
});
createdAt: new Date(),
updatedAt: new Date(),
},
});
revalidatePath("/admin/import/badges");
}
+8 -17
View File
@@ -1,10 +1,8 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { z } from "zod";
import { db, WebsiteBanner } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -24,18 +22,15 @@ const bannerSchema = z.object({
export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => {
const [result] = (await db
.insert(WebsiteBanner)
.values(ctx.data)) as unknown as [ResultSetHeader];
const id = Number(result.insertId);
const banner = await prisma.websiteBanner.create({ data: ctx.data });
logAudit({
userId: ctx.session.user.id,
action: "banner_create",
target: "WebsiteBanner",
targetId: id,
after: { title: ctx.data.title },
targetId: banner.id,
after: { title: banner.title },
});
return actionOk({ id });
return actionOk({ id: banner.id });
},
);
@@ -47,13 +42,9 @@ export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const [existing] = await db
.select({ id: WebsiteBanner.id })
.from(WebsiteBanner)
.where(eq(WebsiteBanner.id, id))
.limit(1);
const existing = await prisma.websiteBanner.findUnique({ where: { id } });
if (!existing) throw new ActionError("Banner not found");
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
await prisma.websiteBanner.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: "banner_update",
@@ -69,7 +60,7 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => {
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: "banner_delete",
+17 -90
View File
@@ -1,6 +1,7 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import {
bulkBan,
@@ -9,78 +10,14 @@ import {
bulkUnban,
} from "./bulk-users";
const {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
} = vi.hoisted(() => {
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]);
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
}));
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const selectLimit = vi.fn().mockResolvedValue([]);
/** Rows returned when a select chain is awaited without `.limit()`. */
const selectWhereResolved = vi.fn().mockResolvedValue([]);
return {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
};
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
delete: vi.fn(() => ({ where: deleteWhere })),
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: updateWhere })),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return selectWhereResolved().then(resolve, reject);
},
})),
})),
})),
transaction: vi.fn(),
},
Ban: { userId: "userId", id: "id" },
User: {
id: "id",
credits: "credits",
username: "username",
online: "online",
},
UsersCurrency: { userId: "userId", type: "type", amount: "amount" },
UsersBadges: {
id: "id",
userId: "userId",
badgeCode: "badgeCode",
slotId: "slotId",
},
Sanctions: { id: "id", habboId: "habboId" },
UsersSettings: {
userId: "userId",
canTrade: "canTrade",
tradelockAmount: "tradelockAmount",
vi.mock("@/lib/prisma", () => ({
prisma: {
ban: { deleteMany: vi.fn(), create: vi.fn() },
user: { update: vi.fn() },
usersCurrency: { upsert: vi.fn() },
usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/services/rcon", () => ({
@@ -98,22 +35,11 @@ const staff = { id: 1, rank: 7, username: "admin" };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
deleteWhere.mockResolvedValue([{ affectedRows: 3 }]);
insertValues.mockImplementation(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
}));
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]);
selectWhereResolved.mockResolvedValue([]);
});
describe("bulkUnban", () => {
it("unbans users", async () => {
vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never);
const r = await bulkUnban({ userIds: [1, 2, 3] });
expect(r.ok).toBe(true);
expect(r.data).toEqual({ unbanned: 3, total: 3 });
@@ -122,6 +48,7 @@ describe("bulkUnban", () => {
describe("bulkBan", () => {
it("bans users", async () => {
vi.mocked(prisma.ban.create).mockResolvedValue({} as never);
const r = await bulkBan({
userIds: [1, 2],
reason: "Spam",
@@ -129,12 +56,12 @@ describe("bulkBan", () => {
});
expect(r.ok).toBe(true);
expect(r.data.banned).toBe(2);
expect(insertValues).toHaveBeenCalledTimes(2);
});
});
describe("bulkGiveCurrency", () => {
it("gives credits", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({
userIds: [1],
amount: 100,
@@ -142,10 +69,10 @@ describe("bulkGiveCurrency", () => {
});
expect(r.data.given).toBe(1);
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
expect(updateWhere).toHaveBeenCalled();
});
it("gives pixels", async () => {
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({
userIds: [2],
amount: 50,
@@ -153,10 +80,10 @@ describe("bulkGiveCurrency", () => {
});
expect(r.data.given).toBe(1);
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
it("gives points", async () => {
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
const r = await bulkGiveCurrency({
userIds: [3],
amount: 25,
@@ -164,17 +91,17 @@ describe("bulkGiveCurrency", () => {
});
expect(r.data.given).toBe(1);
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
});
describe("bulkGiveBadge", () => {
it("gives badge to user", async () => {
selectLimit.mockResolvedValueOnce([]);
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null);
vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({
_max: { slotId: 5 },
} as never);
vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never);
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
expect(r.data.given).toBe(1);
expect(insertValues).toHaveBeenCalled();
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
});
});
+61 -77
View File
@@ -1,17 +1,10 @@
"use server";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import {
Ban,
db,
Sanctions,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { db, Sanctions, User, UsersSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -22,19 +15,18 @@ export async function bulkUnban({
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const unbanned = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const result = await prisma.ban.deleteMany({
where: { userId: { in: userIds } },
});
await logStaffActivity({
staffId: staff.id,
action: "bulk_unban",
description: `Unbanned ${unbanned} user(s)`,
description: `Unbanned ${result.count} user(s)`,
targetType: "user",
});
return {
ok: true as const,
data: { unbanned, total: userIds.length },
data: { unbanned: result.count, total: userIds.length },
};
}
@@ -53,15 +45,17 @@ export async function bulkBan({
for (const userId of userIds) {
try {
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
await prisma.ban.create({
data: {
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
},
});
banned++;
} catch {
@@ -100,26 +94,24 @@ export async function bulkGiveCurrency({
for (const userId of userIds) {
try {
if (type === "credits") {
await db
.update(User)
.set({ credits: sql`${User.credits} + ${amount}` })
.where(eq(User.id, userId));
await prisma.user.update({
where: { id: userId },
data: { credits: { increment: amount } },
});
await rcon.giveCredits(userId, amount);
} else if (type === "pixels") {
await db
.insert(UsersCurrency)
.values({ userId, type: 0, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: 0 } },
update: { amount: { increment: amount } },
create: { userId, type: 0, amount },
});
await rcon.giveDuckets(userId, amount);
} else if (type === "points") {
await db
.insert(UsersCurrency)
.values({ userId, type: 101, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: 101 } },
update: { amount: { increment: amount } },
create: { userId, type: 101, amount },
});
await rcon.givePointsGotw(userId, amount);
}
given++;
@@ -159,23 +151,19 @@ export async function bulkGiveBadge({
for (const userId of userIds) {
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
select: { id: true },
});
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode },
});
await rcon.giveBadge(userId, badgeCode);
}
given++;
@@ -240,35 +228,31 @@ export async function bulkAdjustCurrency({
for (const userId of userIds) {
try {
if (type === "credits") {
const [user] = await db
.select({ credits: User.credits })
.from(User)
.where(eq(User.id, userId))
.limit(1);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { credits: true },
});
if (!user) {
failedIds.push({ userId, reason: "Not found" });
continue;
}
const next = Math.max(0, user.credits - take);
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
await prisma.user.update({
where: { id: userId },
data: { credits: next },
});
} else {
const currencyType = type === "pixels" ? 0 : 101;
const [row] = await db
.select({ amount: UsersCurrency.amount })
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, userId),
eq(UsersCurrency.type, currencyType),
),
)
.limit(1);
const row = await prisma.usersCurrency.findUnique({
where: { userId_type: { userId, type: currencyType } },
});
const current = row?.amount ?? 0;
const next = Math.max(0, current - take);
await db
.insert(UsersCurrency)
.values({ userId, type: currencyType, amount: next })
.onDuplicateKeyUpdate({ set: { amount: next } });
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: currencyType } },
update: { amount: next },
create: { userId, type: currencyType, amount: next },
});
}
adjusted++;
} catch {
+75 -84
View File
@@ -1,10 +1,9 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -56,10 +55,10 @@ export async function updateBcPage({
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogPagesBc)
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
.where(eq(CatalogPagesBc.id, id));
await prisma.catalogPagesBc.update({
where: { id },
data: data as any,
});
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -74,7 +73,7 @@ export async function updateBcPage({
export async function deleteBcItem({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await prisma.catalogItemsBc.delete({ where: { id } });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -102,10 +101,10 @@ export async function updateBcItem({
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogItemsBc)
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
.where(eq(CatalogItemsBc.id, id));
await prisma.catalogItemsBc.update({
where: { id },
data: safe as any,
});
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -129,18 +128,19 @@ export async function createBcItem({
extradata: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
const createdId = Number(result.insertId);
const created = await prisma.catalogItemsBc.create({
data: { pageId, ...data },
});
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
description: `Created BC catalog item #${createdId}`,
description: `Created BC catalog item #${created.id}`,
targetType: "catalog_item_bc",
targetId: createdId,
targetId: created.id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
return { ok: true as const, data: { id: created.id } };
}
export async function toggleBcPage({
@@ -151,19 +151,15 @@ export async function toggleBcPage({
field: "enabled" | "visible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
const page = await prisma.catalogPagesBc.findUnique({
where: { id },
select: { enabled: true, visible: true },
});
if (!page) return { ok: false as const, error: "Page not found" };
await db
.update(CatalogPagesBc)
.set({ [field]: page[field] === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, id));
await prisma.catalogPagesBc.update({
where: { id },
data: { [field]: page[field] === "1" ? "0" : "1" },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
@@ -180,30 +176,31 @@ export async function createBcPage(input: {
orderNum?: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const [result] = await db.insert(CatalogPagesBc).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
const created = await prisma.catalogPagesBc.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
},
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc",
targetId: createdId,
targetId: created.id,
});
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
return { ok: true as const, data: { id: created.id } };
}
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
@@ -213,19 +210,18 @@ async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const [parent] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, currentId))
.limit(1);
const parent = await prisma.catalogPagesBc.findUnique({
where: { id: currentId },
select: { parentId: true },
});
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await db
.update(CatalogPagesBc)
.set({ parentId: newParentId })
.where(eq(CatalogPagesBc.id, pageId));
await prisma.catalogPagesBc.update({
where: { id: pageId },
data: { parentId: newParentId },
});
}
export async function reorderBcTreePage(input: {
@@ -244,10 +240,10 @@ export async function reorderBcTreePage(input: {
};
}
}
await db
.update(CatalogPagesBc)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPagesBc.id, input.pageId));
await prisma.catalogPagesBc.update({
where: { id: input.pageId },
data: { orderNum: input.newOrderNum },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
@@ -259,48 +255,43 @@ export async function deleteBcTreePage(input: {
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId))
.limit(1);
const page = await prisma.catalogPagesBc.findUnique({
where: { id: input.pageId },
select: { parentId: true },
});
if (!page) return { ok: false as const, error: "Page not found" };
if (input.mode === "reparent") {
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, input.pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, input.pageId));
await tx
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId));
});
await prisma.$transaction([
prisma.catalogPagesBc.updateMany({
where: { parentId: input.pageId },
data: { parentId: page.parentId },
}),
prisma.catalogItemsBc.deleteMany({ where: { pageId: input.pageId } }),
prisma.catalogPagesBc.delete({ where: { id: input.pageId } }),
]);
} else {
const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
const children = await prisma.catalogPagesBc.findMany({
where: { parentId: { in: queue } },
select: { id: true },
});
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
await prisma.$transaction([
prisma.catalogItemsBc.deleteMany({
where: { pageId: { in: toDelete } },
}),
prisma.catalogPagesBc.deleteMany({
where: { id: { in: toDelete } },
}),
]);
}
await rcon.updateCatalog();
+56 -74
View File
@@ -1,10 +1,10 @@
"use server";
import { eq, inArray, like, or, sql } from "drizzle-orm";
import { sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
@@ -87,7 +87,7 @@ async function insertCatalogItemRow(data: {
}): Promise<number> {
const pageIdStr = String(data.pageId);
return allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
await prisma.$executeRaw`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
@@ -100,7 +100,7 @@ async function insertCatalogItemRow(data: {
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
${data.haveOffer}, ${data.clubOnly}
)
`);
`;
return nextId;
});
}
@@ -127,14 +127,10 @@ export async function createCatalogItem(data: {
if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
if (firstId > 0) {
const [base] = await db
.select({
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, firstId))
.limit(1);
const base = await prisma.itemsBase.findUnique({
where: { id: firstId },
select: { publicName: true, itemName: true },
});
catalogName = base?.publicName || base?.itemName || String(firstId);
}
}
@@ -173,14 +169,10 @@ export async function bulkCreateCatalogItems({
}
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const bases = await prisma.itemsBase.findMany({
where: { id: { in: baseIds } },
select: { id: true, publicName: true, itemName: true },
});
const baseMap = new Map(bases.map((b) => [b.id, b]));
let created = 0;
@@ -233,7 +225,7 @@ export async function bulkCreateCatalogItems({
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -257,14 +249,11 @@ export async function moveCatalogItems({
return { ok: true as const, data: {} };
}
const pageIdStr = String(targetPageId);
await db.execute(sql`
await prisma.$executeRaw`
UPDATE catalog_items
SET page_id = ${pageIdStr}
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
WHERE id IN (${sql.join(ids, sql`, `)})
`;
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
@@ -277,10 +266,7 @@ export async function reorderCatalogItems({
}) {
await requirePermission(PERMS.CATALOG_EDIT);
for (const { id, orderNumber } of orders) {
await db
.update(CatalogItems)
.set({ orderNumber })
.where(eq(CatalogItems.id, id));
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
@@ -306,25 +292,25 @@ export async function updateCatalogItem({
const pageIdRaw = safeCatalog.pageId;
if (pageIdRaw !== undefined) {
const pageIdStr = String(pageIdRaw);
await db.execute(sql`
await prisma.$executeRaw`
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
`);
`;
delete safeCatalog.pageId;
}
if (Object.keys(safeCatalog).length > 0) {
await db
.update(CatalogItems)
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
.where(eq(CatalogItems.id, id));
await prisma.catalogItems.update({
where: { id },
data: safeCatalog as any,
});
}
if (baseItem) {
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
if (Object.keys(safeBase).length > 0) {
await db
.update(ItemsBase)
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, baseItem.id));
await prisma.itemsBase.update({
where: { id: baseItem.id },
data: safeBase as any,
});
}
}
await rcon.updateCatalog();
@@ -369,17 +355,16 @@ export async function translateCatalogItems(input: {
}> = [];
for (const item of items) {
const [base] = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, item.id))
.limit(1);
const base = await prisma.itemsBase.findUnique({
where: { id: item.id },
select: {
id: true,
publicName: true,
itemName: true,
type: true,
spriteId: true,
},
});
if (!base) continue;
const nextName = item.publicName?.trim() ?? "";
@@ -387,31 +372,28 @@ export async function translateCatalogItems(input: {
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, base.id));
await prisma.itemsBase.update({
where: { id: base.id },
data: { publicName: nextName },
});
const idStr = String(base.id);
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
const related = await prisma.catalogItems.findMany({
where: {
OR: [
{ itemIds: idStr },
{ itemIds: { startsWith: `${idStr};` } },
{ itemIds: { contains: `;${idStr};` } },
{ itemIds: { endsWith: `;${idStr}` } },
],
},
select: { id: true, catalogName: true },
});
for (const row of related) {
if (row.catalogName !== nextName) {
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
await prisma.catalogItems.update({
where: { id: row.id },
data: { catalogName: nextName },
});
}
}
namesUpdated++;
+37 -41
View File
@@ -1,10 +1,9 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
@@ -55,10 +54,10 @@ export async function updateCatalogPage({
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
await db
.update(CatalogPages)
.set(data as Partial<typeof CatalogPages.$inferInsert>)
.where(eq(CatalogPages.id, id));
await prisma.catalogPages.update({
where: { id },
data: data as any,
});
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
@@ -94,21 +93,17 @@ export async function toggleCatalogPage({
action: "toggleEnabled" | "toggleVisible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const [page] = await db
.select({
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
const page = await prisma.catalogPages.findUnique({
where: { id },
select: { enabled: true, visible: true },
});
if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, id));
await prisma.catalogPages.update({
where: { id },
data: { [field]: current === "1" ? "0" : "1" },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
@@ -126,34 +121,35 @@ export async function createCatalogPage(input: {
orderNum?: number;
}): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const [result] = await db.insert(CatalogPages).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
const created = await prisma.catalogPages.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
},
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
targetId: createdId,
targetId: created.id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: createdId } };
return { ok: true as const, data: { id: created.id } };
}
export async function reorderTreePage(input: {
@@ -172,10 +168,10 @@ export async function reorderTreePage(input: {
};
}
}
await db
.update(CatalogPages)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPages.id, input.pageId));
await prisma.catalogPages.update({
where: { id: input.pageId },
data: { orderNum: input.newOrderNum },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
+12 -20
View File
@@ -1,10 +1,9 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
@@ -200,22 +199,15 @@ export const setRank = adminAction(
async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const [target] = await db
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, ctx.data.userId))
.limit(1);
const target = await prisma.user.findUnique({
where: { id: ctx.data.userId },
select: { rank: true },
});
if (!target) throw new ActionError("User not found");
let rankExists: { id: number }[] = [];
try {
const [rows] = await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
);
rankExists = rows as unknown as { id: number }[];
} catch {
rankExists = [];
}
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
`.catch(() => [] as { id: number }[]);
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) {
@@ -230,10 +222,10 @@ export const setRank = adminAction(
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await db
.update(User)
.set({ rank: ctx.data.rank })
.where(eq(User.id, ctx.data.userId));
await prisma.user.update({
where: { id: ctx.data.userId },
data: { rank: ctx.data.rank },
});
revalidatePath(PATH);
return actionOk();
},
+25 -40
View File
@@ -1,10 +1,9 @@
"use server";
import { and, eq, max, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
@@ -64,15 +63,10 @@ export async function buyBadge(formData: FormData): Promise<void> {
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const [badge] = await db
.select({
id: WebsiteDrawbadges.id,
badgePath: WebsiteDrawbadges.badgePath,
published: WebsiteDrawbadges.published,
})
.from(WebsiteDrawbadges)
.where(eq(WebsiteDrawbadges.id, BigInt(rawId)))
.limit(1);
const badge = await prisma.websiteDrawbadges.findUnique({
where: { id: BigInt(rawId) },
select: { id: true, badgePath: true, published: true },
});
if (!badge?.published) {
outcome = "invalid";
@@ -84,43 +78,34 @@ export async function buyBadge(formData: FormData): Promise<void> {
const price = await resolvePrice();
// Re-read the buyer's live credit balance and verify it covers the cost.
const [buyer] = await db
.select({ credits: User.credits })
.from(User)
.where(eq(User.id, userId))
.limit(1);
const buyer = await prisma.user.findUnique({
where: { id: userId },
select: { credits: true },
});
if (!buyer || buyer.credits < price) {
outcome = "credits";
} else {
// Atomically deduct credits and persist the badge so a failure
// between the two operations cannot orphan the user.
if (price > 0) {
await db.transaction(async (tx) => {
await tx
.update(User)
.set({ credits: sql`${User.credits} - ${price}` })
.where(eq(User.id, userId));
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
});
const [existing] = await tx
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, code),
),
)
.limit(1);
const existing = await tx.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const [agg] = await tx
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await tx.insert(UsersBadges).values({
userId,
slotId,
badgeCode: code,
const max = await tx.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await tx.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
});
+6 -16
View File
@@ -1,27 +1,16 @@
// @ts-nocheck
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
const { insertValues } = vi.hoisted(() => {
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
}));
return { insertValues };
});
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
},
EmulatorSettings: { key: "key", value: "value" },
vi.mock("@/lib/prisma", () => ({
prisma: { emulatorSettings: { upsert: vi.fn() } },
}));
vi.mock("@/lib/safe-action", () => ({
adminAction: vi.fn(
(_opts: unknown, fn: (...args: unknown[]) => unknown) => fn,
),
adminAction: vi.fn((_opts: unknown, fn: (...args: unknown[]) => unknown) => fn),
}));
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
@@ -29,6 +18,7 @@ vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
describe("saveEmulatorSettings", () => {
it("saves settings and calls rcon update", async () => {
vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never);
const handler = (await import("./emulator").then(
(m) => m.saveEmulatorSettings,
)) as unknown as (ctx: {
@@ -41,7 +31,7 @@ describe("saveEmulatorSettings", () => {
session: { user: { id: "1" } },
});
expect(insertValues).toHaveBeenCalledTimes(2);
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2);
expect(rcon.updateConfig).toHaveBeenCalled();
expect(result).toBe("ok");
});
+6 -5
View File
@@ -1,8 +1,8 @@
"use server";
import { z } from "zod";
import { db, EmulatorSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -18,10 +18,11 @@ export const saveEmulatorSettings = adminAction(
const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) {
await db
.insert(EmulatorSettings)
.values({ key, value: String(value) })
.onDuplicateKeyUpdate({ set: { value: String(value) } });
await prisma.emulatorSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
});
}
await rcon.updateConfig();
+56 -103
View File
@@ -1,17 +1,9 @@
"use server";
import { and, count, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
db,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventRegistration,
WebsiteEventType,
WebsiteEventWinner,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -29,16 +21,17 @@ import {
export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => {
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
const eventTypeId = Number(result.insertId);
const eventType = await prisma.websiteEventType.create({
data: ctx.data,
});
logAudit({
userId: ctx.session.user.id,
action: "event_type_create",
target: "WebsiteEventType",
targetId: eventTypeId,
after: { name: ctx.data.name },
targetId: eventType.id,
after: { name: eventType.name },
});
return actionOk({ id: eventTypeId });
return actionOk({ id: eventType.id });
},
);
@@ -50,17 +43,12 @@ export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const [existing] = await db
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
.from(WebsiteEventType)
.where(eq(WebsiteEventType.id, id))
.limit(1);
const existing = await prisma.websiteEventType.findUnique({
where: { id },
});
if (!existing) throw new ActionError("Event type not found");
await db
.update(WebsiteEventType)
.set(data)
.where(eq(WebsiteEventType.id, id));
await prisma.websiteEventType.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: "event_type_update",
@@ -80,16 +68,12 @@ const deleteEventTypeInput = z.object({
export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => {
const [existing] = await db
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
.from(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id))
.limit(1);
const existing = await prisma.websiteEventType.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event type not found");
await db
.delete(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id));
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: "event_type_delete",
@@ -106,21 +90,20 @@ export const deleteEventType = adminAction(
export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => {
const now = new Date();
const [result] = await db.insert(WebsiteEvent).values({
...ctx.data,
hostUserId: Number(ctx.session.user.id),
updatedAt: now,
const event = await prisma.websiteEvent.create({
data: {
...ctx.data,
hostUserId: Number(ctx.session.user.id),
},
});
const eventId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "event_create",
target: "WebsiteEvent",
targetId: eventId,
after: { title: ctx.data.title },
targetId: event.id,
after: { title: event.title },
});
return actionOk({ id: eventId });
return actionOk({ id: event.id });
},
);
@@ -132,21 +115,10 @@ export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const [existing] = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
status: WebsiteEvent.status,
})
.from(WebsiteEvent)
.where(eq(WebsiteEvent.id, id))
.limit(1);
const existing = await prisma.websiteEvent.findUnique({ where: { id } });
if (!existing) throw new ActionError("Event not found");
await db
.update(WebsiteEvent)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsiteEvent.id, id));
await prisma.websiteEvent.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: "event_update",
@@ -166,14 +138,12 @@ const deleteEventInput = z.object({
export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => {
const [existing] = await db
.select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
.from(WebsiteEvent)
.where(eq(WebsiteEvent.id, ctx.data.id))
.limit(1);
const existing = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event not found");
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: "event_delete",
@@ -190,8 +160,8 @@ export const deleteEvent = adminAction(
export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => {
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
return actionOk({ id: prize.id });
},
);
@@ -200,9 +170,7 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => {
await db
.delete(WebsiteEventPrize)
.where(eq(WebsiteEventPrize.id, ctx.data.id));
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
return actionOk();
},
);
@@ -212,20 +180,19 @@ export const deleteEventPrize = adminAction(
export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => {
const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
const winnerId = Number(result.insertId);
const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
logAudit({
userId: ctx.session.user.id,
action: "event_winner_add",
target: "WebsiteEventWinner",
targetId: winnerId,
targetId: winner.id,
after: {
eventId: ctx.data.eventId,
userId: ctx.data.userId,
position: ctx.data.position,
},
});
return actionOk({ id: winnerId });
return actionOk({ id: winner.id });
},
);
@@ -244,18 +211,13 @@ export const registerForEvent = authAction(
return actionError("Unauthorized");
}
const [event] = await db
.select({
id: WebsiteEvent.id,
status: WebsiteEvent.status,
endsAt: WebsiteEvent.endsAt,
maxPlayers: WebsiteEvent.maxPlayers,
minRank: WebsiteEventType.minRank,
})
.from(WebsiteEvent)
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
.where(eq(WebsiteEvent.id, ctx.data.eventId))
.limit(1);
const event = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.eventId },
include: {
type: true,
_count: { select: { registrations: true } },
},
});
if (!event) return actionError("Event not found");
if (event.status !== "published") {
@@ -264,37 +226,28 @@ export const registerForEvent = authAction(
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended");
}
if (event.minRank > 0) {
if (event.type.minRank > 0) {
const rank = Number(ctx.session.user.rank ?? 0);
if (rank < event.minRank) {
if (rank < event.type.minRank) {
return actionError("Your rank is too low to join this event");
}
}
if (event.maxPlayers != null) {
const [regCount] = await db
.select({ value: count() })
.from(WebsiteEventRegistration)
.where(eq(WebsiteEventRegistration.eventId, event.id));
if ((regCount?.value ?? 0) >= event.maxPlayers) {
return actionError("This event is full");
}
if (
event.maxPlayers != null &&
event._count.registrations >= event.maxPlayers
) {
return actionError("This event is full");
}
const [existing] = await db
.select({ id: WebsiteEventRegistration.id })
.from(WebsiteEventRegistration)
.where(
and(
eq(WebsiteEventRegistration.eventId, event.id),
eq(WebsiteEventRegistration.userId, userId),
),
)
.limit(1);
const existing = await prisma.websiteEventRegistration.findUnique({
where: {
eventId_userId: { eventId: event.id, userId },
},
});
if (existing) return actionError("You are already registered");
await db.insert(WebsiteEventRegistration).values({
eventId: event.id,
userId,
await prisma.websiteEventRegistration.create({
data: { eventId: event.id, userId },
});
revalidatePath("/events");
+9 -7
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db, WebsiteUserGuestbooks } from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
@@ -78,12 +78,14 @@ export async function postGuestbook(formData: FormData): Promise<void> {
outcome = "moderated";
} else {
const now = new Date();
await db.insert(WebsiteUserGuestbooks).values({
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
},
});
outcome = "posted";
}
+67 -84
View File
@@ -1,17 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import {
db,
WebsiteHelpCenterCategories,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
@@ -115,30 +109,34 @@ export async function createTicket(formData: FormData): Promise<void> {
if (!/ban\s*appeal/i.test(ticketTitle)) {
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
}
const [existing] = await db
.select({ id: WebsiteHelpCenterCategories.id })
.from(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
.limit(1);
const existing = await prisma.websiteHelpCenterCategories.findFirst(
{
where: { name: { equals: "Ban appeal" } },
select: { id: true },
},
);
if (existing) {
categoryId = existing.id;
} else {
try {
const [created] = await db
.insert(WebsiteHelpCenterCategories)
.values({
name: "Ban appeal",
content:
"Appeals for account bans. Staff can lift bans from the ticket.",
position: 0,
});
categoryId = BigInt(created.insertId);
const created = await prisma.websiteHelpCenterCategories.create(
{
data: {
name: "Ban appeal",
content:
"Appeals for account bans. Staff can lift bans from the ticket.",
position: 0,
},
select: { id: true },
},
);
categoryId = created.id;
} catch {
const [again] = await db
.select({ id: WebsiteHelpCenterCategories.id })
.from(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
.limit(1);
const again =
await prisma.websiteHelpCenterCategories.findFirst({
where: { name: { equals: "Ban appeal" } },
select: { id: true },
});
categoryId = again?.id ?? null;
}
}
@@ -150,14 +148,16 @@ export async function createTicket(formData: FormData): Promise<void> {
}
const now = new Date();
await db.insert(WebsiteHelpCenterTickets).values({
userId,
title: ticketTitle,
content,
categoryId,
open: true,
createdAt: now,
updatedAt: now,
await prisma.websiteHelpCenterTickets.create({
data: {
userId,
title: ticketTitle,
content,
categoryId,
open: true,
createdAt: now,
updatedAt: now,
},
});
outcome = "created";
}
@@ -204,15 +204,10 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
if (!parsed.success) {
outcome = "invalid";
} else {
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true },
});
if (!ticket || ticket.userId !== userId) {
outcome = "not_found";
@@ -228,37 +223,30 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
}
if (!moderated) {
const created = await db.transaction(async (tx) =>
const created = await prisma.$transaction((tx) =>
createOwnedTicketReply(
{
findTicket: async (id) => {
const [row] = await tx
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, id))
.limit(1);
return row ?? null;
},
createReply: async (data) => {
const [result] = await tx
.insert(WebsiteHelpCenterTicketReplies)
.values(data);
return {
id: BigInt(result.insertId),
userId: data.userId,
content: data.content,
createdAt: data.createdAt,
};
},
findTicket: (id) =>
tx.websiteHelpCenterTickets.findUnique({
where: { id },
select: { id: true, userId: true, open: true },
}),
createReply: (data) =>
tx.websiteHelpCenterTicketReplies.create({
data,
select: {
id: true,
userId: true,
content: true,
createdAt: true,
},
}),
touchTicket: (id, updatedAt) =>
tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt })
.where(eq(WebsiteHelpCenterTickets.id, id)),
tx.websiteHelpCenterTickets.update({
where: { id },
data: { updatedAt },
select: { id: true },
}),
},
{
ticketId,
@@ -301,15 +289,10 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true },
});
if (!ticket || ticket.userId !== userId) {
outcome = "not_found";
@@ -317,10 +300,10 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
outcome = "closed_ticket";
} else {
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
outcome = "closed";
}
}
Loaded 100 of 454 files, more files were not shown because too many files have changed in this diff. Show more