Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc10c168b |
No files matched your search
+4
-9
@@ -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
@@ -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';"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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 ?? "",
|
||||
},
|
||||
|
||||
Whitespace-only changes.
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1818
-190
File diff suppressed because it is too large.
Load diff
+5
-1
@@ -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
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ---- */
|
||||
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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: {} };
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user