Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a48060063 | ||
|
|
1f4aadb3d7 | ||
|
|
c7fb37356e | ||
|
|
95b1955218 | ||
|
|
d173dd3194 | ||
|
|
0dc16e832d | ||
|
|
59f03827bc | ||
|
|
0d6032d444 | ||
|
|
ff1fa319a5 | ||
|
|
cc02851be3 | ||
|
|
7c1f8d709e | ||
|
|
2799b63943 | ||
|
|
fd8ab7db93 | ||
|
|
14a3de0f2a | ||
|
|
22d455da7a | ||
|
|
8275842e78 | ||
|
|
c601ffbb76 | ||
|
|
d39738eb0d | ||
|
|
d69e3f5da5 | ||
|
|
811cf5719b | ||
|
|
2194aa1239 | ||
|
|
16191cef14 | ||
|
|
9c4949186c | ||
|
|
db957d7fb1 | ||
|
|
725e1cb338 | ||
|
|
3bd712e744 | ||
|
|
ba82789166 | ||
|
|
d8199ea1e4 | ||
|
|
24d0b735c1 | ||
|
|
422567272c | ||
|
|
ca72966a37 | ||
|
|
30b54e99e7 | ||
|
|
9aa4f331bf | ||
|
|
580972c0a0 | ||
|
|
2cd0863cb8 | ||
|
|
7c39aef5d9 | ||
|
|
53b350057d | ||
|
|
65b2fbee6a | ||
|
|
22234fe102 | ||
|
|
096f55b394 | ||
|
|
ed9c23c702 |
No files matched your search
+9
-4
@@ -32,13 +32,20 @@ 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
|
||||
BCRYPT_ROUNDS=12
|
||||
ARGON2_MEMORY_KB=65536
|
||||
ARGON2_ITERATIONS=4
|
||||
ARGON2_PARALLELISM=1
|
||||
|
||||
# --- 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
|
||||
@@ -63,7 +70,5 @@ PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
|
||||
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
|
||||
# --- LOGGING ---
|
||||
LOG_LEVEL=error
|
||||
SENTRY_DSN=https://[email protected]/0
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
|
||||
+10
-30
@@ -43,9 +43,7 @@ jobs:
|
||||
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm install --frozen-lockfile
|
||||
# Generate Prisma type stubs for facade type-checking (dev only).
|
||||
pnpm prisma:generate
|
||||
# Run lint + typecheck + tests on the Drizzle-backed codebase.
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
@@ -107,7 +105,6 @@ 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}"
|
||||
@@ -141,11 +138,7 @@ jobs:
|
||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# 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
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
@@ -205,9 +198,6 @@ 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
|
||||
@@ -301,10 +291,7 @@ jobs:
|
||||
export NODE_ENV=production
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
pnpm install --frozen-lockfile
|
||||
# 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
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
# 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"
|
||||
@@ -416,21 +403,14 @@ 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. 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 "### 4. 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."
|
||||
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 ""
|
||||
echo "### 6. Polaris Emulator"
|
||||
echo "### 5. Polaris Emulator"
|
||||
echo ""
|
||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||
echo '```bash'
|
||||
@@ -444,7 +424,7 @@ jobs:
|
||||
echo "./update-Nitrov3.sh"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 7. Nitro V3 & Renderer"
|
||||
echo "### 6. Nitro V3 & Renderer"
|
||||
echo ""
|
||||
echo "Clone both Nitro repos and build the client:"
|
||||
echo '```bash'
|
||||
@@ -456,14 +436,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 "### 8. Catalogus (catalog & gamedata)"
|
||||
echo "### 7. 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 "### 9. Build & Start the CMS"
|
||||
echo "### 8. Build & Start the CMS"
|
||||
echo '```bash'
|
||||
echo "# Development (hot reload)"
|
||||
echo "pnpm dev"
|
||||
@@ -474,7 +454,7 @@ jobs:
|
||||
echo ""
|
||||
echo "Open http://localhost:3000 in your browser."
|
||||
echo ""
|
||||
echo "### 10. First Login"
|
||||
echo "### 9. 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,7 +5,6 @@ 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 (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 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/`.
|
||||
> **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`).
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
@@ -78,25 +78,14 @@ const found = await db.select()
|
||||
|
||||
| Command | What it does |
|
||||
| ------- | ------------ |
|
||||
| `npx drizzle-kit generate --dialect mysql --schema src/db/schema.ts --out src/db/migrations` | Inspect the Drizzle schema and emit migration SQL |
|
||||
| `npx drizzle-kit studio` | Open a local DB browser (dev only) |
|
||||
| `npx drizzle-kit introspect` | Reverse-engineer an existing DB into a Drizzle schema |
|
||||
| `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 |
|
||||
|
||||
> 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).
|
||||
> 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`.
|
||||
|
||||
#### 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.
|
||||
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.
|
||||
|
||||
### 5. Run CMS Migrations
|
||||
|
||||
@@ -104,7 +93,7 @@ This creates `src/generated/prisma/` (a local, `gitignore`d build artifact) with
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `prisma/migrations/`. Emulator tables are never touched.
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
|
||||
|
||||
Check migration status:
|
||||
|
||||
@@ -132,6 +121,235 @@ 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
|
||||
@@ -164,18 +382,15 @@ 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 Prisma + live DB (needs `DATABASE_URL`) |
|
||||
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
||||
| `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 analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
**Drizzle CLI (dev only, run with `npx`):**
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `drizzle-kit generate` | Generate migration SQL from Drizzle schema |
|
||||
| `drizzle-kit studio` | Local Drizzle Studio database browser |
|
||||
| `drizzle-kit introspect` | Reverse-engineer DB → Drizzle schema |
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -203,27 +418,26 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
## Architecture
|
||||
|
||||
```
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only (legacy)
|
||||
│ └── migrations/ # 19 SQL migrations for CMS tables (idempotent, never re-run)
|
||||
├── drizzle/
|
||||
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
|
||||
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
|
||||
├── 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 # One-off: generate src/db/schema.ts from schema.prisma
|
||||
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
|
||||
├── src/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
|
||||
│ │ └── migrations/ # Drizzle migration files (local dev only)
|
||||
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
|
||||
│ │ └── relations.ts # Drizzle relations
|
||||
│ ├── 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
|
||||
@@ -244,18 +458,16 @@ 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 | `prisma/migrations/*.sql` (19 files) |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||
|
||||
### ORM Architecture
|
||||
|
||||
The CMS uses a dual-layer approach:
|
||||
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. 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`.
|
||||
|
||||
- **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.
|
||||
Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
|
||||
|
||||
---
|
||||
|
||||
@@ -319,9 +531,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 `prisma/migrations/` must be idempotent
|
||||
4. SQL migrations in `drizzle/migrations/` must be idempotent
|
||||
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
|
||||
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable (e.g., Prisma facade compatibility)
|
||||
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// The schema is generated by scripts/generate-drizzle-schema.mjs from
|
||||
// prisma/schema.prisma + live DB introspection. This DB is shared live with the
|
||||
// Arcturus emulator, so we NEVER run `drizzle-kit migrate`/`push` against it —
|
||||
// CMS-only schema changes stay in prisma/migrations/*.sql via `pnpm db:migrate`.
|
||||
// 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/.
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/drafts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
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`.
|
||||
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.
@@ -4,8 +4,7 @@
|
||||
"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}",
|
||||
"sentry.{server,edge}.config.ts"
|
||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignoreDependencies": [
|
||||
|
||||
+17
-25
@@ -1,8 +1,22 @@
|
||||
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" },
|
||||
{
|
||||
@@ -20,6 +34,7 @@ const securityHeaders = [
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
deploymentId: resolveDeploymentId(),
|
||||
turbopack: {},
|
||||
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
@@ -32,9 +47,6 @@ 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,
|
||||
},
|
||||
@@ -69,28 +81,8 @@ 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(
|
||||
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,
|
||||
}),
|
||||
);
|
||||
export default withBA(config);
|
||||
+4
-5
@@ -10,7 +10,6 @@
|
||||
"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 .",
|
||||
@@ -21,6 +20,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -29,7 +31,7 @@
|
||||
"biome check --write"
|
||||
],
|
||||
"*.{json,md,css,scss,html}": [
|
||||
"biome format --write"
|
||||
"biome format --write --no-errors-on-unmatched"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -38,7 +40,6 @@
|
||||
"@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",
|
||||
@@ -76,7 +77,6 @@
|
||||
"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,7 +92,6 @@
|
||||
"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
+190
-1818
File diff suppressed because it is too large.
Load diff
+1
-5
@@ -3,13 +3,9 @@
|
||||
# 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
|
||||
@@ -32,4 +28,4 @@ peerDependencyRules:
|
||||
allowedVersions:
|
||||
nodemailer: "9.0.3"
|
||||
ignoreMissing:
|
||||
- nodemailer
|
||||
- nodemailer
|
||||
@@ -1,16 +0,0 @@
|
||||
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 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, "../prisma/migrations");
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../drizzle/migrations");
|
||||
const TRACKING_TABLE = "cms_migrations";
|
||||
|
||||
interface MigrationFile {
|
||||
|
||||
+143
-195
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/db/schema.ts from:
|
||||
// 1. prisma/schema.prisma -> TS field names (camelCase) + @map column names + table names
|
||||
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
|
||||
// 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)
|
||||
//
|
||||
// The DB is owned by the Arcturus emulator; we never run drizzle-kit migrate/push.
|
||||
// This schema is only used for the query builder + TypeScript types.
|
||||
// CMS DDL lands in drizzle/migrations/*.sql via `pnpm db:migrate`.
|
||||
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
|
||||
//
|
||||
// Usage: pnpm db:schema:generate
|
||||
import "dotenv/config";
|
||||
@@ -27,84 +28,124 @@ function mysqlConnectionUrl(value) {
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SCHEMA_PRISMA = resolve(ROOT, "prisma/schema.prisma");
|
||||
const OUT = resolve(ROOT, "src/db/schema.ts");
|
||||
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
||||
const OUT = SCHEMA_TS;
|
||||
|
||||
const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8");
|
||||
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
|
||||
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
|
||||
|
||||
// ---------- Parse prisma/schema.prisma ----------
|
||||
const modelBlocks = [
|
||||
...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm),
|
||||
];
|
||||
const modelNames = new Set(modelBlocks.map((m) => m[1]));
|
||||
/**
|
||||
* @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 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();
|
||||
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);
|
||||
}
|
||||
|
||||
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("//") ||
|
||||
trimmed.startsWith("@@") ||
|
||||
trimmed.startsWith("///")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const m = trimmed.match(/^(\w+)\s+(.+)$/);
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\s*$/);
|
||||
if (!m) continue;
|
||||
const [fieldName, rest] = [m[1], m[2]];
|
||||
const typeMatch = rest.match(/^([^\s]+)/);
|
||||
const prismaType = typeMatch[1];
|
||||
const baseType = prismaType.replace(/\?$/, "").replace(/\[\]$/, "");
|
||||
const isList = prismaType.endsWith("[]");
|
||||
// relation field (points at another model) -> skip
|
||||
if (modelNames.has(baseType)) continue;
|
||||
const 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 attrs = rest;
|
||||
const optional = prismaType.endsWith("?");
|
||||
const map = attrs.match(/@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? fieldName;
|
||||
const isId = /@id\b/.test(attrs);
|
||||
const isUnique = /@unique\b/.test(attrs);
|
||||
const autoIncrement = /@default\(autoincrement\(\)\)/.test(attrs);
|
||||
const updatedAt = /@updatedAt\b/.test(attrs);
|
||||
const dbHint = attrs.match(/@db\.(\w+)(?:\((\d+)(?:\s*,\s*(\d+))?\))?/);
|
||||
fields.push({
|
||||
fieldName,
|
||||
column: map,
|
||||
prismaType: baseType,
|
||||
optional,
|
||||
isList,
|
||||
isId,
|
||||
isUnique,
|
||||
autoIncrement,
|
||||
updatedAt,
|
||||
attrs,
|
||||
dbHint: dbHint
|
||||
? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] }
|
||||
: null,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// model-level keys
|
||||
const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1];
|
||||
const ids = compIds
|
||||
? compIds.split(",").map((s) => s.trim().replace(/`/g, ""))
|
||||
: null;
|
||||
|
||||
let ids = null;
|
||||
const uniques = [];
|
||||
for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) {
|
||||
uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, "")));
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, table, fields, ids, uniques };
|
||||
}
|
||||
|
||||
const models = modelBlocks.map(parseModel);
|
||||
const models = parseDrizzleTables(schemaSource);
|
||||
|
||||
// ---------- Introspect live MySQL ----------
|
||||
let url;
|
||||
@@ -152,21 +193,20 @@ 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.prismaType === "users_gender"
|
||||
? ["M", "F"]
|
||||
: ["account", "ip", "machine", "super"];
|
||||
: (field.enumValues ??
|
||||
(field.prismaType === "users_gender"
|
||||
? ["M", "F"]
|
||||
: ["account", "ip", "machine", "super"]));
|
||||
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
||||
}
|
||||
|
||||
@@ -179,7 +219,8 @@ function columnExpr(field, dbCol) {
|
||||
break;
|
||||
case "tinyint": {
|
||||
const isBool =
|
||||
dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean";
|
||||
dbCol.column_type === "tinyint(1)" &&
|
||||
(field.isBoolean || field.prismaType === "Boolean");
|
||||
if (isBool) {
|
||||
use("boolean");
|
||||
type = `boolean(${quote(col)})`;
|
||||
@@ -209,8 +250,6 @@ 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(
|
||||
@@ -284,14 +323,13 @@ function columnExpr(field, dbCol) {
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col} (prisma:${field.prismaType})`,
|
||||
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col}`,
|
||||
);
|
||||
use("varchar");
|
||||
type = `varchar(${quote(col)}, { length: 255 })`;
|
||||
}
|
||||
expr += type;
|
||||
|
||||
return expr;
|
||||
return type;
|
||||
}
|
||||
|
||||
function modifiers(field, dbCol) {
|
||||
@@ -306,55 +344,38 @@ 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 += parseDefault(field, dbCol);
|
||||
expr += emitDefault(field, dbCol);
|
||||
return expr;
|
||||
}
|
||||
|
||||
/** Extract `@default(...)` and emit a drizzle `.default(...)` (or ""). */
|
||||
function parseDefault(field, dbCol) {
|
||||
const m = field.attrs.match(/@default\(/);
|
||||
if (!m) return "";
|
||||
const start = m.index + "@default(".length;
|
||||
let depth = 0;
|
||||
let content = "";
|
||||
for (let i = start; i < field.attrs.length; i++) {
|
||||
const ch = field.attrs[i];
|
||||
if (ch === "(") {
|
||||
depth++;
|
||||
} else if (ch === ")") {
|
||||
if (depth === 0) {
|
||||
content = field.attrs.slice(start, i);
|
||||
break;
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
function emitDefault(field, dbCol) {
|
||||
const content = field.defaultContent;
|
||||
if (!content) return "";
|
||||
if (content === "now()") {
|
||||
if (
|
||||
content === "sql`CURRENT_TIMESTAMP`" ||
|
||||
content.includes("CURRENT_TIMESTAMP")
|
||||
) {
|
||||
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})`;
|
||||
// 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)})`;
|
||||
if (
|
||||
(content.startsWith('"') && content.endsWith('"')) ||
|
||||
(content.startsWith("'") && content.endsWith("'"))
|
||||
) {
|
||||
return `.default(${JSON.stringify(content.slice(1, -1))})`;
|
||||
}
|
||||
return `.default(${content})`;
|
||||
}
|
||||
|
||||
function modelTable(model) {
|
||||
@@ -362,8 +383,6 @@ 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(
|
||||
@@ -407,96 +426,23 @@ ${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;
|
||||
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.enumValues) {
|
||||
use("mysqlEnum");
|
||||
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
|
||||
}
|
||||
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 })`;
|
||||
if (field.isBoolean || field.prismaType === "Boolean") {
|
||||
use("boolean");
|
||||
return `boolean(${quote(name)})`;
|
||||
}
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
}
|
||||
|
||||
// ---------- Generate file ----------
|
||||
@@ -532,8 +478,8 @@ const IMPORTABLE = [
|
||||
const importList = IMPORTABLE.filter((b) => used.has(b));
|
||||
|
||||
const helpers = [
|
||||
"// MySQL TIME / DATE columns are hydrated by Prisma as JS Date (epoch 1970-01-01",
|
||||
"// for TIME). Mirror that so existing call sites keep working unchanged.",
|
||||
"// MySQL TIME / DATE columns are hydrated as JS Date (epoch 1970-01-01",
|
||||
"// for TIME). Keep this so existing call sites stay unchanged.",
|
||||
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "time";',
|
||||
@@ -563,13 +509,15 @@ 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 mirror prisma/schema.prisma (camelCase); column names are the
|
||||
// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes.
|
||||
import {
|
||||
// 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 {
|
||||
${importList.map((b) => `\t${b},`).join("\n")}
|
||||
} from "drizzle-orm/mysql-core";
|
||||
${usedSql ? `import { sql } from "drizzle-orm";\n` : ""}
|
||||
|
||||
${helpers}${body}
|
||||
`;
|
||||
|
||||
|
||||
+166
-24
@@ -1,29 +1,81 @@
|
||||
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 { 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" });
|
||||
}
|
||||
import { redis } from "../src/lib/redis";
|
||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
logger.error(context, {
|
||||
module: "jobs",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +121,90 @@ 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 prisma.websiteLoginLogs.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
await db
|
||||
.delete(WebsiteLoginLogs)
|
||||
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
|
||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Log cleanup failed");
|
||||
@@ -84,9 +214,7 @@ async function cleanupOldLogs(): Promise<void> {
|
||||
async function cleanupOldSessions(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
await prisma.passwordReset.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
|
||||
logger.info("Cleaned up expired password reset tokens", {
|
||||
module: "jobs",
|
||||
});
|
||||
@@ -96,7 +224,6 @@ async function cleanupOldSessions(): Promise<void> {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
initWorkerSentry();
|
||||
logger.info("Worker started", { module: "jobs" });
|
||||
|
||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||
@@ -108,6 +235,15 @@ 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"),
|
||||
@@ -115,10 +251,16 @@ 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,7 +16,8 @@ import {
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
import { db, User } from "../src/lib/db";
|
||||
|
||||
function getKey(appKey: string): Buffer {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
@@ -84,10 +85,10 @@ async function main() {
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
const users = await db
|
||||
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
|
||||
.from(User)
|
||||
.where(isNotNull(User.twoFactorSecret));
|
||||
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
|
||||
@@ -107,10 +108,10 @@ async function main() {
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
await db
|
||||
.update(User)
|
||||
.set({ twoFactorSecret: reEncrypted })
|
||||
.where(eq(User.id, user.id));
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
@@ -125,12 +126,10 @@ async function main() {
|
||||
if (errors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/* ---- 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("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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,15 +4,29 @@ 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/prisma", () => ({
|
||||
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } },
|
||||
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/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
@@ -34,28 +48,27 @@ 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(prisma.websiteAds.create).toHaveBeenCalled();
|
||||
expect(insertValues).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(prisma.websiteAds.create).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error on db failure", async () => {
|
||||
vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("db"));
|
||||
insertValues.mockRejectedValue(new Error("db"));
|
||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
@@ -63,7 +76,6 @@ 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 } };
|
||||
@@ -74,7 +86,7 @@ describe("deleteAd", () => {
|
||||
});
|
||||
|
||||
it("throws ActionError when not found", async () => {
|
||||
vi.mocked(prisma.websiteAds.delete).mockRejectedValue(new Error("nf"));
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
|
||||
+23
-13
@@ -1,13 +1,15 @@
|
||||
"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";
|
||||
@@ -25,15 +27,17 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const ad = await prisma.websiteAds.create({
|
||||
data: { image, createdAt: now, updatedAt: now },
|
||||
});
|
||||
const [result] = (await db.insert(WebsiteAds).values({
|
||||
image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${ad.id} (${image})`,
|
||||
description: `Created advertisement #${result.insertId} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: createAd", {
|
||||
@@ -58,10 +62,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.update({
|
||||
where: { id },
|
||||
data: { image, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteAds)
|
||||
.set({ image, updatedAt: new Date() })
|
||||
.where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
@@ -92,8 +96,14 @@ export const deleteAd = adminAction(
|
||||
async (ctx) => {
|
||||
const id = ctx.data.id;
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
} catch {
|
||||
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;
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
await logStaffActivity({
|
||||
@@ -115,7 +125,7 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
|
||||
@@ -9,6 +9,12 @@ 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,7 +1,9 @@
|
||||
"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";
|
||||
|
||||
@@ -29,3 +31,17 @@ 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,10 +1,11 @@
|
||||
"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);
|
||||
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, id));
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
"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;
|
||||
while (
|
||||
await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
for (;;) {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
@@ -41,17 +47,15 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
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,
|
||||
},
|
||||
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,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
@@ -67,9 +71,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 prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -87,8 +91,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");
|
||||
}
|
||||
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticles.delete({ where: { id } }),
|
||||
]);
|
||||
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));
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,9 +1,10 @@
|
||||
"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> {
|
||||
@@ -23,19 +24,20 @@ 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 prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
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 },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the RCON grant already succeeded for online users.
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
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/prisma", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
ban: { create: vi.fn(), delete: vi.fn() },
|
||||
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 })),
|
||||
},
|
||||
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() }));
|
||||
@@ -26,13 +41,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",
|
||||
@@ -41,9 +56,9 @@ describe("createBan", () => {
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.ban.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ userId: 42, type: "account" }),
|
||||
});
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, type: "account" }),
|
||||
);
|
||||
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
@@ -56,14 +71,14 @@ describe("createBan", () => {
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.ban.create).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liftBan", () => {
|
||||
it("deletes ban and revalidates", async () => {
|
||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(prisma.ban.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
});
|
||||
+18
-18
@@ -1,9 +1,10 @@
|
||||
"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";
|
||||
|
||||
@@ -29,23 +30,22 @@ 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 prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true },
|
||||
});
|
||||
const [user] = await db
|
||||
.select({ username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as any,
|
||||
cfhTopic: -1,
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
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 prisma.ban.delete({ where: { id } });
|
||||
await db.delete(Ban).where(eq(Ban.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"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);
|
||||
@@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!name || !subject || !body) return;
|
||||
|
||||
await prisma.emailTemplates.create({
|
||||
data: {
|
||||
name,
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
},
|
||||
await db.insert(EmailTemplates).values({
|
||||
name,
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
});
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -56,15 +55,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!subject || !body) return;
|
||||
|
||||
await prisma.emailTemplates.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(EmailTemplates)
|
||||
.set({
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(EmailTemplates.id, id));
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
@@ -72,6 +71,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await prisma.emailTemplates.delete({ where: { id } });
|
||||
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, 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,11 +20,10 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.slice(0, 512);
|
||||
if (!key) return;
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
await db
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
|
||||
@@ -38,10 +37,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await prisma.emulatorTexts.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
await db
|
||||
.insert(EmulatorTexts)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
@@ -2,23 +2,41 @@
|
||||
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/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(),
|
||||
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 })),
|
||||
},
|
||||
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() }));
|
||||
@@ -35,29 +53,32 @@ beforeEach(() => {
|
||||
|
||||
describe("disbandGuild", () => {
|
||||
it("disbands guild and cleans related data", async () => {
|
||||
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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
);
|
||||
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||
@@ -65,6 +86,6 @@ describe("disbandGuild", () => {
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
||||
expect(prisma.guilds.findUnique).not.toHaveBeenCalled();
|
||||
expect(selectLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+36
-19
@@ -1,9 +1,19 @@
|
||||
"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. */
|
||||
@@ -12,29 +22,36 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, userId: true },
|
||||
});
|
||||
const [guild] = await db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
name: Guilds.name,
|
||||
userId: Guilds.userId,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, id))
|
||||
.limit(1);
|
||||
if (!guild) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const threads = await tx.guildsForumsThreads.findMany({
|
||||
where: { guildId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
await db.transaction(async (tx) => {
|
||||
const threads = await tx
|
||||
.select({ id: GuildsForumsThreads.id })
|
||||
.from(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, id));
|
||||
const threadIds = threads.map((t) => t.id);
|
||||
if (threadIds.length > 0) {
|
||||
await tx.guildsForumsComments.deleteMany({
|
||||
where: { threadId: { in: threadIds } },
|
||||
});
|
||||
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
|
||||
await tx
|
||||
.delete(GuildsForumsComments)
|
||||
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||
await tx
|
||||
.delete(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, 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 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 logStaffActivity({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"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";
|
||||
@@ -38,25 +44,32 @@ export const liftBanFromHelpTicket = adminAction(
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true, title: true },
|
||||
});
|
||||
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);
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.userId == null) {
|
||||
throw new ActionError("Ticket has no requester to unban");
|
||||
}
|
||||
|
||||
const removed = await prisma.ban.deleteMany({
|
||||
where: { userId: ticket.userId },
|
||||
});
|
||||
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 now = new Date();
|
||||
if (ticket.open) {
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: false, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
}
|
||||
|
||||
logAudit({
|
||||
@@ -66,7 +79,7 @@ export const liftBanFromHelpTicket = adminAction(
|
||||
targetId: ticket.userId,
|
||||
after: {
|
||||
ticketId: String(ticketId),
|
||||
removedBans: removed.count,
|
||||
removedBans: removed,
|
||||
title: ticket.title,
|
||||
},
|
||||
});
|
||||
@@ -74,7 +87,7 @@ export const liftBanFromHelpTicket = adminAction(
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
||||
return actionOk({ removed: removed.count, userId: ticket.userId });
|
||||
return actionOk({ removed, userId: ticket.userId });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,31 +97,33 @@ export const replyHelpCenterTicket = adminAction(
|
||||
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
const now = new Date();
|
||||
const staffId = Number(ctx.session.user.id);
|
||||
|
||||
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 },
|
||||
}),
|
||||
]);
|
||||
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));
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: staffId,
|
||||
@@ -126,19 +141,23 @@ export const closeHelpCenterTicket = adminAction(
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: false, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: Number(ctx.session.user.id),
|
||||
@@ -158,19 +177,23 @@ export const reopenHelpCenterTicket = adminAction(
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.open) throw new ActionError("Ticket is already open");
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: true, updatedAt: now },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: true, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: Number(ctx.session.user.id),
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
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/prisma", () => ({
|
||||
prisma: {
|
||||
websiteHelpCenterCategories: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteHelpCenterCategories: { id: "id" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
@@ -31,29 +36,26 @@ 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(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled();
|
||||
expect(insertValues).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",
|
||||
@@ -61,18 +63,15 @@ describe("updateHelpQuestion", () => {
|
||||
content: "New",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled();
|
||||
expect(updateWhere).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(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled();
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
+24
-22
@@ -1,12 +1,14 @@
|
||||
"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
|
||||
@@ -32,25 +34,23 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
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,
|
||||
},
|
||||
});
|
||||
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];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_create",
|
||||
description: `Created help-center entry #${entry.id} (${name})`,
|
||||
description: `Created help-center entry #${result.insertId} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(entry.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} 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 prisma.websiteHelpCenterCategories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteHelpCenterCategories)
|
||||
.set({
|
||||
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,7 +116,9 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteHelpCenterCategories)
|
||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_delete",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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.
|
||||
@@ -34,16 +35,19 @@ export async function bulkImportPermissions(
|
||||
export async function exportPermissions(): Promise<string> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
|
||||
const perms = await prisma.websiteHousekeepingPermissions.findMany({
|
||||
orderBy: [{ groupName: "asc" }, { permission: "asc" }],
|
||||
select: {
|
||||
permission: true,
|
||||
minRank: true,
|
||||
description: true,
|
||||
groupName: true,
|
||||
dependsOn: true,
|
||||
},
|
||||
});
|
||||
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),
|
||||
);
|
||||
|
||||
return JSON.stringify(perms, null, 2);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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,
|
||||
@@ -10,15 +9,23 @@ 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/prisma", () => ({
|
||||
prisma: {
|
||||
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() },
|
||||
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteIpWhitelist: { id: "id" },
|
||||
WebsiteIpBlacklist: { id: "id" },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
@@ -30,6 +37,8 @@ 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", () => {
|
||||
@@ -37,24 +46,24 @@ describe("addWhitelist", () => {
|
||||
await addWhitelist(
|
||||
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({
|
||||
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false },
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
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(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWhitelist", () => {
|
||||
it("deletes whitelist entry", async () => {
|
||||
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
});
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +72,10 @@ describe("addBlacklist", () => {
|
||||
await addBlacklist(
|
||||
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({
|
||||
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false },
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
ipAddress: "203.0.113.1",
|
||||
asn: null,
|
||||
blacklistAsn: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,8 +83,6 @@ describe("addBlacklist", () => {
|
||||
describe("deleteBlacklist", () => {
|
||||
it("deletes blacklist entry", async () => {
|
||||
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(99) },
|
||||
});
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+16
-7
@@ -1,9 +1,10 @@
|
||||
"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") ?? "")
|
||||
@@ -25,8 +26,10 @@ export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpWhitelist.create({
|
||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
||||
await db.insert(WebsiteIpWhitelist).values({
|
||||
ipAddress,
|
||||
asn,
|
||||
whitelistAsn: asn != null,
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -37,7 +40,9 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
||||
await db
|
||||
.delete(WebsiteIpWhitelist)
|
||||
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
@@ -46,8 +51,10 @@ export async function addBlacklist(formData: FormData): Promise<void> {
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
||||
await db.insert(WebsiteIpBlacklist).values({
|
||||
ipAddress,
|
||||
asn,
|
||||
blacklistAsn: asn != null,
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -58,6 +65,8 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
||||
await db
|
||||
.delete(WebsiteIpBlacklist)
|
||||
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
|
||||
vi.hoisted(() => ({
|
||||
mockUpsert: vi.fn(),
|
||||
const {
|
||||
mockValues,
|
||||
mockOnDuplicateKeyUpdate,
|
||||
mockRequirePermission,
|
||||
mockReload,
|
||||
mockRevalidatePath,
|
||||
} = vi.hoisted(() => {
|
||||
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const mockValues = vi.fn(() => ({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
}));
|
||||
return {
|
||||
mockValues,
|
||||
mockOnDuplicateKeyUpdate,
|
||||
mockRequirePermission: vi.fn(),
|
||||
mockReload: vi.fn(),
|
||||
mockRevalidatePath: vi.fn(),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
@@ -16,10 +28,11 @@ vi.mock("@/lib/permissions", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
websiteSetting: { upsert: mockUpsert },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: mockValues })),
|
||||
},
|
||||
WebsiteSetting: { key: "key", value: "value" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
@@ -38,6 +51,10 @@ import { saveMaintenance } from "./admin-maintenance";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockValues.mockReturnValue({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
});
|
||||
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("saveMaintenance", () => {
|
||||
@@ -57,37 +74,26 @@ describe("saveMaintenance", () => {
|
||||
|
||||
expect(mockRequirePermission).toHaveBeenCalled();
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledTimes(3);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledTimes(3);
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "maintenance_enabled" },
|
||||
update: { value: "1" },
|
||||
create: expect.objectContaining({
|
||||
key: "maintenance_enabled",
|
||||
value: "1",
|
||||
}),
|
||||
key: "maintenance_enabled",
|
||||
value: "1",
|
||||
}),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "maintenance_message" },
|
||||
update: { value: "We will be back soon!" },
|
||||
create: expect.objectContaining({
|
||||
key: "maintenance_message",
|
||||
value: "We will be back soon!",
|
||||
}),
|
||||
key: "maintenance_message",
|
||||
value: "We will be back soon!",
|
||||
}),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "3" },
|
||||
create: expect.objectContaining({
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "3",
|
||||
}),
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "3",
|
||||
}),
|
||||
);
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(mockReload).toHaveBeenCalledOnce();
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||
@@ -106,16 +112,16 @@ describe("saveMaintenance", () => {
|
||||
|
||||
await saveMaintenance(fd);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "maintenance_enabled" },
|
||||
update: { value: "0" },
|
||||
key: "maintenance_enabled",
|
||||
value: "0",
|
||||
}),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "5" },
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -134,10 +140,10 @@ describe("saveMaintenance", () => {
|
||||
|
||||
await saveMaintenance(fd);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "5" },
|
||||
key: "min_maintenance_login_rank",
|
||||
value: "5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -156,10 +162,10 @@ describe("saveMaintenance", () => {
|
||||
|
||||
await saveMaintenance(fd);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: "min_maintenance_login_rank" },
|
||||
update: { value: "5" },
|
||||
key: "min_maintenance_login_rank",
|
||||
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,12 +27,15 @@ const COMMENTS: Record<string, string> = {
|
||||
};
|
||||
|
||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
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 },
|
||||
});
|
||||
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 } });
|
||||
}
|
||||
|
||||
export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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). */
|
||||
@@ -14,16 +15,23 @@ export async function cancelMarketplaceListing(
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const listing = await prisma.marketplaceItems.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, state: true, userId: true, itemId: true, price: true },
|
||||
});
|
||||
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);
|
||||
if (listing?.state !== 1) return;
|
||||
|
||||
await prisma.marketplaceItems.update({
|
||||
where: { id },
|
||||
data: { state: 0 },
|
||||
});
|
||||
await db
|
||||
.update(MarketplaceItems)
|
||||
.set({ state: 0 })
|
||||
.where(eq(MarketplaceItems.id, id));
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"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,
|
||||
@@ -50,23 +51,22 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.radioApiKeys.create({
|
||||
data: {
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
const [result] = await db.insert(RadioApiKeys).values({
|
||||
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}" (#${created.id}, rate limit ${rateLimit})`,
|
||||
description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(created.id),
|
||||
targetId: Number(createdId),
|
||||
});
|
||||
} catch {
|
||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||
@@ -84,17 +84,21 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
const existing = await prisma.radioApiKeys.findUnique({
|
||||
where: { id },
|
||||
select: { name: true, isActive: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({
|
||||
name: RadioApiKeys.name,
|
||||
isActive: RadioApiKeys.isActive,
|
||||
})
|
||||
.from(RadioApiKeys)
|
||||
.where(eq(RadioApiKeys.id, id))
|
||||
.limit(1);
|
||||
if (!existing) return;
|
||||
|
||||
const next = !existing.isActive;
|
||||
await prisma.radioApiKeys.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(RadioApiKeys)
|
||||
.set({ isActive: next, updatedAt: new Date() })
|
||||
.where(eq(RadioApiKeys.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_toggle",
|
||||
@@ -116,7 +120,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioApiKeys.delete({ where: { id } });
|
||||
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_delete",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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
|
||||
@@ -65,25 +66,24 @@ export async function createTrack(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
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 [result] = await db.insert(RadioAutoDjPlaylist).values({
|
||||
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: Number(created.id),
|
||||
targetId: createdId,
|
||||
});
|
||||
} 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 prisma.radioAutoDjPlaylist.update({
|
||||
where: { id },
|
||||
data: { isActive, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(RadioAutoDjPlaylist)
|
||||
.set({ isActive, updatedAt: new Date() })
|
||||
.where(eq(RadioAutoDjPlaylist.id, id));
|
||||
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 prisma.radioAutoDjPlaylist.delete({ where: { id } });
|
||||
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_delete",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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 ────────────────────────────────────────────────────────────────
|
||||
@@ -44,11 +45,10 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
@@ -71,14 +71,13 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
await Promise.all(
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: null },
|
||||
});
|
||||
return db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
@@ -105,17 +104,15 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.create({
|
||||
data: {
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioBanners).values({
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
@@ -139,17 +136,17 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
if (!imagePath) return;
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(RadioBanners)
|
||||
.set({
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(RadioBanners.id, id));
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
@@ -161,7 +158,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioBanners.delete({ where: { id } });
|
||||
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
@@ -181,15 +178,13 @@ export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(RadioRanks).values({
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
@@ -209,16 +204,16 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(RadioRanks)
|
||||
.set({
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(RadioRanks.id, id));
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
@@ -230,7 +225,7 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioRanks.delete({ where: { id } });
|
||||
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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. */
|
||||
@@ -28,7 +29,7 @@ export async function deleteShout(formData: FormData): Promise<void> {
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
await db.delete(RadioShouts).where(eq(RadioShouts.id, 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,15 +67,16 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
await Promise.all(
|
||||
POINTS_KEYS.map((key) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
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" },
|
||||
}),
|
||||
.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] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"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);
|
||||
@@ -24,8 +25,10 @@ export async function createCategory(formData: FormData): Promise<void> {
|
||||
if (!name || !badge) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValueCategories.create({
|
||||
data: { name, badge, priority },
|
||||
await db.insert(WebsiteRareValueCategories).values({
|
||||
name,
|
||||
badge,
|
||||
priority,
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
||||
@@ -40,8 +43,12 @@ export async function deleteCategory(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
// Remove the category's values first to avoid orphaned rows.
|
||||
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
|
||||
await prisma.websiteRareValueCategories.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteRareValues)
|
||||
.where(eq(WebsiteRareValues.categoryId, id));
|
||||
await db
|
||||
.delete(WebsiteRareValueCategories)
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
@@ -81,16 +88,14 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
.slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValues.create({
|
||||
data: {
|
||||
categoryId,
|
||||
itemId,
|
||||
name,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
},
|
||||
await db.insert(WebsiteRareValues).values({
|
||||
categoryId,
|
||||
itemId,
|
||||
name,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
});
|
||||
} catch {
|
||||
// DB error — ignore.
|
||||
@@ -104,7 +109,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValues.delete({ where: { id } });
|
||||
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"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";
|
||||
@@ -48,11 +49,10 @@ export const saveManagedSettings = adminAction(
|
||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||
await Promise.all(
|
||||
entries.map(([key, value]) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
}),
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } }),
|
||||
),
|
||||
);
|
||||
await siteSettings.reload();
|
||||
@@ -76,7 +76,10 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
await db
|
||||
.update(WebsiteSetting)
|
||||
.set({ value })
|
||||
.where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -97,11 +100,10 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
@@ -113,7 +115,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
|
||||
+38
-37
@@ -1,11 +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 { 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";
|
||||
|
||||
@@ -40,43 +42,42 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
const costs = reqUInt(formData, "costs");
|
||||
try {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
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") ?? "")
|
||||
.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: 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,
|
||||
},
|
||||
});
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${created.costs} costs)`,
|
||||
description: `Created shop package "${name}" (${costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(created.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, {
|
||||
@@ -103,9 +104,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteShopArticles)
|
||||
.set({
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -131,8 +132,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",
|
||||
@@ -159,7 +160,7 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.delete({ where: { id } });
|
||||
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_delete",
|
||||
|
||||
@@ -2,18 +2,34 @@
|
||||
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/prisma", () => ({
|
||||
prisma: {
|
||||
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
taggables: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
transaction,
|
||||
},
|
||||
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() }));
|
||||
@@ -26,12 +42,18 @@ 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",
|
||||
@@ -39,10 +61,8 @@ describe("createTag", () => {
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(prisma.tags.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ name: "News" }),
|
||||
}),
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "News" }),
|
||||
);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
@@ -50,23 +70,19 @@ describe("createTag", () => {
|
||||
|
||||
it("returns early when name is empty", async () => {
|
||||
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
||||
expect(prisma.tags.create).not.toHaveBeenCalled();
|
||||
expect(insertValues).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(prisma.tags.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ backgroundColor: "#888888" }),
|
||||
}),
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ backgroundColor: "#888888" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles db error gracefully", async () => {
|
||||
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error"));
|
||||
insertValues.mockRejectedValue(new Error("DB error"));
|
||||
|
||||
await expect(
|
||||
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||
@@ -77,8 +93,6 @@ describe("createTag", () => {
|
||||
|
||||
describe("updateTag", () => {
|
||||
it("updates a tag and revalidates", async () => {
|
||||
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
|
||||
|
||||
await updateTag(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
@@ -87,54 +101,34 @@ describe("updateTag", () => {
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(prisma.tags.update).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
data: expect.objectContaining({ name: "Updated" }),
|
||||
});
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
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(prisma.tags.update).not.toHaveBeenCalled();
|
||||
expect(updateWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when name is empty after update", async () => {
|
||||
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||
expect(prisma.tags.update).not.toHaveBeenCalled();
|
||||
expect(updateWhere).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(prisma.taggables.deleteMany).toHaveBeenCalledWith({
|
||||
where: { tagId: BigInt(42) },
|
||||
});
|
||||
expect(prisma.tags.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
});
|
||||
expect(transaction).toHaveBeenCalled();
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
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(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+19
-14
@@ -1,9 +1,11 @@
|
||||
"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 ────────────────────────────────────────────────────────────────
|
||||
@@ -40,15 +42,18 @@ export async function createTag(formData: FormData): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.tags.create({
|
||||
data: { name, backgroundColor, createdAt: now, updatedAt: now },
|
||||
});
|
||||
const [result] = (await db.insert(Tags).values({
|
||||
name,
|
||||
backgroundColor,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_create",
|
||||
description: `Created tag "${name}" (#${created.id})`,
|
||||
description: `Created tag "${name}" (#${result.insertId})`,
|
||||
targetType: "tag",
|
||||
targetId: Number(created.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable or duplicate.
|
||||
@@ -66,10 +71,10 @@ export async function updateTag(formData: FormData): Promise<void> {
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.tags.update({
|
||||
where: { id },
|
||||
data: { name, backgroundColor, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(Tags)
|
||||
.set({ name, backgroundColor, updatedAt: new Date() })
|
||||
.where(eq(Tags.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_update",
|
||||
@@ -90,10 +95,10 @@ export async function deleteTag(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
// Remove the tag and any taggable links pointing at it.
|
||||
await prisma.$transaction([
|
||||
prisma.taggables.deleteMany({ where: { tagId: id } }),
|
||||
prisma.tags.delete({ where: { id } }),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
|
||||
await tx.delete(Tags).where(eq(Tags.id, id));
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_delete",
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
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/prisma", () => ({
|
||||
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteTeams: { id: "id" },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
@@ -20,6 +29,8 @@ 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", () => {
|
||||
@@ -27,25 +38,22 @@ describe("createTeam", () => {
|
||||
await createTeam(
|
||||
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ rankName: "Moderator" }),
|
||||
});
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
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(prisma.websiteTeams.create).not.toHaveBeenCalled();
|
||||
expect(insertValues).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(prisma.websiteTeams.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
});
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
});
|
||||
});
|
||||
+11
-12
@@ -1,9 +1,10 @@
|
||||
"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);
|
||||
@@ -26,16 +27,14 @@ export async function createTeam(formData: FormData): Promise<void> {
|
||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||
|
||||
const now = new Date();
|
||||
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,
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
@@ -45,7 +44,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
await prisma.websiteTeams.delete({ where: { id } });
|
||||
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, 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,11 +24,10 @@ 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 prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: "Theme (housekeeping)" },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
export async function saveTheme(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"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,
|
||||
@@ -45,19 +47,17 @@ export async function createVoucher(input: {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.websiteShopVouchers.create({
|
||||
data: {
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
useCount: 0,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
const [result] = (await db.insert(WebsiteShopVouchers).values({
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
useCount: 0,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk({ id: String(created.id) });
|
||||
return actionOk({ id: String(result.insertId) });
|
||||
} 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 prisma.websiteShopVouchers.delete({ where: { id } });
|
||||
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
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/prisma", () => ({
|
||||
prisma: { websiteSetting: { upsert: vi.fn() } },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: mockValues })),
|
||||
},
|
||||
WebsiteSetting: { key: "key", value: "value" },
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { reload: vi.fn() },
|
||||
@@ -27,7 +37,10 @@ const fakeForm = (data: Record<string, string | null>) => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never);
|
||||
mockValues.mockReturnValue({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
});
|
||||
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("saveVpn", () => {
|
||||
@@ -39,7 +52,8 @@ describe("saveVpn", () => {
|
||||
vpn_api_key: "abc123",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4);
|
||||
expect(mockValues).toHaveBeenCalledTimes(4);
|
||||
expect(mockOnDuplicateKeyUpdate).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,11 +21,10 @@ async function writeSetting(
|
||||
value: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment },
|
||||
});
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
export async function saveVpn(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"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,
|
||||
@@ -23,11 +25,13 @@ export async function addWord(input: {
|
||||
if (!word) return actionError("Word is required");
|
||||
|
||||
try {
|
||||
const created = await prisma.websiteWordfilter.create({ data: { word } });
|
||||
const [result] = (await db
|
||||
.insert(WebsiteWordfilter)
|
||||
.values({ word })) as unknown as [ResultSetHeader];
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
revalidatePath("/admin/wordfilter");
|
||||
return actionOk({ id: String(created.id) });
|
||||
return actionOk({ id: String(result.insertId) });
|
||||
} catch {
|
||||
return actionError("Could not add word (it may already exist)");
|
||||
}
|
||||
@@ -39,7 +43,9 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||
if (!raw) return actionError("Missing word id");
|
||||
|
||||
try {
|
||||
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
|
||||
await db
|
||||
.delete(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
revalidatePath("/admin/wordfilter");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"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
|
||||
@@ -51,28 +53,25 @@ export async function createBox(formData: FormData): Promise<void> {
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
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,
|
||||
},
|
||||
});
|
||||
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];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_create",
|
||||
description: `Created writeable box "${title}" (#${created.id})`,
|
||||
description: `Created writeable box "${title}" (#${result.insertId})`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(created.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — swallow and re-render.
|
||||
@@ -95,9 +94,9 @@ export async function updateBox(formData: FormData): Promise<void> {
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteWriteableBoxes)
|
||||
.set({
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
@@ -109,8 +108,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",
|
||||
@@ -132,7 +131,9 @@ export async function deleteBox(formData: FormData): Promise<void> {
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteWriteableBoxes)
|
||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_delete",
|
||||
@@ -157,10 +158,10 @@ export async function toggleBox(formData: FormData): Promise<void> {
|
||||
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteWriteableBoxes)
|
||||
.set({ isActive: next, updatedAt: new Date() })
|
||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_toggle",
|
||||
|
||||
@@ -2,64 +2,83 @@
|
||||
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/prisma", () => ({
|
||||
prisma: { websiteStaffApplications: { findFirst: vi.fn(), create: 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/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: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
const fakeForm = (data) => ({
|
||||
get: (key) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } } as never);
|
||||
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } });
|
||||
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!",
|
||||
}) as unknown as FormData,
|
||||
}),
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 42,
|
||||
rankId: 3,
|
||||
content: "I want to help!",
|
||||
}),
|
||||
);
|
||||
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({}) as unknown as FormData);
|
||||
await applyStaff(fakeForm({}));
|
||||
expect(redirect).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("returns duplicate status when application exists", async () => {
|
||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue({
|
||||
id: 1,
|
||||
} as never);
|
||||
selectLimit.mockResolvedValue([{ id: 1 }]);
|
||||
await applyStaff(
|
||||
fakeForm({
|
||||
rankId: "3",
|
||||
content: "I want to help!",
|
||||
}) as unknown as FormData,
|
||||
}),
|
||||
);
|
||||
expect(prisma.websiteStaffApplications.create).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=duplicate");
|
||||
});
|
||||
|
||||
@@ -69,7 +88,7 @@ describe("applyStaff", () => {
|
||||
fakeForm({
|
||||
rankId: "3",
|
||||
content: "I want to help!",
|
||||
}) as unknown as FormData,
|
||||
}),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit");
|
||||
});
|
||||
@@ -77,25 +96,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!",
|
||||
}) as unknown as FormData,
|
||||
}),
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 42,
|
||||
rankId: 2,
|
||||
content: "I want to join team!",
|
||||
}),
|
||||
);
|
||||
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({}) as unknown as FormData);
|
||||
await applyTeam(fakeForm({}));
|
||||
expect(redirect).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
+34
-13
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
||||
@@ -67,16 +68,26 @@ export async function applyStaff(formData: FormData): Promise<void> {
|
||||
if (content.length < CONTENT_MIN) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteStaffApplications.id })
|
||||
.from(WebsiteStaffApplications)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteStaffApplications.userId, userId),
|
||||
eq(WebsiteStaffApplications.rankId, rankId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
outcome = "duplicate";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
await db.insert(WebsiteStaffApplications).values({
|
||||
userId,
|
||||
rankId,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
@@ -120,16 +131,26 @@ export async function applyTeam(formData: FormData): Promise<void> {
|
||||
if (content.length < CONTENT_MIN) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteStaffApplications.id })
|
||||
.from(WebsiteStaffApplications)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteStaffApplications.userId, userId),
|
||||
eq(WebsiteStaffApplications.rankId, rankId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
outcome = "duplicate";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
await db.insert(WebsiteStaffApplications).values({
|
||||
userId,
|
||||
rankId,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, WebsiteArticleComments, WebsiteArticles } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
@@ -83,23 +84,22 @@ export async function postComment(formData: FormData): Promise<void> {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
const [article] = await db
|
||||
.select({ slug: WebsiteArticles.slug })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, articleId))
|
||||
.limit(1);
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
slug = article.slug;
|
||||
const now = new Date();
|
||||
await prisma.websiteArticleComments.create({
|
||||
data: {
|
||||
articleId,
|
||||
userId,
|
||||
comment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(WebsiteArticleComments).values({
|
||||
articleId,
|
||||
userId,
|
||||
comment,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, WebsiteArticleReactions, WebsiteArticles } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// The reaction set the UI offers. The action rejects anything outside this list
|
||||
@@ -75,39 +76,59 @@ export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
} else {
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
const [article] = await db
|
||||
.select({ slug: WebsiteArticles.slug })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, articleId))
|
||||
.limit(1);
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
slug = article.slug;
|
||||
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
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);
|
||||
|
||||
if (existing?.active) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteArticleReactions)
|
||||
.set({ active: false })
|
||||
.where(eq(WebsiteArticleReactions.id, existing.id));
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteArticleReactions)
|
||||
.set({ active: false })
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticleReactions.userId, userId),
|
||||
eq(WebsiteArticleReactions.articleId, articleId),
|
||||
eq(WebsiteArticleReactions.active, true),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteArticleReactions)
|
||||
.set({ active: true })
|
||||
.where(eq(WebsiteArticleReactions.id, existing.id));
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
await db.insert(WebsiteArticleReactions).values({
|
||||
userId,
|
||||
articleId,
|
||||
reaction,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
// @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/prisma", () => ({ prisma: { user: { findUnique: 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/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/services/captcha", () => ({
|
||||
captchaConfig: vi.fn(),
|
||||
@@ -25,26 +46,31 @@ 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 () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
},
|
||||
]);
|
||||
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns twofactor when 2FA is set up", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: new Date(),
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: new Date(),
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
},
|
||||
]);
|
||||
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
||||
});
|
||||
|
||||
@@ -57,29 +83,33 @@ describe("precheckLogin", () => {
|
||||
provider: "hcaptcha",
|
||||
} as never);
|
||||
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
},
|
||||
]);
|
||||
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
||||
});
|
||||
|
||||
it("returns invalid when user not found (dummy hash check)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
selectLimit.mockResolvedValue([]);
|
||||
const result = await precheckLogin("nonexistent", "pass");
|
||||
expect(result).toBe("invalid");
|
||||
expect(checkLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unverified when email verification required", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: "[email protected]",
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: "[email protected]",
|
||||
mailVerified: "0",
|
||||
},
|
||||
]);
|
||||
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
||||
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "@/env";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -45,15 +46,17 @@ export async function precheckLogin(
|
||||
mailVerified: string;
|
||||
} | null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: u },
|
||||
select: {
|
||||
password: true,
|
||||
twoFactorConfirmedAt: true,
|
||||
mail: true,
|
||||
mailVerified: true,
|
||||
},
|
||||
});
|
||||
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;
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
+24
-13
@@ -1,16 +1,21 @@
|
||||
"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 prisma.websiteBadges.findUnique({
|
||||
where: { badgeKey: code },
|
||||
select: { badgeName: true, badgeDescription: true },
|
||||
});
|
||||
const [badge] = await db
|
||||
.select({
|
||||
badgeName: WebsiteBadges.badgeName,
|
||||
badgeDescription: WebsiteBadges.badgeDescription,
|
||||
})
|
||||
.from(WebsiteBadges)
|
||||
.where(eq(WebsiteBadges.badgeKey, code))
|
||||
.limit(1);
|
||||
if (!badge) return { ok: false as const, data: null };
|
||||
return {
|
||||
ok: true as const,
|
||||
@@ -28,16 +33,22 @@ export async function updateBadge({
|
||||
desc: string;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
||||
create: {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
.values({
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/import/badges");
|
||||
}
|
||||
+17
-8
@@ -1,8 +1,10 @@
|
||||
"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";
|
||||
@@ -22,15 +24,18 @@ const bannerSchema = z.object({
|
||||
export const createBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||
async (ctx) => {
|
||||
const banner = await prisma.websiteBanner.create({ data: ctx.data });
|
||||
const [result] = (await db
|
||||
.insert(WebsiteBanner)
|
||||
.values(ctx.data)) as unknown as [ResultSetHeader];
|
||||
const id = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_create",
|
||||
target: "WebsiteBanner",
|
||||
targetId: banner.id,
|
||||
after: { title: banner.title },
|
||||
targetId: id,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
return actionOk({ id: banner.id });
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -42,9 +47,13 @@ export const updateBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteBanner.findUnique({ where: { id } });
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteBanner.id })
|
||||
.from(WebsiteBanner)
|
||||
.where(eq(WebsiteBanner.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Banner not found");
|
||||
await prisma.websiteBanner.update({ where: { id }, data });
|
||||
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_update",
|
||||
@@ -60,7 +69,7 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
|
||||
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_delete",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @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,
|
||||
@@ -10,14 +9,78 @@ 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/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/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/services/rcon", () => ({
|
||||
@@ -35,11 +98,22 @@ 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 });
|
||||
@@ -48,7 +122,6 @@ 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",
|
||||
@@ -56,12 +129,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,
|
||||
@@ -69,10 +142,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,
|
||||
@@ -80,10 +153,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,
|
||||
@@ -91,17 +164,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 () => {
|
||||
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);
|
||||
selectLimit.mockResolvedValueOnce([]);
|
||||
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
|
||||
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
|
||||
});
|
||||
});
|
||||
+77
-61
@@ -1,10 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, Sanctions, User, UsersSettings } from "@/lib/db";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
Sanctions,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
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";
|
||||
@@ -15,18 +22,19 @@ export async function bulkUnban({
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const result = await prisma.ban.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
|
||||
const unbanned = Number(
|
||||
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||
);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_unban",
|
||||
description: `Unbanned ${result.count} user(s)`,
|
||||
description: `Unbanned ${unbanned} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { unbanned: result.count, total: userIds.length },
|
||||
data: { unbanned, total: userIds.length },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,17 +53,15 @@ export async function bulkBan({
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire: duration > 0 ? now + duration : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
},
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire: duration > 0 ? now + duration : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
});
|
||||
banned++;
|
||||
} catch {
|
||||
@@ -94,24 +100,26 @@ export async function bulkGiveCurrency({
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { increment: amount } },
|
||||
});
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.where(eq(User.id, userId));
|
||||
await rcon.giveCredits(userId, amount);
|
||||
} else if (type === "pixels") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: 0 } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: 0, amount },
|
||||
});
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: 0, amount })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} else if (type === "points") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: 101 } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: 101, amount },
|
||||
});
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: 101, amount })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
await rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
given++;
|
||||
@@ -151,19 +159,23 @@ export async function bulkGiveBadge({
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersBadges.userId, userId),
|
||||
eq(UsersBadges.badgeCode, badgeCode),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
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 },
|
||||
});
|
||||
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 });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
}
|
||||
given++;
|
||||
@@ -228,31 +240,35 @@ export async function bulkAdjustCurrency({
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
const [user] = await db
|
||||
.select({ credits: User.credits })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
failedIds.push({ userId, reason: "Not found" });
|
||||
continue;
|
||||
}
|
||||
const next = Math.max(0, user.credits - take);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: next },
|
||||
});
|
||||
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
const row = await prisma.usersCurrency.findUnique({
|
||||
where: { userId_type: { userId, type: currencyType } },
|
||||
});
|
||||
const [row] = await db
|
||||
.select({ amount: UsersCurrency.amount })
|
||||
.from(UsersCurrency)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersCurrency.userId, userId),
|
||||
eq(UsersCurrency.type, currencyType),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const current = row?.amount ?? 0;
|
||||
const next = Math.max(0, current - take);
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: currencyType } },
|
||||
update: { amount: next },
|
||||
create: { userId, type: currencyType, amount: next },
|
||||
});
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: currencyType, amount: next })
|
||||
.onDuplicateKeyUpdate({ set: { amount: next } });
|
||||
}
|
||||
adjusted++;
|
||||
} catch {
|
||||
|
||||
+84
-75
@@ -1,9 +1,10 @@
|
||||
"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";
|
||||
|
||||
@@ -55,10 +56,10 @@ export async function updateBcPage({
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
});
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -73,7 +74,7 @@ export async function updateBcPage({
|
||||
|
||||
export async function deleteBcItem({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await prisma.catalogItemsBc.delete({ where: { id } });
|
||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -101,10 +102,10 @@ export async function updateBcItem({
|
||||
if (Object.keys(safe).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await prisma.catalogItemsBc.update({
|
||||
where: { id },
|
||||
data: safe as any,
|
||||
});
|
||||
await db
|
||||
.update(CatalogItemsBc)
|
||||
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||
.where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -128,19 +129,18 @@ export async function createBcItem({
|
||||
extradata: string;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const created = await prisma.catalogItemsBc.create({
|
||||
data: { pageId, ...data },
|
||||
});
|
||||
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${created.id}`,
|
||||
description: `Created BC catalog item #${createdId}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: created.id,
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
export async function toggleBcPage({
|
||||
@@ -151,15 +151,19 @@ export async function toggleBcPage({
|
||||
field: "enabled" | "visible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const page = await prisma.catalogPagesBc.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
visible: CatalogPagesBc.visible,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: { [field]: page[field] === "1" ? "0" : "1" },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
@@ -176,31 +180,30 @@ export async function createBcPage(input: {
|
||||
orderNum?: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
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 [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 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: created.id,
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
|
||||
@@ -210,18 +213,19 @@ 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 prisma.catalogPagesBc.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPagesBc.id, pageId));
|
||||
}
|
||||
|
||||
export async function reorderBcTreePage(input: {
|
||||
@@ -240,10 +244,10 @@ export async function reorderBcTreePage(input: {
|
||||
};
|
||||
}
|
||||
}
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id: input.pageId },
|
||||
data: { orderNum: input.newOrderNum },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
@@ -255,43 +259,48 @@ export async function deleteBcTreePage(input: {
|
||||
mode: "reparent" | "cascade";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const page = await prisma.catalogPagesBc.findUnique({
|
||||
where: { id: input.pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
|
||||
if (input.mode === "reparent") {
|
||||
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 } }),
|
||||
]);
|
||||
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));
|
||||
});
|
||||
} else {
|
||||
const toDelete: number[] = [input.pageId];
|
||||
const queue: number[] = [input.pageId];
|
||||
while (queue.length > 0) {
|
||||
const children = await prisma.catalogPagesBc.findMany({
|
||||
where: { parentId: { in: queue } },
|
||||
select: { id: true },
|
||||
});
|
||||
const children = await db
|
||||
.select({ id: CatalogPagesBc.id })
|
||||
.from(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
await prisma.$transaction([
|
||||
prisma.catalogItemsBc.deleteMany({
|
||||
where: { pageId: { in: toDelete } },
|
||||
}),
|
||||
prisma.catalogPagesBc.deleteMany({
|
||||
where: { id: { in: toDelete } },
|
||||
}),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||
});
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { eq, inArray, like, or, 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 prisma.$executeRaw`
|
||||
await db.execute(sql`
|
||||
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,10 +127,14 @@ export async function createCatalogItem(data: {
|
||||
if (!catalogName) {
|
||||
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||
if (firstId > 0) {
|
||||
const base = await prisma.itemsBase.findUnique({
|
||||
where: { id: firstId },
|
||||
select: { publicName: true, itemName: true },
|
||||
});
|
||||
const [base] = await db
|
||||
.select({
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, firstId))
|
||||
.limit(1);
|
||||
catalogName = base?.publicName || base?.itemName || String(firstId);
|
||||
}
|
||||
}
|
||||
@@ -169,10 +173,14 @@ export async function bulkCreateCatalogItems({
|
||||
}
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await prisma.itemsBase.findMany({
|
||||
where: { id: { in: baseIds } },
|
||||
select: { id: true, publicName: true, itemName: true },
|
||||
});
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
let created = 0;
|
||||
@@ -225,7 +233,7 @@ export async function bulkCreateCatalogItems({
|
||||
|
||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
|
||||
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -249,11 +257,14 @@ export async function moveCatalogItems({
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
const pageIdStr = String(targetPageId);
|
||||
await prisma.$executeRaw`
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}
|
||||
WHERE id IN (${sql.join(ids, sql`, `)})
|
||||
`;
|
||||
WHERE id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`);
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
@@ -266,7 +277,10 @@ export async function reorderCatalogItems({
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ orderNumber })
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
@@ -292,25 +306,25 @@ export async function updateCatalogItem({
|
||||
const pageIdRaw = safeCatalog.pageId;
|
||||
if (pageIdRaw !== undefined) {
|
||||
const pageIdStr = String(pageIdRaw);
|
||||
await prisma.$executeRaw`
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||
`;
|
||||
`);
|
||||
delete safeCatalog.pageId;
|
||||
}
|
||||
|
||||
if (Object.keys(safeCatalog).length > 0) {
|
||||
await prisma.catalogItems.update({
|
||||
where: { id },
|
||||
data: safeCatalog as any,
|
||||
});
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
if (baseItem) {
|
||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: baseItem.id },
|
||||
data: safeBase as any,
|
||||
});
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, baseItem.id));
|
||||
}
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
@@ -355,16 +369,17 @@ export async function translateCatalogItems(input: {
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const base = await prisma.itemsBase.findUnique({
|
||||
where: { id: item.id },
|
||||
select: {
|
||||
id: true,
|
||||
publicName: true,
|
||||
itemName: true,
|
||||
type: true,
|
||||
spriteId: true,
|
||||
},
|
||||
});
|
||||
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);
|
||||
if (!base) continue;
|
||||
|
||||
const nextName = item.publicName?.trim() ?? "";
|
||||
@@ -372,28 +387,31 @@ export async function translateCatalogItems(input: {
|
||||
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
||||
|
||||
if (nameChanged) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: base.id },
|
||||
data: { publicName: nextName },
|
||||
});
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, base.id));
|
||||
const idStr = String(base.id);
|
||||
const related = await prisma.catalogItems.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ itemIds: idStr },
|
||||
{ itemIds: { startsWith: `${idStr};` } },
|
||||
{ itemIds: { contains: `;${idStr};` } },
|
||||
{ itemIds: { endsWith: `;${idStr}` } },
|
||||
],
|
||||
},
|
||||
select: { id: true, catalogName: true },
|
||||
});
|
||||
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}`),
|
||||
),
|
||||
);
|
||||
for (const row of related) {
|
||||
if (row.catalogName !== nextName) {
|
||||
await prisma.catalogItems.update({
|
||||
where: { id: row.id },
|
||||
data: { catalogName: nextName },
|
||||
});
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
}
|
||||
}
|
||||
namesUpdated++;
|
||||
|
||||
+41
-37
@@ -1,9 +1,10 @@
|
||||
"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";
|
||||
@@ -54,10 +55,10 @@ export async function updateCatalogPage({
|
||||
if (typeof data.caption === "string" && !data.captionSave) {
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
await prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
});
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -93,17 +94,21 @@ export async function toggleCatalogPage({
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPages.enabled,
|
||||
visible: CatalogPages.visible,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, id))
|
||||
.limit(1);
|
||||
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 prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: { [field]: current === "1" ? "0" : "1" },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ [field]: current === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
@@ -121,35 +126,34 @@ export async function createCatalogPage(input: {
|
||||
orderNum?: number;
|
||||
}): Promise<ActionResult<{ id: number }>> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
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 [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 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: created.id,
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
export async function reorderTreePage(input: {
|
||||
@@ -168,10 +172,10 @@ export async function reorderTreePage(input: {
|
||||
};
|
||||
}
|
||||
}
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: input.pageId },
|
||||
data: { orderNum: input.newOrderNum },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPages.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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";
|
||||
@@ -199,15 +200,22 @@ export const setRank = adminAction(
|
||||
async (ctx) => {
|
||||
const staffRank = Number(ctx.session.user.rank);
|
||||
const isSuper = ctx.permissions.isSuperAdmin;
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: ctx.data.userId },
|
||||
select: { rank: true },
|
||||
});
|
||||
const [target] = await db
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, ctx.data.userId))
|
||||
.limit(1);
|
||||
if (!target) throw new ActionError("User not found");
|
||||
|
||||
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
|
||||
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
|
||||
`.catch(() => [] as { id: number }[]);
|
||||
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 = [];
|
||||
}
|
||||
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
|
||||
|
||||
if (!isSuper) {
|
||||
@@ -222,10 +230,10 @@ export const setRank = adminAction(
|
||||
}
|
||||
|
||||
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||
await prisma.user.update({
|
||||
where: { id: ctx.data.userId },
|
||||
data: { rank: ctx.data.rank },
|
||||
});
|
||||
await db
|
||||
.update(User)
|
||||
.set({ rank: ctx.data.rank })
|
||||
.where(eq(User.id, ctx.data.userId));
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
|
||||
+40
-25
@@ -1,9 +1,10 @@
|
||||
"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 { prisma } from "@/lib/prisma";
|
||||
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -63,10 +64,15 @@ 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 prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
});
|
||||
const [badge] = await db
|
||||
.select({
|
||||
id: WebsiteDrawbadges.id,
|
||||
badgePath: WebsiteDrawbadges.badgePath,
|
||||
published: WebsiteDrawbadges.published,
|
||||
})
|
||||
.from(WebsiteDrawbadges)
|
||||
.where(eq(WebsiteDrawbadges.id, BigInt(rawId)))
|
||||
.limit(1);
|
||||
|
||||
if (!badge?.published) {
|
||||
outcome = "invalid";
|
||||
@@ -78,34 +84,43 @@ 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 prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
const [buyer] = await db
|
||||
.select({ credits: User.credits })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
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 prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} - ${price}` })
|
||||
.where(eq(User.id, userId));
|
||||
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await tx
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersBadges.userId, userId),
|
||||
eq(UsersBadges.badgeCode, code),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
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 },
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
// @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/prisma", () => ({
|
||||
prisma: { emulatorSettings: { upsert: vi.fn() } },
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
},
|
||||
EmulatorSettings: { key: "key", value: "value" },
|
||||
}));
|
||||
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() }));
|
||||
@@ -18,7 +29,6 @@ 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: {
|
||||
@@ -31,7 +41,7 @@ describe("saveEmulatorSettings", () => {
|
||||
session: { user: { id: "1" } },
|
||||
});
|
||||
|
||||
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(insertValues).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,11 +18,10 @@ export const saveEmulatorSettings = adminAction(
|
||||
const entries = Object.entries(ctx.data.settings);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) },
|
||||
});
|
||||
await db
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value: String(value) })
|
||||
.onDuplicateKeyUpdate({ set: { value: String(value) } });
|
||||
}
|
||||
|
||||
await rcon.updateConfig();
|
||||
|
||||
+103
-56
@@ -1,9 +1,17 @@
|
||||
"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";
|
||||
@@ -21,17 +29,16 @@ import {
|
||||
export const createEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
||||
async (ctx) => {
|
||||
const eventType = await prisma.websiteEventType.create({
|
||||
data: ctx.data,
|
||||
});
|
||||
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
|
||||
const eventTypeId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_create",
|
||||
target: "WebsiteEventType",
|
||||
targetId: eventType.id,
|
||||
after: { name: eventType.name },
|
||||
targetId: eventTypeId,
|
||||
after: { name: ctx.data.name },
|
||||
});
|
||||
return actionOk({ id: eventType.id });
|
||||
return actionOk({ id: eventTypeId });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,12 +50,17 @@ export const updateEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteEventType.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
||||
.from(WebsiteEventType)
|
||||
.where(eq(WebsiteEventType.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event type not found");
|
||||
|
||||
await prisma.websiteEventType.update({ where: { id }, data });
|
||||
await db
|
||||
.update(WebsiteEventType)
|
||||
.set(data)
|
||||
.where(eq(WebsiteEventType.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_update",
|
||||
@@ -68,12 +80,16 @@ const deleteEventTypeInput = z.object({
|
||||
export const deleteEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||
async (ctx) => {
|
||||
const existing = await prisma.websiteEventType.findUnique({
|
||||
where: { id: ctx.data.id },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
||||
.from(WebsiteEventType)
|
||||
.where(eq(WebsiteEventType.id, ctx.data.id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event type not found");
|
||||
|
||||
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
|
||||
await db
|
||||
.delete(WebsiteEventType)
|
||||
.where(eq(WebsiteEventType.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_delete",
|
||||
@@ -90,20 +106,21 @@ export const deleteEventType = adminAction(
|
||||
export const createEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||
async (ctx) => {
|
||||
const event = await prisma.websiteEvent.create({
|
||||
data: {
|
||||
...ctx.data,
|
||||
hostUserId: Number(ctx.session.user.id),
|
||||
},
|
||||
const now = new Date();
|
||||
const [result] = await db.insert(WebsiteEvent).values({
|
||||
...ctx.data,
|
||||
hostUserId: Number(ctx.session.user.id),
|
||||
updatedAt: now,
|
||||
});
|
||||
const eventId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_create",
|
||||
target: "WebsiteEvent",
|
||||
targetId: event.id,
|
||||
after: { title: event.title },
|
||||
targetId: eventId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
return actionOk({ id: event.id });
|
||||
return actionOk({ id: eventId });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -115,10 +132,21 @@ export const updateEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteEvent.findUnique({ where: { id } });
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.where(eq(WebsiteEvent.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
|
||||
await prisma.websiteEvent.update({ where: { id }, data });
|
||||
await db
|
||||
.update(WebsiteEvent)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(WebsiteEvent.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_update",
|
||||
@@ -138,12 +166,14 @@ const deleteEventInput = z.object({
|
||||
export const deleteEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||
async (ctx) => {
|
||||
const existing = await prisma.websiteEvent.findUnique({
|
||||
where: { id: ctx.data.id },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
|
||||
.from(WebsiteEvent)
|
||||
.where(eq(WebsiteEvent.id, ctx.data.id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
|
||||
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
|
||||
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_delete",
|
||||
@@ -160,8 +190,8 @@ export const deleteEvent = adminAction(
|
||||
export const addEventPrize = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||
async (ctx) => {
|
||||
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
|
||||
return actionOk({ id: prize.id });
|
||||
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
|
||||
return actionOk({ id: Number(result.insertId) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -170,7 +200,9 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteEventPrize = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
|
||||
await db
|
||||
.delete(WebsiteEventPrize)
|
||||
.where(eq(WebsiteEventPrize.id, ctx.data.id));
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -180,19 +212,20 @@ export const deleteEventPrize = adminAction(
|
||||
export const addEventWinner = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||
async (ctx) => {
|
||||
const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
|
||||
const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
|
||||
const winnerId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_winner_add",
|
||||
target: "WebsiteEventWinner",
|
||||
targetId: winner.id,
|
||||
targetId: winnerId,
|
||||
after: {
|
||||
eventId: ctx.data.eventId,
|
||||
userId: ctx.data.userId,
|
||||
position: ctx.data.position,
|
||||
},
|
||||
});
|
||||
return actionOk({ id: winner.id });
|
||||
return actionOk({ id: winnerId });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -211,13 +244,18 @@ export const registerForEvent = authAction(
|
||||
return actionError("Unauthorized");
|
||||
}
|
||||
|
||||
const event = await prisma.websiteEvent.findUnique({
|
||||
where: { id: ctx.data.eventId },
|
||||
include: {
|
||||
type: true,
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
||||
if (!event) return actionError("Event not found");
|
||||
if (event.status !== "published") {
|
||||
@@ -226,28 +264,37 @@ export const registerForEvent = authAction(
|
||||
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
|
||||
return actionError("This event has already ended");
|
||||
}
|
||||
if (event.type.minRank > 0) {
|
||||
if (event.minRank > 0) {
|
||||
const rank = Number(ctx.session.user.rank ?? 0);
|
||||
if (rank < event.type.minRank) {
|
||||
if (rank < event.minRank) {
|
||||
return actionError("Your rank is too low to join this event");
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.maxPlayers != null &&
|
||||
event._count.registrations >= event.maxPlayers
|
||||
) {
|
||||
return actionError("This event is full");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await prisma.websiteEventRegistration.findUnique({
|
||||
where: {
|
||||
eventId_userId: { eventId: event.id, userId },
|
||||
},
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEventRegistration.id })
|
||||
.from(WebsiteEventRegistration)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteEventRegistration.eventId, event.id),
|
||||
eq(WebsiteEventRegistration.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return actionError("You are already registered");
|
||||
|
||||
await prisma.websiteEventRegistration.create({
|
||||
data: { eventId: event.id, userId },
|
||||
await db.insert(WebsiteEventRegistration).values({
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
import { db, WebsiteUserGuestbooks } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
@@ -78,14 +78,12 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
outcome = "moderated";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteUserGuestbooks.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(WebsiteUserGuestbooks).values({
|
||||
profileId,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
|
||||
+84
-67
@@ -1,11 +1,17 @@
|
||||
"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 { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
db,
|
||||
WebsiteHelpCenterCategories,
|
||||
WebsiteHelpCenterTicketReplies,
|
||||
WebsiteHelpCenterTickets,
|
||||
} from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
@@ -109,34 +115,30 @@ 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 prisma.websiteHelpCenterCategories.findFirst(
|
||||
{
|
||||
where: { name: { equals: "Ban appeal" } },
|
||||
select: { id: true },
|
||||
},
|
||||
);
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteHelpCenterCategories.id })
|
||||
.from(WebsiteHelpCenterCategories)
|
||||
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
categoryId = existing.id;
|
||||
} else {
|
||||
try {
|
||||
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 prisma.websiteHelpCenterCategories.findFirst({
|
||||
where: { name: { equals: "Ban appeal" } },
|
||||
select: { id: true },
|
||||
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);
|
||||
} catch {
|
||||
const [again] = await db
|
||||
.select({ id: WebsiteHelpCenterCategories.id })
|
||||
.from(WebsiteHelpCenterCategories)
|
||||
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
|
||||
.limit(1);
|
||||
categoryId = again?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -148,16 +150,14 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title: ticketTitle,
|
||||
content,
|
||||
categoryId,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(WebsiteHelpCenterTickets).values({
|
||||
userId,
|
||||
title: ticketTitle,
|
||||
content,
|
||||
categoryId,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "created";
|
||||
}
|
||||
@@ -204,10 +204,15 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
if (!parsed.success) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
userId: WebsiteHelpCenterTickets.userId,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket || ticket.userId !== userId) {
|
||||
outcome = "not_found";
|
||||
@@ -223,30 +228,37 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const created = await prisma.$transaction((tx) =>
|
||||
const created = await db.transaction(async (tx) =>
|
||||
createOwnedTicketReply(
|
||||
{
|
||||
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,
|
||||
},
|
||||
}),
|
||||
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,
|
||||
};
|
||||
},
|
||||
touchTicket: (id, updatedAt) =>
|
||||
tx.websiteHelpCenterTickets.update({
|
||||
where: { id },
|
||||
data: { updatedAt },
|
||||
select: { id: true },
|
||||
}),
|
||||
tx
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ updatedAt })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, id)),
|
||||
},
|
||||
{
|
||||
ticketId,
|
||||
@@ -289,10 +301,15 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true },
|
||||
});
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
userId: WebsiteHelpCenterTickets.userId,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket || ticket.userId !== userId) {
|
||||
outcome = "not_found";
|
||||
@@ -300,10 +317,10 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
|
||||
outcome = "closed_ticket";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: false, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
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