Compare commits
41
Commits
efc10c168b
...
9a48060063
| 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
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
APP_KEY=base64:your-app-key-here=
|
APP_KEY=base64:your-app-key-here=
|
||||||
CONVERT_PASSWORDS=true
|
CONVERT_PASSWORDS=true
|
||||||
BCRYPT_ROUNDS=12
|
ARGON2_MEMORY_KB=65536
|
||||||
|
ARGON2_ITERATIONS=4
|
||||||
|
ARGON2_PARALLELISM=1
|
||||||
|
|
||||||
# --- PATHS ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
EMULATOR_BACKUP_DIR=./backups/emulator
|
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||||
EMULATOR_BACKUP_KEEP=7
|
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 (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
@@ -63,7 +70,5 @@ PAYPAL_CLIENT_ID=
|
|||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||||
|
|
||||||
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
|
# --- LOGGING ---
|
||||||
LOG_LEVEL=error
|
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 REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Generate Prisma type stubs for facade type-checking (dev only).
|
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||||
pnpm prisma:generate
|
|
||||||
# Run lint + typecheck + tests on the Drizzle-backed codebase.
|
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
@@ -107,7 +105,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||||
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
|
||||||
echo "APP_VERSION=${APP_VERSION}"
|
echo "APP_VERSION=${APP_VERSION}"
|
||||||
|
|
||||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||||
@@ -141,11 +138,7 @@ jobs:
|
|||||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Generate Prisma type stubs (for facade type-checking) — no DB connection needed.
|
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||||
# Drizzle schema (src/db/schema.ts) is committed and does not require generation.
|
|
||||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
|
||||||
pnpm prisma:generate
|
|
||||||
unset DATABASE_URL
|
|
||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||||
@@ -205,9 +198,6 @@ jobs:
|
|||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
mv "${STAGE}/node_modules" 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
|
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
|
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||||
@@ -301,10 +291,7 @@ jobs:
|
|||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# prisma generate only needs a resolvable URL — no live DB connection.
|
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
|
||||||
pnpm prisma:generate
|
|
||||||
unset DATABASE_URL
|
|
||||||
# Tag releases must apply CMS SQL migrations against the live DB
|
# Tag releases must apply CMS SQL migrations against the live DB
|
||||||
# (same path as push-to-main deploy), using the production .env.
|
# (same path as push-to-main deploy), using the production .env.
|
||||||
LIVE="/var/www/atom-nexst"
|
LIVE="/var/www/atom-nexst"
|
||||||
@@ -416,21 +403,14 @@ jobs:
|
|||||||
echo ""
|
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 "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 ""
|
||||||
echo "### 4. Generate Prisma Type Stubs (Dev Only)"
|
echo "### 4. Run CMS Migrations"
|
||||||
echo '```bash'
|
|
||||||
echo "pnpm prisma:generate"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Generates TypeScript types in src/generated/prisma/ for the Prisma compatibility facade (types only — no runtime Prisma engine in production). New code should use Drizzle ORM directly via '@/lib/db'."
|
|
||||||
echo ""
|
|
||||||
echo "### 5. Run CMS Migrations"
|
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "pnpm db:migrate"
|
echo "pnpm db:migrate"
|
||||||
echo '```'
|
echo '```'
|
||||||
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 ""
|
||||||
echo "### 6. Polaris Emulator"
|
echo "### 5. Polaris Emulator"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
@@ -444,7 +424,7 @@ jobs:
|
|||||||
echo "./update-Nitrov3.sh"
|
echo "./update-Nitrov3.sh"
|
||||||
echo '```'
|
echo '```'
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 7. Nitro V3 & Renderer"
|
echo "### 6. Nitro V3 & Renderer"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Clone both Nitro repos and build the client:"
|
echo "Clone both Nitro repos and build the client:"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
@@ -456,14 +436,14 @@ jobs:
|
|||||||
echo ""
|
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 "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 ""
|
||||||
echo "### 8. Catalogus (catalog & gamedata)"
|
echo "### 7. Catalogus (catalog & gamedata)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||||
echo '```'
|
echo '```'
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 9. Build & Start the CMS"
|
echo "### 8. Build & Start the CMS"
|
||||||
echo '```bash'
|
echo '```bash'
|
||||||
echo "# Development (hot reload)"
|
echo "# Development (hot reload)"
|
||||||
echo "pnpm dev"
|
echo "pnpm dev"
|
||||||
@@ -474,7 +454,7 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Open http://localhost:3000 in your browser."
|
echo "Open http://localhost:3000 in your browser."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 10. First Login"
|
echo "### 9. First Login"
|
||||||
echo ""
|
echo ""
|
||||||
echo "1. Register at /register, or log in with an existing emulator account."
|
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';"
|
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ node_modules/
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
# Prisma client is generated by `prisma generate`
|
|
||||||
src/generated/
|
src/generated/
|
||||||
# Runtime avatar/badge imaging disk cache
|
# Runtime avatar/badge imaging disk cache
|
||||||
public/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.
|
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.
|
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
|
### 3. Configure Environment
|
||||||
|
|
||||||
@@ -78,25 +78,14 @@ const found = await db.select()
|
|||||||
|
|
||||||
| Command | What it does |
|
| 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 |
|
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
|
||||||
| `npx drizzle-kit studio` | Open a local DB browser (dev only) |
|
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||||
| `npx drizzle-kit introspect` | Reverse-engineer an existing DB into a Drizzle schema |
|
| `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)
|
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.
|
||||||
|
|
||||||
A Prisma-compatible facade at `@/lib/prisma` allows existing code to keep calling `prisma.users.findMany()` without refactoring. At runtime, the facade routes every query through Drizzle. **There is zero Prisma client or query-engine overhead in production.**
|
|
||||||
|
|
||||||
Generate the Prisma type stubs used for type-checking the facade:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm prisma:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates `src/generated/prisma/` (a local, `gitignore`d build artifact) with TypeScript types only. It is never shipped in the production bundle.
|
|
||||||
|
|
||||||
> **Legacy CLI command removed:** The `prisma` CLI is now a devDependency used **only** for type generation. Old commands such as `prisma migrate dev`, `prisma studio`, or `prisma db push` are no longer applicable — use the SQL migration runner (`pnpm db:migrate`) or Drizzle CLI instead.
|
|
||||||
|
|
||||||
### 5. Run CMS Migrations
|
### 5. Run CMS Migrations
|
||||||
|
|
||||||
@@ -104,7 +93,7 @@ This creates `src/generated/prisma/` (a local, `gitignore`d build artifact) with
|
|||||||
pnpm db:migrate
|
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:
|
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)
|
## Production Deployment (PM2)
|
||||||
|
|
||||||
```bash
|
```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 test` | Run all tests (Vitest) |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `pnpm db:migrate:status` | Show migration status |
|
||||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from Prisma + live DB (needs `DATABASE_URL`) |
|
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
||||||
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
| `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 analyze` | Build + open bundle analyzer |
|
||||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||||
| `pnpm biome:check` | Lint and format code |
|
| `pnpm biome:check` | Lint and format code |
|
||||||
|
|
||||||
**Drizzle CLI (dev only, run with `npx`):**
|
**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.
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -203,27 +418,26 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
├── prisma/
|
├── drizzle/
|
||||||
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only (legacy)
|
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
|
||||||
│ └── migrations/ # 19 SQL migrations for CMS tables (idempotent, never re-run)
|
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||||
│ ├── jobs-worker.ts # Background task scheduler
|
│ ├── jobs-worker.ts # Background task scheduler
|
||||||
│ ├── merge-config.cjs # Utility: merge split config files
|
│ ├── 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/
|
├── src/
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
|
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
|
||||||
│ │ └── migrations/ # Drizzle migration files (local dev only)
|
│ │ └── relations.ts # Drizzle relations
|
||||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||||
│ ├── actions/ # Server Actions
|
│ ├── actions/ # Server Actions
|
||||||
│ ├── components/ # UI components
|
│ ├── components/ # UI components
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||||
│ │ ├── prisma.ts # Prisma-compatible facade (routes to Drizzle at runtime)
|
|
||||||
│ │ ├── prisma-facade.ts # Drizzle-backed implementation of Prisma API surface
|
|
||||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||||
|
│ │ ├── cached-db.ts # Redis-backed query cache helpers
|
||||||
│ │ ├── redis.ts # Redis client (ioredis)
|
│ │ ├── redis.ts # Redis client (ioredis)
|
||||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||||
│ │ ├── cache.ts # In-memory cache fallback
|
│ │ ├── cache.ts # In-memory cache fallback
|
||||||
@@ -244,18 +458,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| Component | Type | Migrations |
|
| Component | Type | Migrations |
|
||||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
| 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 |
|
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||||
|
|
||||||
### ORM Architecture
|
### 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.
|
Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
|
||||||
- **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -319,9 +531,9 @@ pnpm biome:check # Lint and format
|
|||||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
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)
|
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 "dotenv/config";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
// The schema is generated by scripts/generate-drizzle-schema.mjs from
|
// Schema source of truth for the query builder: src/db/schema.ts
|
||||||
// prisma/schema.prisma + live DB introspection. This DB is shared live with the
|
// (regenerated via `pnpm db:schema:generate` from the previous schema + live DB).
|
||||||
// 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`.
|
// 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({
|
export default defineConfig({
|
||||||
dialect: "mysql",
|
dialect: "mysql",
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle/drafts",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? "",
|
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/**/page.{ts,tsx}",
|
||||||
"src/app/**/layout.{ts,tsx}",
|
"src/app/**/layout.{ts,tsx}",
|
||||||
"src/app/**/route.{ts,tsx}",
|
"src/app/**/route.{ts,tsx}",
|
||||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}"
|
||||||
"sentry.{server,edge}.config.ts"
|
|
||||||
],
|
],
|
||||||
"project": ["src/**/*.{ts,tsx}"],
|
"project": ["src/**/*.{ts,tsx}"],
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
|
|||||||
+17
-25
@@ -1,8 +1,22 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import withBundleAnalyzer from "@next/bundle-analyzer";
|
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
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 = [
|
const securityHeaders = [
|
||||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||||
{
|
{
|
||||||
@@ -20,6 +34,7 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
deploymentId: resolveDeploymentId(),
|
||||||
turbopack: {},
|
turbopack: {},
|
||||||
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
||||||
|
|
||||||
@@ -32,9 +47,6 @@ const nextConfig: NextConfig = {
|
|||||||
// Disable Next.js telemetry and browser sourcemaps in production
|
// Disable Next.js telemetry and browser sourcemaps in production
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|
||||||
// Standalone output for smaller, faster Docker deployments and cold starts
|
|
||||||
output: "standalone",
|
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
useTypeScriptCli: true,
|
useTypeScriptCli: true,
|
||||||
},
|
},
|
||||||
@@ -69,28 +81,8 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|||||||
|
|
||||||
const config = withNextIntl(nextConfig);
|
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({
|
const withBA = withBundleAnalyzer({
|
||||||
enabled: process.env.ANALYZE === "true",
|
enabled: process.env.ANALYZE === "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBA(
|
export default withBA(config);
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
+8
-9
@@ -10,7 +10,6 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
|
||||||
"typecheck": "tsc --noEmit --incremental",
|
"typecheck": "tsc --noEmit --incremental",
|
||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
@@ -21,6 +20,9 @@
|
|||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
"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",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
"biome check --write"
|
"biome check --write"
|
||||||
],
|
],
|
||||||
"*.{json,md,css,scss,html}": [
|
"*.{json,md,css,scss,html}": [
|
||||||
"biome format --write"
|
"biome format --write --no-errors-on-unmatched"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -37,8 +39,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "^5.6.0",
|
||||||
"@sentry/nextjs": "^10.69.0",
|
|
||||||
"@tanstack/react-virtual": "^3.14.9",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"isomorphic-dompurify": "^3.20.0",
|
"isomorphic-dompurify": "^3.21.0",
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.83.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"resend": "^6.18.1",
|
"resend": "^6.18.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
@@ -76,7 +77,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@next/bundle-analyzer": "^16.2.12",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
"@prisma/client": "^7.9.1",
|
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
@@ -88,11 +88,10 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.30.0",
|
"knip": "^6.31.0",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.25",
|
||||||
"prisma": "^7.9.1",
|
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
|
|||||||
Generated
+211
-1839
File diff suppressed because it is too large.
Load diff
+1
-5
@@ -3,13 +3,9 @@
|
|||||||
# pnpm v11 vervanger voor onlyBuiltDependencies
|
# pnpm v11 vervanger voor onlyBuiltDependencies
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
|
||||||
"@prisma/client": true
|
|
||||||
"@prisma/engines": true
|
|
||||||
sharp: true
|
sharp: true
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
"@swc/core": true
|
"@swc/core": true
|
||||||
"@sentry/cli": true
|
|
||||||
bcrypt: true
|
bcrypt: true
|
||||||
|
|
||||||
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
|
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
|
||||||
@@ -32,4 +28,4 @@ peerDependencyRules:
|
|||||||
allowedVersions:
|
allowedVersions:
|
||||||
nodemailer: "9.0.3"
|
nodemailer: "9.0.3"
|
||||||
ignoreMissing:
|
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";
|
import { splitSqlStatements } from "./sql-statements";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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";
|
const TRACKING_TABLE = "cms_migrations";
|
||||||
|
|
||||||
interface MigrationFile {
|
interface MigrationFile {
|
||||||
|
|||||||
+143
-195
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Generates src/db/schema.ts from:
|
// Generates src/db/schema.ts from:
|
||||||
// 1. prisma/schema.prisma -> TS field names (camelCase) + @map column names + table names
|
// 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)
|
// 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.
|
// 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
|
// Usage: pnpm db:schema:generate
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
@@ -27,84 +28,124 @@ function mysqlConnectionUrl(value) {
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = resolve(__dirname, "..");
|
const ROOT = resolve(__dirname, "..");
|
||||||
const SCHEMA_PRISMA = resolve(ROOT, "prisma/schema.prisma");
|
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
||||||
const OUT = 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 = [
|
* @returns {{ name: string, table: string, fields: object[], ids: string[]|null, uniques: string[][] }}
|
||||||
...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm),
|
*/
|
||||||
];
|
function parseDrizzleTables(source) {
|
||||||
const modelNames = new Set(modelBlocks.map((m) => m[1]));
|
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 } */
|
let match = re2.exec(source);
|
||||||
function parseModel(block) {
|
const seen = new Set();
|
||||||
const [_full, name, body] = block;
|
while (match !== null) {
|
||||||
const table =
|
const [, name, table, body, extras] = match;
|
||||||
body.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? name.toLowerCase();
|
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 = [];
|
const fields = [];
|
||||||
for (const line of body.split("\n")) {
|
for (const line of body.split("\n")) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (
|
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||||
!trimmed ||
|
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\s*$/);
|
||||||
trimmed.startsWith("//") ||
|
|
||||||
trimmed.startsWith("@@") ||
|
|
||||||
trimmed.startsWith("///")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const m = trimmed.match(/^(\w+)\s+(.+)$/);
|
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const [fieldName, rest] = [m[1], m[2]];
|
const fieldName = m[1];
|
||||||
const typeMatch = rest.match(/^([^\s]+)/);
|
const expr = m[2];
|
||||||
const prismaType = typeMatch[1];
|
const colMatch = expr.match(/\(\s*"([^"]+)"/);
|
||||||
const baseType = prismaType.replace(/\?$/, "").replace(/\[\]$/, "");
|
if (!colMatch) continue;
|
||||||
const isList = prismaType.endsWith("[]");
|
const column = colMatch[1];
|
||||||
// relation field (points at another model) -> skip
|
|
||||||
if (modelNames.has(baseType)) continue;
|
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({
|
fields.push({
|
||||||
fieldName,
|
fieldName,
|
||||||
column: map,
|
column,
|
||||||
prismaType: baseType,
|
optional: !/\.notNull\s*\(/.test(expr),
|
||||||
optional,
|
isId: /\.primaryKey\s*\(/.test(expr),
|
||||||
isList,
|
isUnique: /\.unique\s*\(/.test(expr),
|
||||||
isId,
|
autoIncrement: /\.autoincrement\s*\(/.test(expr),
|
||||||
isUnique,
|
isBoolean,
|
||||||
autoIncrement,
|
enumValues,
|
||||||
updatedAt,
|
defaultContent,
|
||||||
attrs,
|
// legacy shape used by columnExpr / fallback
|
||||||
dbHint: dbHint
|
prismaType: isBoolean
|
||||||
? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] }
|
? "Boolean"
|
||||||
: null,
|
: enumValues
|
||||||
|
? fieldName === "gender"
|
||||||
|
? "users_gender"
|
||||||
|
: fieldName === "type" && table === "bans"
|
||||||
|
? "bans_type"
|
||||||
|
: "String"
|
||||||
|
: "String",
|
||||||
|
attrs: defaultContent ? `@default(${defaultContent})` : "",
|
||||||
|
dbHint: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// model-level keys
|
let ids = null;
|
||||||
const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1];
|
|
||||||
const ids = compIds
|
|
||||||
? compIds.split(",").map((s) => s.trim().replace(/`/g, ""))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const uniques = [];
|
const uniques = [];
|
||||||
for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) {
|
if (extras) {
|
||||||
uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, "")));
|
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 };
|
return { name, table, fields, ids, uniques };
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = modelBlocks.map(parseModel);
|
const models = parseDrizzleTables(schemaSource);
|
||||||
|
|
||||||
// ---------- Introspect live MySQL ----------
|
// ---------- Introspect live MySQL ----------
|
||||||
let url;
|
let url;
|
||||||
@@ -152,21 +193,20 @@ function quote(v) {
|
|||||||
/** Map a DB column row to a drizzle column expression string. */
|
/** Map a DB column row to a drizzle column expression string. */
|
||||||
function columnExpr(field, dbCol) {
|
function columnExpr(field, dbCol) {
|
||||||
const col = field.column;
|
const col = field.column;
|
||||||
let expr = "";
|
|
||||||
let type = "";
|
let type = "";
|
||||||
|
|
||||||
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
||||||
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
||||||
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
||||||
|
|
||||||
// Prisma enum types -> mysqlEnum
|
|
||||||
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
||||||
use("mysqlEnum");
|
use("mysqlEnum");
|
||||||
const values = enumMatch
|
const values = enumMatch
|
||||||
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||||
: field.prismaType === "users_gender"
|
: (field.enumValues ??
|
||||||
? ["M", "F"]
|
(field.prismaType === "users_gender"
|
||||||
: ["account", "ip", "machine", "super"];
|
? ["M", "F"]
|
||||||
|
: ["account", "ip", "machine", "super"]));
|
||||||
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +219,8 @@ function columnExpr(field, dbCol) {
|
|||||||
break;
|
break;
|
||||||
case "tinyint": {
|
case "tinyint": {
|
||||||
const isBool =
|
const isBool =
|
||||||
dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean";
|
dbCol.column_type === "tinyint(1)" &&
|
||||||
|
(field.isBoolean || field.prismaType === "Boolean");
|
||||||
if (isBool) {
|
if (isBool) {
|
||||||
use("boolean");
|
use("boolean");
|
||||||
type = `boolean(${quote(col)})`;
|
type = `boolean(${quote(col)})`;
|
||||||
@@ -209,8 +250,6 @@ function columnExpr(field, dbCol) {
|
|||||||
break;
|
break;
|
||||||
case "varchar":
|
case "varchar":
|
||||||
case "enum": {
|
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");
|
use("varchar");
|
||||||
const maxLen = enumMatch
|
const maxLen = enumMatch
|
||||||
? Math.max(
|
? Math.max(
|
||||||
@@ -284,14 +323,13 @@ function columnExpr(field, dbCol) {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(
|
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");
|
use("varchar");
|
||||||
type = `varchar(${quote(col)}, { length: 255 })`;
|
type = `varchar(${quote(col)}, { length: 255 })`;
|
||||||
}
|
}
|
||||||
expr += type;
|
|
||||||
|
|
||||||
return expr;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modifiers(field, dbCol) {
|
function modifiers(field, dbCol) {
|
||||||
@@ -306,55 +344,38 @@ function modifiers(field, dbCol) {
|
|||||||
} else if (field.isUnique) {
|
} else if (field.isUnique) {
|
||||||
expr += `.unique()`;
|
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) {
|
if (!field.optional) {
|
||||||
expr += `.notNull()`;
|
expr += `.notNull()`;
|
||||||
}
|
}
|
||||||
expr += parseDefault(field, dbCol);
|
expr += emitDefault(field, dbCol);
|
||||||
return expr;
|
return expr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract `@default(...)` and emit a drizzle `.default(...)` (or ""). */
|
function emitDefault(field, dbCol) {
|
||||||
function parseDefault(field, dbCol) {
|
const content = field.defaultContent;
|
||||||
const m = field.attrs.match(/@default\(/);
|
|
||||||
if (!m) return "";
|
|
||||||
const start = m.index + "@default(".length;
|
|
||||||
let depth = 0;
|
|
||||||
let content = "";
|
|
||||||
for (let i = start; i < field.attrs.length; i++) {
|
|
||||||
const ch = field.attrs[i];
|
|
||||||
if (ch === "(") {
|
|
||||||
depth++;
|
|
||||||
} else if (ch === ")") {
|
|
||||||
if (depth === 0) {
|
|
||||||
content = field.attrs.slice(start, i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
depth--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!content) return "";
|
if (!content) return "";
|
||||||
if (content === "now()") {
|
if (
|
||||||
|
content === "sql`CURRENT_TIMESTAMP`" ||
|
||||||
|
content.includes("CURRENT_TIMESTAMP")
|
||||||
|
) {
|
||||||
markSqlUsed();
|
markSqlUsed();
|
||||||
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
||||||
}
|
}
|
||||||
if (content === "autoincrement()" || content.startsWith("dbgenerated("))
|
|
||||||
return "";
|
|
||||||
if (content === "true" || content === "false") return `.default(${content})`;
|
if (content === "true" || content === "false") return `.default(${content})`;
|
||||||
|
if (/^-?\d+n$/.test(content)) return `.default(${content})`;
|
||||||
if (/^-?\d+$/.test(content)) {
|
if (/^-?\d+$/.test(content)) {
|
||||||
// integer literal; bigint64 data type is `bigint`, others are `number`
|
|
||||||
return dbCol?.data_type === "bigint"
|
return dbCol?.data_type === "bigint"
|
||||||
? `.default(${content}n)`
|
? `.default(${content}n)`
|
||||||
: `.default(${content})`;
|
: `.default(${content})`;
|
||||||
}
|
}
|
||||||
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
||||||
// quoted string or bare enum/string identifier (e.g. @default(M))
|
if (
|
||||||
const s =
|
(content.startsWith('"') && content.endsWith('"')) ||
|
||||||
content.startsWith('"') && content.endsWith('"')
|
(content.startsWith("'") && content.endsWith("'"))
|
||||||
? content.slice(1, -1)
|
) {
|
||||||
: content;
|
return `.default(${JSON.stringify(content.slice(1, -1))})`;
|
||||||
return `.default(${JSON.stringify(s)})`;
|
}
|
||||||
|
return `.default(${content})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelTable(model) {
|
function modelTable(model) {
|
||||||
@@ -362,8 +383,6 @@ function modelTable(model) {
|
|||||||
for (const f of model.fields) {
|
for (const f of model.fields) {
|
||||||
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
||||||
if (!dbCol) {
|
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);
|
const fallback = fallbackColumn(f) + modifiers(f, null);
|
||||||
rows.push(`\t${f.fieldName}: ${fallback},`);
|
rows.push(`\t${f.fieldName}: ${fallback},`);
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -407,96 +426,23 @@ ${uniqueRows.join("\n")}
|
|||||||
],
|
],
|
||||||
);`;
|
);`;
|
||||||
}
|
}
|
||||||
return `export const ${model.name} = mysqlTable(
|
return `export const ${model.name} = mysqlTable("${model.table}", {
|
||||||
"${model.table}",
|
|
||||||
{
|
|
||||||
${rows.join("\n")}
|
${rows.join("\n")}
|
||||||
},
|
});`;
|
||||||
);`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackColumn(field) {
|
function fallbackColumn(field) {
|
||||||
const hint = field.dbHint;
|
|
||||||
const name = field.column;
|
const name = field.column;
|
||||||
switch (hint?.type) {
|
if (field.enumValues) {
|
||||||
case "VarChar":
|
use("mysqlEnum");
|
||||||
use("varchar");
|
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
|
||||||
return `varchar(${quote(name)}, { length: ${Number(hint.param1 ?? 191)} })`;
|
|
||||||
case "Char":
|
|
||||||
use("char");
|
|
||||||
return `char(${quote(name)}, { length: ${Number(hint.param1 ?? 8)} })`;
|
|
||||||
case "Text":
|
|
||||||
use("text");
|
|
||||||
return `text(${quote(name)})`;
|
|
||||||
case "MediumText":
|
|
||||||
use("mediumtext");
|
|
||||||
return `mediumtext(${quote(name)})`;
|
|
||||||
case "LongText":
|
|
||||||
use("longtext");
|
|
||||||
return `longtext(${quote(name)})`;
|
|
||||||
case "Decimal":
|
|
||||||
use("decimal");
|
|
||||||
return `decimal(${quote(name)}, { precision: ${Number(hint.param1 ?? 10)}, scale: ${Number(hint.param2 ?? 0)}, mode: "number" })`;
|
|
||||||
case "UnsignedBigInt":
|
|
||||||
use("bigint");
|
|
||||||
return `bigint(${quote(name)}, { mode: "bigint", unsigned: true })`;
|
|
||||||
case "UnsignedInt":
|
|
||||||
use("int");
|
|
||||||
return `int(${quote(name)}, { unsigned: true })`;
|
|
||||||
case "Double":
|
|
||||||
use("double");
|
|
||||||
return `double(${quote(name)})`;
|
|
||||||
case "Float":
|
|
||||||
use("float");
|
|
||||||
return `float(${quote(name)})`;
|
|
||||||
case "Json":
|
|
||||||
use("json");
|
|
||||||
return `json(${quote(name)})`;
|
|
||||||
case "Timestamp":
|
|
||||||
use("timestamp");
|
|
||||||
return `timestamp(${quote(name)})`;
|
|
||||||
case "Time":
|
|
||||||
return `timeAsDate(${quote(name)})`;
|
|
||||||
case "Date":
|
|
||||||
return `dateAsDate(${quote(name)})`;
|
|
||||||
case "TinyInt":
|
|
||||||
use("tinyint");
|
|
||||||
return `tinyint(${quote(name)})`;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
switch (field.prismaType) {
|
if (field.isBoolean || field.prismaType === "Boolean") {
|
||||||
case "Int":
|
use("boolean");
|
||||||
use("int");
|
return `boolean(${quote(name)})`;
|
||||||
return `int(${quote(name)})`;
|
|
||||||
case "BigInt":
|
|
||||||
use("bigint");
|
|
||||||
return `bigint(${quote(name)}, { mode: "bigint" })`;
|
|
||||||
case "Boolean":
|
|
||||||
use("boolean");
|
|
||||||
return `boolean(${quote(name)})`;
|
|
||||||
case "DateTime":
|
|
||||||
use("datetime");
|
|
||||||
return `datetime(${quote(name)})`;
|
|
||||||
case "String":
|
|
||||||
use("varchar");
|
|
||||||
return `varchar(${quote(name)}, { length: 255 })`;
|
|
||||||
case "Float":
|
|
||||||
use("double");
|
|
||||||
return `double(${quote(name)})`;
|
|
||||||
case "Decimal":
|
|
||||||
use("decimal");
|
|
||||||
return `decimal(${quote(name)}, { precision: 10, scale: 2, mode: "number" })`;
|
|
||||||
case "Json":
|
|
||||||
use("json");
|
|
||||||
return `json(${quote(name)})`;
|
|
||||||
case "Bytes":
|
|
||||||
use("binary");
|
|
||||||
return `binary(${quote(name)})`;
|
|
||||||
default:
|
|
||||||
use("varchar");
|
|
||||||
return `varchar(${quote(name)}, { length: 255 })`;
|
|
||||||
}
|
}
|
||||||
|
use("varchar");
|
||||||
|
return `varchar(${quote(name)}, { length: 255 })`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Generate file ----------
|
// ---------- Generate file ----------
|
||||||
@@ -532,8 +478,8 @@ const IMPORTABLE = [
|
|||||||
const importList = IMPORTABLE.filter((b) => used.has(b));
|
const importList = IMPORTABLE.filter((b) => used.has(b));
|
||||||
|
|
||||||
const helpers = [
|
const helpers = [
|
||||||
"// MySQL TIME / DATE columns are hydrated by Prisma as JS Date (epoch 1970-01-01",
|
"// MySQL TIME / DATE columns are hydrated as JS Date (epoch 1970-01-01",
|
||||||
"// for TIME). Mirror that so existing call sites keep working unchanged.",
|
"// for TIME). Keep this so existing call sites stay unchanged.",
|
||||||
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
||||||
" dataType() {",
|
" dataType() {",
|
||||||
' return "time";',
|
' return "time";',
|
||||||
@@ -563,13 +509,15 @@ const helpers = [
|
|||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
const sqlImport = usedSql ? 'import { sql } from "drizzle-orm";\n' : "";
|
||||||
|
|
||||||
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
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
|
// TS field names come from the previous src/db/schema.ts; column types from the
|
||||||
// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes.
|
// live MySQL DB. Run \`pnpm db:schema:generate\` after schema/map changes.
|
||||||
import {
|
${sqlImport}import {
|
||||||
${importList.map((b) => `\t${b},`).join("\n")}
|
${importList.map((b) => `\t${b},`).join("\n")}
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
${usedSql ? `import { sql } from "drizzle-orm";\n` : ""}
|
|
||||||
${helpers}${body}
|
${helpers}${body}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
+166
-24
@@ -1,29 +1,81 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
|
import { lt, sql } from "drizzle-orm";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
|
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { redis } from "../src/lib/redis";
|
||||||
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
function initWorkerSentry(): void {
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
const dsn = process.env.SENTRY_DSN;
|
|
||||||
if (!dsn || process.env.NODE_ENV !== "production") return;
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn,
|
|
||||||
environment: process.env.NODE_ENV,
|
|
||||||
release: process.env.APP_VERSION,
|
|
||||||
tracesSampleRate: 0.05,
|
|
||||||
});
|
|
||||||
logger.info("Sentry initialized for jobs worker", { module: "jobs" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureWorkerError(err: unknown, context: string): void {
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
logger.error(context, {
|
logger.error(context, {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
err: err instanceof Error ? err.message : String(err),
|
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> {
|
async function cleanupOldLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.websiteLoginLogs.deleteMany({
|
await db
|
||||||
where: { createdAt: { lt: cutoff } },
|
.delete(WebsiteLoginLogs)
|
||||||
});
|
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
|
||||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
captureWorkerError(err, "Log cleanup failed");
|
captureWorkerError(err, "Log cleanup failed");
|
||||||
@@ -84,9 +214,7 @@ async function cleanupOldLogs(): Promise<void> {
|
|||||||
async function cleanupOldSessions(): Promise<void> {
|
async function cleanupOldSessions(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.passwordReset.deleteMany({
|
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
|
||||||
where: { createdAt: { lt: cutoff } },
|
|
||||||
});
|
|
||||||
logger.info("Cleaned up expired password reset tokens", {
|
logger.info("Cleaned up expired password reset tokens", {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
@@ -96,7 +224,6 @@ async function cleanupOldSessions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
initWorkerSentry();
|
|
||||||
logger.info("Worker started", { module: "jobs" });
|
logger.info("Worker started", { module: "jobs" });
|
||||||
|
|
||||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
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 * * *", () => {
|
new Cron("0 4 * * *", () => {
|
||||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||||
captureWorkerError(e, "Cleanup error"),
|
captureWorkerError(e, "Cleanup error"),
|
||||||
@@ -115,10 +251,16 @@ async function main() {
|
|||||||
});
|
});
|
||||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
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([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
cleanupOldSessions(),
|
cleanupOldSessions(),
|
||||||
|
checkOpsHealth(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
timingSafeEqual,
|
timingSafeEqual,
|
||||||
} from "node:crypto";
|
} 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 {
|
function getKey(appKey: string): Buffer {
|
||||||
const raw = appKey.startsWith("base64:")
|
const raw = appKey.startsWith("base64:")
|
||||||
@@ -84,10 +85,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const key = getKey(appKey);
|
const key = getKey(appKey);
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await db
|
||||||
where: { twoFactorSecret: { not: null } },
|
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
|
||||||
select: { id: true, twoFactorSecret: true },
|
.from(User)
|
||||||
});
|
.where(isNotNull(User.twoFactorSecret));
|
||||||
|
|
||||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||||
|
|
||||||
@@ -107,10 +108,10 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||||
const reEncrypted = encryptGcm(plaintext, key);
|
const reEncrypted = encryptGcm(plaintext, key);
|
||||||
await prisma.user.update({
|
await db
|
||||||
where: { id: user.id },
|
.update(User)
|
||||||
data: { twoFactorSecret: reEncrypted },
|
.set({ twoFactorSecret: reEncrypted })
|
||||||
});
|
.where(eq(User.id, user.id));
|
||||||
console.log(` [OK] User ${user.id} — migrated`);
|
console.log(` [OK] User ${user.id} — migrated`);
|
||||||
migrated++;
|
migrated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -125,12 +126,10 @@ async function main() {
|
|||||||
if (errors > 0) process.exit(1);
|
if (errors > 0) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main().catch((err) => {
|
||||||
.catch((err) => {
|
console.error(err);
|
||||||
console.error(err);
|
process.exit(1);
|
||||||
process.exit(1);
|
});
|
||||||
})
|
|
||||||
.finally(() => prisma.$disconnect());
|
|
||||||
|
|
||||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
describe("radio columns migration", () => {
|
describe("radio columns migration", () => {
|
||||||
it("adds every column idempotently for partially migrated databases", () => {
|
it("adds every column idempotently for partially migrated databases", () => {
|
||||||
const sql = readFileSync(
|
const sql = readFileSync(
|
||||||
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
|
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { ActionError } from "@/lib/safe-action-shared";
|
import { ActionError } from "@/lib/safe-action-shared";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { createAd, deleteAd } from "./admin-ads";
|
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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } },
|
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/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
@@ -34,28 +48,27 @@ const fakeForm = (data: Record<string, string>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createAd", () => {
|
describe("createAd", () => {
|
||||||
it("creates ad and redirects", async () => {
|
it("creates ad and redirects", async () => {
|
||||||
vi.mocked(prisma.websiteAds.create).mockResolvedValue({
|
|
||||||
id: BigInt(1),
|
|
||||||
} as never);
|
|
||||||
await createAd(
|
await createAd(
|
||||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteAds.create).toHaveBeenCalled();
|
expect(insertValues).toHaveBeenCalled();
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when image empty", async () => {
|
it("returns early when image empty", async () => {
|
||||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
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 () => {
|
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);
|
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -63,7 +76,6 @@ describe("createAd", () => {
|
|||||||
|
|
||||||
describe("deleteAd", () => {
|
describe("deleteAd", () => {
|
||||||
it("deletes ad and returns ok", async () => {
|
it("deletes ad and returns ok", async () => {
|
||||||
vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never);
|
|
||||||
const h = deleteAd as unknown as (ctx: {
|
const h = deleteAd as unknown as (ctx: {
|
||||||
data: { id: bigint };
|
data: { id: bigint };
|
||||||
session: { user: { id: string } };
|
session: { user: { id: string } };
|
||||||
@@ -74,7 +86,7 @@ describe("deleteAd", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws ActionError when not found", async () => {
|
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: {
|
const h = deleteAd as unknown as (ctx: {
|
||||||
data: { id: bigint };
|
data: { id: bigint };
|
||||||
session: { user: { id: string } };
|
session: { user: { id: string } };
|
||||||
|
|||||||
+23
-13
@@ -1,13 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteAds } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -25,15 +27,17 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
const ad = await prisma.websiteAds.create({
|
const [result] = (await db.insert(WebsiteAds).values({
|
||||||
data: { image, createdAt: now, updatedAt: now },
|
image,
|
||||||
});
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ad_create",
|
action: "ad_create",
|
||||||
description: `Created advertisement #${ad.id} (${image})`,
|
description: `Created advertisement #${result.insertId} (${image})`,
|
||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(ad.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Action failed: createAd", {
|
logger.error("Action failed: createAd", {
|
||||||
@@ -58,10 +62,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
|||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteAds.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteAds)
|
||||||
data: { image, updatedAt: new Date() },
|
.set({ image, updatedAt: new Date() })
|
||||||
});
|
.where(eq(WebsiteAds.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ad_update",
|
action: "ad_update",
|
||||||
@@ -92,8 +96,14 @@ export const deleteAd = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = ctx.data.id;
|
const id = ctx.data.id;
|
||||||
try {
|
try {
|
||||||
await prisma.websiteAds.delete({ where: { id } });
|
const [result] = (await db
|
||||||
} catch {
|
.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");
|
throw new ActionError("Advertisement not found");
|
||||||
}
|
}
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -115,7 +125,7 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteAds.delete({ where: { id } });
|
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ad_delete",
|
action: "ad_delete",
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
|||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
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("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { AlertLogs, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
@@ -29,3 +31,17 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/alerts");
|
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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
await requirePermission(PERMS.USERS_EDIT);
|
||||||
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
await db
|
||||||
|
.delete(WebsiteStaffApplications)
|
||||||
|
.where(eq(WebsiteStaffApplications.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// already gone / no DB — nothing to do
|
// already gone / no DB — nothing to do
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
WebsiteArticleComments,
|
||||||
|
WebsiteArticleReactions,
|
||||||
|
WebsiteArticles,
|
||||||
|
} from "@/lib/db";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let n = 2;
|
let n = 2;
|
||||||
while (
|
for (;;) {
|
||||||
await prisma.websiteArticles.findUnique({
|
const [existing] = await db
|
||||||
where: { slug },
|
.select({ id: WebsiteArticles.id })
|
||||||
select: { id: true },
|
.from(WebsiteArticles)
|
||||||
})
|
.where(eq(WebsiteArticles.slug, slug))
|
||||||
) {
|
.limit(1);
|
||||||
|
if (!existing) return slug;
|
||||||
slug = `${base}-${n++}`;
|
slug = `${base}-${n++}`;
|
||||||
}
|
}
|
||||||
return slug;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createArticle(formData: FormData): Promise<void> {
|
export async function createArticle(formData: FormData): Promise<void> {
|
||||||
@@ -41,17 +47,15 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteArticles.create({
|
await db.insert(WebsiteArticles).values({
|
||||||
data: {
|
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
title: title.slice(0, 255),
|
||||||
title: title.slice(0, 255),
|
shortStory: shortStory.slice(0, 255),
|
||||||
shortStory: shortStory.slice(0, 255),
|
fullStory,
|
||||||
fullStory,
|
image: image.slice(0, 255),
|
||||||
image: image.slice(0, 255),
|
userId: staff.id,
|
||||||
userId: staff.id,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Database error — re-render unchanged with error.
|
// 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 id = BigInt(String(formData.get("id")));
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
try {
|
try {
|
||||||
await prisma.websiteArticles.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteArticles)
|
||||||
data: {
|
.set({
|
||||||
title: String(formData.get("title") ?? "")
|
title: String(formData.get("title") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -87,8 +91,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
})
|
||||||
});
|
.where(eq(WebsiteArticles.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
redirect("/admin/articles?error=Update failed");
|
redirect("/admin/articles?error=Update failed");
|
||||||
}
|
}
|
||||||
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.NEWS_EDIT);
|
await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = BigInt(String(formData.get("id")));
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
await tx
|
||||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
.delete(WebsiteArticleReactions)
|
||||||
prisma.websiteArticles.delete({ where: { id } }),
|
.where(eq(WebsiteArticleReactions.articleId, id));
|
||||||
]);
|
await tx
|
||||||
|
.delete(WebsiteArticleComments)
|
||||||
|
.where(eq(WebsiteArticleComments.articleId, id));
|
||||||
|
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
redirect("/admin/articles?error=Delete failed");
|
redirect("/admin/articles?error=Delete failed");
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-13
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, max } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, UsersBadges } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
export async function giveBadge(formData: FormData): Promise<void> {
|
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
|
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||||
// against duplicates and compute the next free slot ourselves.
|
// against duplicates and compute the next free slot ourselves.
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.usersBadges.findFirst({
|
const [existing] = await db
|
||||||
where: { userId, badgeCode: code },
|
.select({ id: UsersBadges.id })
|
||||||
select: { id: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(
|
||||||
|
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const max = await prisma.usersBadges.aggregate({
|
const [agg] = await db
|
||||||
where: { userId },
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||||
_max: { slotId: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(eq(UsersBadges.userId, userId));
|
||||||
const slotId = (max._max.slotId ?? 0) + 1;
|
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||||
await prisma.usersBadges.create({
|
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
||||||
data: { userId, slotId, badgeCode: code },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: the RCON grant already succeeded for online users.
|
// Best-effort: the RCON grant already succeeded for online users.
|
||||||
|
|||||||
@@ -2,17 +2,32 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { createBan, liftBan } from "./admin-bans";
|
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/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
user: { findUnique: vi.fn() },
|
select: vi.fn(() => ({
|
||||||
ban: { create: vi.fn(), delete: 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/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
@@ -26,13 +41,13 @@ const fakeForm = (data: Record<string, string>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
|
selectLimit.mockResolvedValue([{ username: "baduser" }]);
|
||||||
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createBan", () => {
|
describe("createBan", () => {
|
||||||
it("creates a ban for valid inputs", async () => {
|
it("creates a ban for valid inputs", async () => {
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
||||||
username: "baduser",
|
|
||||||
} as never);
|
|
||||||
await createBan(
|
await createBan(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
userId: "42",
|
userId: "42",
|
||||||
@@ -41,9 +56,9 @@ describe("createBan", () => {
|
|||||||
type: "account",
|
type: "account",
|
||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.ban.create).toHaveBeenCalledWith({
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
data: expect.objectContaining({ userId: 42, type: "account" }),
|
expect.objectContaining({ userId: 42, type: "account" }),
|
||||||
});
|
);
|
||||||
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
@@ -56,14 +71,14 @@ describe("createBan", () => {
|
|||||||
type: "account",
|
type: "account",
|
||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.ban.create).not.toHaveBeenCalled();
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("liftBan", () => {
|
describe("liftBan", () => {
|
||||||
it("deletes ban and revalidates", async () => {
|
it("deletes ban and revalidates", async () => {
|
||||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
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");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+18
-18
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { Ban, db, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -29,23 +30,22 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const [user] = await db
|
||||||
where: { id: userId },
|
.select({ username: User.username })
|
||||||
select: { username: true },
|
.from(User)
|
||||||
});
|
.where(eq(User.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
await prisma.ban.create({
|
await db.insert(Ban).values({
|
||||||
data: {
|
userId,
|
||||||
userId,
|
ip: "",
|
||||||
ip: "",
|
machineId: "",
|
||||||
machineId: "",
|
userStaffId: staff.id,
|
||||||
userStaffId: staff.id,
|
timestamp: now,
|
||||||
timestamp: now,
|
banExpire,
|
||||||
banExpire,
|
banReason: reason,
|
||||||
banReason: reason,
|
type: type as "account" | "ip" | "machine" | "super",
|
||||||
type: type as any,
|
cfhTopic: -1,
|
||||||
cfhTopic: -1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) await rcon.disconnectUser(userId, user.username);
|
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 staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (id > 0) {
|
if (id > 0) {
|
||||||
await prisma.ban.delete({ where: { id } });
|
await db.delete(Ban).where(eq(Ban.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ban_lift",
|
action: "ban_lift",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, EmailTemplates } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
@@ -23,14 +24,12 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
const isActive = formData.get("isActive") != null;
|
const isActive = formData.get("isActive") != null;
|
||||||
if (!name || !subject || !body) return;
|
if (!name || !subject || !body) return;
|
||||||
|
|
||||||
await prisma.emailTemplates.create({
|
await db.insert(EmailTemplates).values({
|
||||||
data: {
|
name,
|
||||||
name,
|
subject,
|
||||||
subject,
|
body,
|
||||||
body,
|
variables: variablesRaw || null,
|
||||||
variables: variablesRaw || null,
|
isActive,
|
||||||
isActive,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
@@ -56,15 +55,15 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
const isActive = formData.get("isActive") != null;
|
const isActive = formData.get("isActive") != null;
|
||||||
if (!subject || !body) return;
|
if (!subject || !body) return;
|
||||||
|
|
||||||
await prisma.emailTemplates.update({
|
await db
|
||||||
where: { id },
|
.update(EmailTemplates)
|
||||||
data: {
|
.set({
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
variables: variablesRaw || null,
|
variables: variablesRaw || null,
|
||||||
isActive,
|
isActive,
|
||||||
},
|
})
|
||||||
});
|
.where(eq(EmailTemplates.id, id));
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +71,6 @@ export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
await prisma.emailTemplates.delete({ where: { id } });
|
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
|
// 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).
|
// 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")
|
.normalize("NFC")
|
||||||
.slice(0, 512);
|
.slice(0, 512);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.emulatorSettings.upsert({
|
await db
|
||||||
where: { key },
|
.insert(EmulatorSettings)
|
||||||
update: { value },
|
.values({ key, value })
|
||||||
create: { key, value },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
revalidatePath("/admin/emulator");
|
revalidatePath("/admin/emulator");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +37,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.slice(0, 4096);
|
.slice(0, 4096);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.emulatorTexts.upsert({
|
await db
|
||||||
where: { key },
|
.insert(EmulatorTexts)
|
||||||
update: { value },
|
.values({ key, value })
|
||||||
create: { key, value },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
revalidatePath("/admin/emulator");
|
revalidatePath("/admin/emulator");
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,41 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { disbandGuild } from "./admin-guilds";
|
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/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
guilds: { findUnique: vi.fn(), delete: vi.fn() },
|
select: vi.fn(() => ({
|
||||||
guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() },
|
from: vi.fn(() => ({
|
||||||
guildsForumsComments: { deleteMany: vi.fn() },
|
where: vi.fn(() => ({
|
||||||
guildForumViews: { deleteMany: vi.fn() },
|
limit: selectLimit,
|
||||||
guildsMembers: { deleteMany: vi.fn() },
|
})),
|
||||||
rooms: { updateMany: vi.fn() },
|
})),
|
||||||
items: { updateMany: vi.fn() },
|
})),
|
||||||
$transaction: vi.fn(),
|
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("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
@@ -35,29 +53,32 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("disbandGuild", () => {
|
describe("disbandGuild", () => {
|
||||||
it("disbands guild and cleans related data", async () => {
|
it("disbands guild and cleans related data", async () => {
|
||||||
vi.mocked(prisma.guilds.findUnique).mockResolvedValue({
|
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
|
||||||
id: 1,
|
transactionFn.mockImplementation(
|
||||||
name: "TestGuild",
|
async (fn: (tx: unknown) => Promise<void>) => {
|
||||||
userId: 42,
|
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
|
||||||
} as never);
|
const tx = {
|
||||||
vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([
|
select: vi.fn(() => ({
|
||||||
{ id: 10 },
|
from: vi.fn(() => ({
|
||||||
] as never);
|
where: vi.fn(() => ({
|
||||||
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => {
|
limit: txSelectLimit,
|
||||||
const tx = {
|
})),
|
||||||
guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) },
|
})),
|
||||||
guildsForumsThreads: {
|
})),
|
||||||
findMany: vi.fn().mockResolvedValue([{ id: 10 }]),
|
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||||
deleteMany: vi.fn().mockResolvedValue({}),
|
update: vi.fn(() => ({
|
||||||
},
|
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||||
guildForumViews: { deleteMany: vi.fn().mockResolvedValue({}) },
|
})),
|
||||||
guildsMembers: { deleteMany: vi.fn().mockResolvedValue({}) },
|
};
|
||||||
rooms: { updateMany: vi.fn().mockResolvedValue({}) },
|
// For threads findMany (no limit) — make where resolve to array
|
||||||
items: { updateMany: vi.fn().mockResolvedValue({}) },
|
tx.select = vi.fn(() => ({
|
||||||
guilds: { delete: vi.fn().mockResolvedValue({}) },
|
from: vi.fn(() => ({
|
||||||
} as never;
|
where: vi.fn().mockResolvedValue([{ id: 10 }]),
|
||||||
await (fn as (tx: never) => Promise<void>)(tx);
|
})),
|
||||||
});
|
}));
|
||||||
|
await fn(tx);
|
||||||
|
},
|
||||||
|
);
|
||||||
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||||
@@ -65,6 +86,6 @@ describe("disbandGuild", () => {
|
|||||||
|
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
GuildForumViews,
|
||||||
|
Guilds,
|
||||||
|
GuildsForumsComments,
|
||||||
|
GuildsForumsThreads,
|
||||||
|
GuildsMembers,
|
||||||
|
Items,
|
||||||
|
Rooms,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
/** Disband a guild and clean related membership/forum rows. */
|
/** 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"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
const guild = await prisma.guilds.findUnique({
|
const [guild] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { id: true, name: true, userId: true },
|
id: Guilds.id,
|
||||||
});
|
name: Guilds.name,
|
||||||
|
userId: Guilds.userId,
|
||||||
|
})
|
||||||
|
.from(Guilds)
|
||||||
|
.where(eq(Guilds.id, id))
|
||||||
|
.limit(1);
|
||||||
if (!guild) return;
|
if (!guild) return;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const threads = await tx.guildsForumsThreads.findMany({
|
const threads = await tx
|
||||||
where: { guildId: id },
|
.select({ id: GuildsForumsThreads.id })
|
||||||
select: { id: true },
|
.from(GuildsForumsThreads)
|
||||||
});
|
.where(eq(GuildsForumsThreads.guildId, id));
|
||||||
const threadIds = threads.map((t) => t.id);
|
const threadIds = threads.map((t) => t.id);
|
||||||
if (threadIds.length > 0) {
|
if (threadIds.length > 0) {
|
||||||
await tx.guildsForumsComments.deleteMany({
|
await tx
|
||||||
where: { threadId: { in: threadIds } },
|
.delete(GuildsForumsComments)
|
||||||
});
|
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||||
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
|
await tx
|
||||||
|
.delete(GuildsForumsThreads)
|
||||||
|
.where(eq(GuildsForumsThreads.guildId, id));
|
||||||
}
|
}
|
||||||
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
|
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
|
||||||
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
|
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
|
||||||
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
|
||||||
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
|
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
|
||||||
await tx.guilds.delete({ where: { id } });
|
await tx.delete(Guilds).where(eq(Guilds.id, id));
|
||||||
});
|
});
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
db,
|
||||||
|
WebsiteHelpCenterTicketReplies,
|
||||||
|
WebsiteHelpCenterTickets,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
@@ -38,25 +44,32 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, userId: true, open: true, title: true },
|
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) throw new ActionError("Ticket not found");
|
||||||
if (ticket.userId == null) {
|
if (ticket.userId == null) {
|
||||||
throw new ActionError("Ticket has no requester to unban");
|
throw new ActionError("Ticket has no requester to unban");
|
||||||
}
|
}
|
||||||
|
|
||||||
const removed = await prisma.ban.deleteMany({
|
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
||||||
where: { userId: ticket.userId },
|
const removed = Number(
|
||||||
});
|
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (ticket.open) {
|
if (ticket.open) {
|
||||||
await prisma.websiteHelpCenterTickets.update({
|
await db
|
||||||
where: { id: ticketId },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { open: false, updatedAt: now },
|
.set({ open: false, updatedAt: now })
|
||||||
});
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -66,7 +79,7 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
targetId: ticket.userId,
|
targetId: ticket.userId,
|
||||||
after: {
|
after: {
|
||||||
ticketId: String(ticketId),
|
ticketId: String(ticketId),
|
||||||
removedBans: removed.count,
|
removedBans: removed,
|
||||||
title: ticket.title,
|
title: ticket.title,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -74,7 +87,7 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
revalidatePath("/admin/bans");
|
revalidatePath("/admin/bans");
|
||||||
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
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 },
|
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, open: true },
|
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) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const staffId = Number(ctx.session.user.id);
|
const staffId = Number(ctx.session.user.id);
|
||||||
|
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.websiteHelpCenterTicketReplies.create({
|
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
||||||
data: {
|
ticketId,
|
||||||
ticketId,
|
userId: staffId,
|
||||||
userId: staffId,
|
content: ctx.data.content.trim(),
|
||||||
content: ctx.data.content.trim(),
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
});
|
||||||
},
|
await tx
|
||||||
}),
|
.update(WebsiteHelpCenterTickets)
|
||||||
prisma.websiteHelpCenterTickets.update({
|
.set({ updatedAt: now })
|
||||||
where: { id: ticketId },
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
data: { updatedAt: now },
|
});
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: staffId,
|
userId: staffId,
|
||||||
@@ -126,19 +141,23 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, open: true },
|
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) throw new ActionError("Ticket not found");
|
||||||
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.update({
|
await db
|
||||||
where: { id: ticketId },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { open: false, updatedAt: now },
|
.set({ open: false, updatedAt: now })
|
||||||
});
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: Number(ctx.session.user.id),
|
userId: Number(ctx.session.user.id),
|
||||||
@@ -158,19 +177,23 @@ export const reopenHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, open: true },
|
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) throw new ActionError("Ticket not found");
|
||||||
if (ticket.open) throw new ActionError("Ticket is already open");
|
if (ticket.open) throw new ActionError("Ticket is already open");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.update({
|
await db
|
||||||
where: { id: ticketId },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { open: true, updatedAt: now },
|
.set({ open: true, updatedAt: now })
|
||||||
});
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: Number(ctx.session.user.id),
|
userId: Number(ctx.session.user.id),
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import {
|
import {
|
||||||
createHelpQuestion,
|
createHelpQuestion,
|
||||||
deleteHelpQuestion,
|
deleteHelpQuestion,
|
||||||
updateHelpQuestion,
|
updateHelpQuestion,
|
||||||
} from "./admin-help";
|
} 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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
websiteHelpCenterCategories: {
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
create: vi.fn(),
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||||
update: vi.fn(),
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
WebsiteHelpCenterCategories: { id: "id" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
@@ -31,29 +36,26 @@ const fakeForm = (data: Record<string, string | null>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
||||||
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createHelpQuestion", () => {
|
describe("createHelpQuestion", () => {
|
||||||
it("creates a help question and redirects", async () => {
|
it("creates a help question and redirects", async () => {
|
||||||
vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({
|
|
||||||
id: BigInt(5),
|
|
||||||
} as never);
|
|
||||||
await createHelpQuestion(
|
await createHelpQuestion(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
name: "FAQ",
|
name: "FAQ",
|
||||||
content: "<p>Answer</p>",
|
content: "<p>Answer</p>",
|
||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled();
|
expect(insertValues).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateHelpQuestion", () => {
|
describe("updateHelpQuestion", () => {
|
||||||
it("updates and redirects", async () => {
|
it("updates and redirects", async () => {
|
||||||
vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue(
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
await updateHelpQuestion(
|
await updateHelpQuestion(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
id: "42",
|
id: "42",
|
||||||
@@ -61,18 +63,15 @@ describe("updateHelpQuestion", () => {
|
|||||||
content: "New",
|
content: "New",
|
||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled();
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteHelpQuestion", () => {
|
describe("deleteHelpQuestion", () => {
|
||||||
it("deletes and redirects", async () => {
|
it("deletes and redirects", async () => {
|
||||||
vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue(
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled();
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+24
-22
@@ -1,12 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
// 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";
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
||||||
data: {
|
name,
|
||||||
name,
|
content,
|
||||||
content,
|
position: parsePosition(formData.get("position")),
|
||||||
position: parsePosition(formData.get("position")),
|
imageUrl: imageUrl || null,
|
||||||
imageUrl: imageUrl || null,
|
buttonText: buttonText || null,
|
||||||
buttonText: buttonText || null,
|
buttonUrl: buttonUrl || null,
|
||||||
buttonUrl: buttonUrl || null,
|
buttonColor,
|
||||||
buttonColor,
|
buttonBorderColor,
|
||||||
buttonBorderColor,
|
smallBox: formData.get("smallBox") != null,
|
||||||
smallBox: formData.get("smallBox") != null,
|
})) as unknown as [ResultSetHeader];
|
||||||
},
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_create",
|
action: "help_create",
|
||||||
description: `Created help-center entry #${entry.id} (${name})`,
|
description: `Created help-center entry #${result.insertId} (${name})`,
|
||||||
targetType: "help_center_category",
|
targetType: "help_center_category",
|
||||||
targetId: Number(entry.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Unique name collision or DB error — re-render unchanged with error.
|
// 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";
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteHelpCenterCategories)
|
||||||
data: {
|
.set({
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
position: parsePosition(formData.get("position")),
|
position: parsePosition(formData.get("position")),
|
||||||
@@ -93,8 +93,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
buttonColor,
|
buttonColor,
|
||||||
buttonBorderColor,
|
buttonBorderColor,
|
||||||
smallBox: formData.get("smallBox") != null,
|
smallBox: formData.get("smallBox") != null,
|
||||||
},
|
})
|
||||||
});
|
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_update",
|
action: "help_update",
|
||||||
@@ -116,7 +116,9 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
await db
|
||||||
|
.delete(WebsiteHelpCenterCategories)
|
||||||
|
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_delete",
|
action: "help_delete",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { asc } from "drizzle-orm";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
|
||||||
import { redirectSafe } from "@/lib/foundation/security";
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||||
@@ -34,16 +35,19 @@ export async function bulkImportPermissions(
|
|||||||
export async function exportPermissions(): Promise<string> {
|
export async function exportPermissions(): Promise<string> {
|
||||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
|
||||||
const perms = await prisma.websiteHousekeepingPermissions.findMany({
|
const perms = await db
|
||||||
orderBy: [{ groupName: "asc" }, { permission: "asc" }],
|
.select({
|
||||||
select: {
|
permission: WebsiteHousekeepingPermissions.permission,
|
||||||
permission: true,
|
minRank: WebsiteHousekeepingPermissions.minRank,
|
||||||
minRank: true,
|
description: WebsiteHousekeepingPermissions.description,
|
||||||
description: true,
|
groupName: WebsiteHousekeepingPermissions.groupName,
|
||||||
groupName: true,
|
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
|
||||||
dependsOn: true,
|
})
|
||||||
},
|
.from(WebsiteHousekeepingPermissions)
|
||||||
});
|
.orderBy(
|
||||||
|
asc(WebsiteHousekeepingPermissions.groupName),
|
||||||
|
asc(WebsiteHousekeepingPermissions.permission),
|
||||||
|
);
|
||||||
|
|
||||||
return JSON.stringify(perms, null, 2);
|
return JSON.stringify(perms, null, 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import {
|
import {
|
||||||
addBlacklist,
|
addBlacklist,
|
||||||
addWhitelist,
|
addWhitelist,
|
||||||
@@ -10,15 +9,23 @@ import {
|
|||||||
deleteWhitelist,
|
deleteWhitelist,
|
||||||
} from "./admin-ip";
|
} 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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() },
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() },
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
},
|
},
|
||||||
|
WebsiteIpWhitelist: { id: "id" },
|
||||||
|
WebsiteIpBlacklist: { id: "id" },
|
||||||
}));
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
@@ -30,6 +37,8 @@ const fakeForm = (data: Record<string, string>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("addWhitelist", () => {
|
describe("addWhitelist", () => {
|
||||||
@@ -37,24 +46,24 @@ describe("addWhitelist", () => {
|
|||||||
await addWhitelist(
|
await addWhitelist(
|
||||||
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({
|
expect(insertValues).toHaveBeenCalledWith({
|
||||||
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false },
|
ipAddress: "192.168.1.1",
|
||||||
|
asn: null,
|
||||||
|
whitelistAsn: false,
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when ip is empty", async () => {
|
it("returns early when ip is empty", async () => {
|
||||||
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
||||||
expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled();
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteWhitelist", () => {
|
describe("deleteWhitelist", () => {
|
||||||
it("deletes whitelist entry", async () => {
|
it("deletes whitelist entry", async () => {
|
||||||
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
where: { id: BigInt(42) },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,8 +72,10 @@ describe("addBlacklist", () => {
|
|||||||
await addBlacklist(
|
await addBlacklist(
|
||||||
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({
|
expect(insertValues).toHaveBeenCalledWith({
|
||||||
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false },
|
ipAddress: "203.0.113.1",
|
||||||
|
asn: null,
|
||||||
|
blacklistAsn: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -72,8 +83,6 @@ describe("addBlacklist", () => {
|
|||||||
describe("deleteBlacklist", () => {
|
describe("deleteBlacklist", () => {
|
||||||
it("deletes blacklist entry", async () => {
|
it("deletes blacklist entry", async () => {
|
||||||
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||||
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
where: { id: BigInt(99) },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+16
-7
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
function parseIp(formData: FormData): string {
|
function parseIp(formData: FormData): string {
|
||||||
return String(formData.get("ipAddress") ?? "")
|
return String(formData.get("ipAddress") ?? "")
|
||||||
@@ -25,8 +26,10 @@ export async function addWhitelist(formData: FormData): Promise<void> {
|
|||||||
const ipAddress = parseIp(formData);
|
const ipAddress = parseIp(formData);
|
||||||
if (!ipAddress) return;
|
if (!ipAddress) return;
|
||||||
const asn = parseAsn(formData);
|
const asn = parseAsn(formData);
|
||||||
await prisma.websiteIpWhitelist.create({
|
await db.insert(WebsiteIpWhitelist).values({
|
||||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
ipAddress,
|
||||||
|
asn,
|
||||||
|
whitelistAsn: asn != null,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,9 @@ export async function deleteWhitelist(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
await db
|
||||||
|
.delete(WebsiteIpWhitelist)
|
||||||
|
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +51,10 @@ export async function addBlacklist(formData: FormData): Promise<void> {
|
|||||||
const ipAddress = parseIp(formData);
|
const ipAddress = parseIp(formData);
|
||||||
if (!ipAddress) return;
|
if (!ipAddress) return;
|
||||||
const asn = parseAsn(formData);
|
const asn = parseAsn(formData);
|
||||||
await prisma.websiteIpBlacklist.create({
|
await db.insert(WebsiteIpBlacklist).values({
|
||||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
ipAddress,
|
||||||
|
asn,
|
||||||
|
blacklistAsn: asn != null,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -58,6 +65,8 @@ export async function deleteBlacklist(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
await db
|
||||||
|
.delete(WebsiteIpBlacklist)
|
||||||
|
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
|
||||||
revalidatePath("/admin/ip");
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
|
const {
|
||||||
vi.hoisted(() => ({
|
mockValues,
|
||||||
mockUpsert: vi.fn(),
|
mockOnDuplicateKeyUpdate,
|
||||||
|
mockRequirePermission,
|
||||||
|
mockReload,
|
||||||
|
mockRevalidatePath,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockValues = vi.fn(() => ({
|
||||||
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
mockValues,
|
||||||
|
mockOnDuplicateKeyUpdate,
|
||||||
mockRequirePermission: vi.fn(),
|
mockRequirePermission: vi.fn(),
|
||||||
mockReload: vi.fn(),
|
mockReload: vi.fn(),
|
||||||
mockRevalidatePath: vi.fn(),
|
mockRevalidatePath: vi.fn(),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: {
|
PERMS: {
|
||||||
@@ -16,10 +28,11 @@ vi.mock("@/lib/permissions", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
websiteSetting: { upsert: mockUpsert },
|
insert: vi.fn(() => ({ values: mockValues })),
|
||||||
},
|
},
|
||||||
|
WebsiteSetting: { key: "key", value: "value" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
@@ -38,6 +51,10 @@ import { saveMaintenance } from "./admin-maintenance";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
|
});
|
||||||
|
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveMaintenance", () => {
|
describe("saveMaintenance", () => {
|
||||||
@@ -57,37 +74,26 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
expect(mockRequirePermission).toHaveBeenCalled();
|
expect(mockRequirePermission).toHaveBeenCalled();
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledTimes(3);
|
expect(mockValues).toHaveBeenCalledTimes(3);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "maintenance_enabled" },
|
key: "maintenance_enabled",
|
||||||
update: { value: "1" },
|
value: "1",
|
||||||
create: expect.objectContaining({
|
|
||||||
key: "maintenance_enabled",
|
|
||||||
value: "1",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "maintenance_message" },
|
key: "maintenance_message",
|
||||||
update: { value: "We will be back soon!" },
|
value: "We will be back soon!",
|
||||||
create: expect.objectContaining({
|
|
||||||
key: "maintenance_message",
|
|
||||||
value: "We will be back soon!",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "3" },
|
value: "3",
|
||||||
create: expect.objectContaining({
|
|
||||||
key: "min_maintenance_login_rank",
|
|
||||||
value: "3",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
expect(mockReload).toHaveBeenCalledOnce();
|
expect(mockReload).toHaveBeenCalledOnce();
|
||||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||||
@@ -106,16 +112,16 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "maintenance_enabled" },
|
key: "maintenance_enabled",
|
||||||
update: { value: "0" },
|
value: "0",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "5" },
|
value: "5",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -134,10 +140,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "5" },
|
value: "5",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -156,10 +162,10 @@ describe("saveMaintenance", () => {
|
|||||||
|
|
||||||
await saveMaintenance(fd);
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
expect(mockValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { key: "min_maintenance_login_rank" },
|
key: "min_maintenance_login_rank",
|
||||||
update: { value: "5" },
|
value: "5",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
|
// 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> {
|
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||||
await prisma.websiteSetting.upsert({
|
await db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
key,
|
||||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
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> {
|
export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { db, MarketplaceItems } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
/** Cancel an active marketplace listing (state 1 → 0). */
|
/** Cancel an active marketplace listing (state 1 → 0). */
|
||||||
@@ -14,16 +15,23 @@ export async function cancelMarketplaceListing(
|
|||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
const listing = await prisma.marketplaceItems.findUnique({
|
const [listing] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { id: true, state: true, userId: true, itemId: true, price: true },
|
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;
|
if (listing?.state !== 1) return;
|
||||||
|
|
||||||
await prisma.marketplaceItems.update({
|
await db
|
||||||
where: { id },
|
.update(MarketplaceItems)
|
||||||
data: { state: 0 },
|
.set({ state: 0 })
|
||||||
});
|
.where(eq(MarketplaceItems.id, id));
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, RadioApiKeys } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
// 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();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
const created = await prisma.radioApiKeys.create({
|
const [result] = await db.insert(RadioApiKeys).values({
|
||||||
data: {
|
name,
|
||||||
name,
|
key,
|
||||||
key,
|
allowedIps,
|
||||||
allowedIps,
|
rateLimit,
|
||||||
rateLimit,
|
isActive: true,
|
||||||
isActive: true,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const createdId = BigInt(result.insertId);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_api_key_create",
|
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",
|
targetType: "radio_api_key",
|
||||||
targetId: Number(created.id),
|
targetId: Number(createdId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
// 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;
|
if (id == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.radioApiKeys.findUnique({
|
const [existing] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { name: true, isActive: true },
|
name: RadioApiKeys.name,
|
||||||
});
|
isActive: RadioApiKeys.isActive,
|
||||||
|
})
|
||||||
|
.from(RadioApiKeys)
|
||||||
|
.where(eq(RadioApiKeys.id, id))
|
||||||
|
.limit(1);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
const next = !existing.isActive;
|
const next = !existing.isActive;
|
||||||
await prisma.radioApiKeys.update({
|
await db
|
||||||
where: { id },
|
.update(RadioApiKeys)
|
||||||
data: { isActive: next, updatedAt: new Date() },
|
.set({ isActive: next, updatedAt: new Date() })
|
||||||
});
|
.where(eq(RadioApiKeys.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_api_key_toggle",
|
action: "radio_api_key_toggle",
|
||||||
@@ -116,7 +120,7 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
|
|||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioApiKeys.delete({ where: { id } });
|
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_api_key_delete",
|
action: "radio_api_key_delete",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, RadioAutoDjPlaylist } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
|
// 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();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await prisma.radioAutoDjPlaylist.create({
|
const [result] = await db.insert(RadioAutoDjPlaylist).values({
|
||||||
data: {
|
title,
|
||||||
title,
|
artist: artist || null,
|
||||||
artist: artist || null,
|
album: album || null,
|
||||||
album: album || null,
|
artworkUrl: artworkUrl || null,
|
||||||
artworkUrl: artworkUrl || null,
|
duration,
|
||||||
duration,
|
sortOrder,
|
||||||
sortOrder,
|
isActive,
|
||||||
isActive,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const createdId = Number(result.insertId);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_autodj_create",
|
action: "radio_autodj_create",
|
||||||
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
||||||
targetType: "radio_auto_dj_track",
|
targetType: "radio_auto_dj_track",
|
||||||
targetId: Number(created.id),
|
targetId: createdId,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Fail soft — DB unavailable; re-render without throwing.
|
// 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"));
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioAutoDjPlaylist.update({
|
await db
|
||||||
where: { id },
|
.update(RadioAutoDjPlaylist)
|
||||||
data: { isActive, updatedAt: new Date() },
|
.set({ isActive, updatedAt: new Date() })
|
||||||
});
|
.where(eq(RadioAutoDjPlaylist.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_autodj_toggle",
|
action: "radio_autodj_toggle",
|
||||||
@@ -123,7 +123,7 @@ export async function deleteTrack(formData: FormData): Promise<void> {
|
|||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
|
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio_autodj_delete",
|
action: "radio_autodj_delete",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -44,11 +45,10 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
|||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteSetting.upsert({
|
await db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({ key, value, comment: comment || null })
|
||||||
create: { key, value, comment: comment || null },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
} catch {
|
} catch {
|
||||||
// DB unavailable — fail soft so the action does not throw.
|
// 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;
|
if (keys.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(
|
await Promise.all(
|
||||||
keys.map((key) => {
|
keys.map((key) => {
|
||||||
const value = str(formData.get(key));
|
const value = str(formData.get(key));
|
||||||
return prisma.websiteSetting.upsert({
|
return db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({ key, value, comment: null })
|
||||||
create: { key, value, comment: null },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
@@ -105,17 +104,15 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioBanners.create({
|
await db.insert(RadioBanners).values({
|
||||||
data: {
|
userId: BigInt(staff.id),
|
||||||
userId: BigInt(staff.id),
|
imagePath,
|
||||||
imagePath,
|
title: title || null,
|
||||||
title: title || null,
|
description: description || null,
|
||||||
description: description || null,
|
sortOrder,
|
||||||
sortOrder,
|
isActive,
|
||||||
isActive,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Fail soft.
|
// Fail soft.
|
||||||
@@ -139,17 +136,17 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
|
|||||||
if (!imagePath) return;
|
if (!imagePath) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioBanners.update({
|
await db
|
||||||
where: { id },
|
.update(RadioBanners)
|
||||||
data: {
|
.set({
|
||||||
imagePath,
|
imagePath,
|
||||||
title: title || null,
|
title: title || null,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
isActive,
|
isActive,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
})
|
||||||
});
|
.where(eq(RadioBanners.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// Row may be gone; ignore.
|
// Row may be gone; ignore.
|
||||||
}
|
}
|
||||||
@@ -161,7 +158,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
|||||||
const id = parseId(formData.get("id"));
|
const id = parseId(formData.get("id"));
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
try {
|
try {
|
||||||
await prisma.radioBanners.delete({ where: { id } });
|
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// Already deleted; ignore.
|
// Already deleted; ignore.
|
||||||
}
|
}
|
||||||
@@ -181,15 +178,13 @@ export async function createRadioRank(formData: FormData): Promise<void> {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioRanks.create({
|
await db.insert(RadioRanks).values({
|
||||||
data: {
|
name,
|
||||||
name,
|
description: description || null,
|
||||||
description: description || null,
|
badgeCode: badgeCode || null,
|
||||||
badgeCode: badgeCode || null,
|
isActive,
|
||||||
isActive,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Fail soft.
|
// Fail soft.
|
||||||
@@ -209,16 +204,16 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioRanks.update({
|
await db
|
||||||
where: { id },
|
.update(RadioRanks)
|
||||||
data: {
|
.set({
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
badgeCode: badgeCode || null,
|
badgeCode: badgeCode || null,
|
||||||
isActive,
|
isActive,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
})
|
||||||
});
|
.where(eq(RadioRanks.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// Row may be gone; ignore.
|
// Row may be gone; ignore.
|
||||||
}
|
}
|
||||||
@@ -230,7 +225,7 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
|
|||||||
const id = parseId(formData.get("id"));
|
const id = parseId(formData.get("id"));
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
try {
|
try {
|
||||||
await prisma.radioRanks.delete({ where: { id } });
|
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// Already deleted; ignore.
|
// Already deleted; ignore.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, RadioShouts } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
/** 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;
|
if (id === null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioShouts.delete({ where: { id } });
|
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "radio.shout.delete",
|
action: "radio.shout.delete",
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -67,15 +67,16 @@ export async function savePoints(formData: FormData): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(
|
await Promise.all(
|
||||||
POINTS_KEYS.map((key) =>
|
POINTS_KEYS.map((key) =>
|
||||||
prisma.websiteSetting.upsert({
|
db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||||
update: { value: values[key] },
|
.values({ key, value: values[key], comment: "Radio points" })
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
.onDuplicateKeyUpdate({
|
||||||
create: { key, value: values[key], comment: "Radio points" },
|
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||||
}),
|
set: { value: values[key] },
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function createCategory(formData: FormData): Promise<void> {
|
export async function createCategory(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.SHOP_EDIT);
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
@@ -24,8 +25,10 @@ export async function createCategory(formData: FormData): Promise<void> {
|
|||||||
if (!name || !badge) return;
|
if (!name || !badge) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteRareValueCategories.create({
|
await db.insert(WebsiteRareValueCategories).values({
|
||||||
data: { name, badge, priority },
|
name,
|
||||||
|
badge,
|
||||||
|
priority,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
// 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 {
|
try {
|
||||||
// Remove the category's values first to avoid orphaned rows.
|
// Remove the category's values first to avoid orphaned rows.
|
||||||
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
|
await db
|
||||||
await prisma.websiteRareValueCategories.delete({ where: { id } });
|
.delete(WebsiteRareValues)
|
||||||
|
.where(eq(WebsiteRareValues.categoryId, id));
|
||||||
|
await db
|
||||||
|
.delete(WebsiteRareValueCategories)
|
||||||
|
.where(eq(WebsiteRareValueCategories.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// Not found or DB error — ignore.
|
// Not found or DB error — ignore.
|
||||||
}
|
}
|
||||||
@@ -81,16 +88,14 @@ export async function createValue(formData: FormData): Promise<void> {
|
|||||||
.slice(0, 255) || "diamonds";
|
.slice(0, 255) || "diamonds";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteRareValues.create({
|
await db.insert(WebsiteRareValues).values({
|
||||||
data: {
|
categoryId,
|
||||||
categoryId,
|
itemId,
|
||||||
itemId,
|
name,
|
||||||
name,
|
creditValue: creditValueRaw || null,
|
||||||
creditValue: creditValueRaw || null,
|
currencyValue: currencyValueRaw || null,
|
||||||
currencyValue: currencyValueRaw || null,
|
currencyType,
|
||||||
currencyType,
|
furnitureIcon,
|
||||||
furnitureIcon,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// DB error — ignore.
|
// DB error — ignore.
|
||||||
@@ -104,7 +109,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteRareValues.delete({ where: { id } });
|
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
|
||||||
} catch {
|
} catch {
|
||||||
// Not found or DB error — ignore.
|
// Not found or DB error — ignore.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
import {
|
import {
|
||||||
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
||||||
normalizeHabboGamedataHotel,
|
normalizeHabboGamedataHotel,
|
||||||
} from "@/lib/habbo-gamedata-hotel";
|
} from "@/lib/habbo-gamedata-hotel";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -48,11 +49,10 @@ export const saveManagedSettings = adminAction(
|
|||||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(([key, value]) =>
|
entries.map(([key, value]) =>
|
||||||
prisma.websiteSetting.upsert({
|
db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({ key, value })
|
||||||
create: { key, value },
|
.onDuplicateKeyUpdate({ set: { value } }),
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await siteSettings.reload();
|
await siteSettings.reload();
|
||||||
@@ -76,7 +76,10 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
|||||||
String(formData.get("value") ?? "").normalize("NFC"),
|
String(formData.get("value") ?? "").normalize("NFC"),
|
||||||
);
|
);
|
||||||
if (!key) return;
|
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();
|
await siteSettings.reload();
|
||||||
bustGamedataCachesIfNeeded(key);
|
bustGamedataCachesIfNeeded(key);
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
@@ -97,11 +100,10 @@ export async function createSetting(formData: FormData): Promise<void> {
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.websiteSetting.upsert({
|
await db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({ key, value, comment: comment || null })
|
||||||
create: { key, value, comment: comment || null },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
await siteSettings.reload();
|
await siteSettings.reload();
|
||||||
bustGamedataCachesIfNeeded(key);
|
bustGamedataCachesIfNeeded(key);
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
@@ -113,7 +115,7 @@ export async function deleteSetting(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
await prisma.websiteSetting.delete({ where: { key } });
|
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||||
await siteSettings.reload();
|
await siteSettings.reload();
|
||||||
bustGamedataCachesIfNeeded(key);
|
bustGamedataCachesIfNeeded(key);
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
|
|||||||
+38
-37
@@ -1,11 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteShopArticles } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -40,43 +42,42 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const costs = reqUInt(formData, "costs");
|
||||||
try {
|
try {
|
||||||
const created = await prisma.websiteShopArticles.create({
|
const [result] = (await db.insert(WebsiteShopArticles).values({
|
||||||
data: {
|
name,
|
||||||
name,
|
info: String(formData.get("info") ?? "")
|
||||||
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")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255) || null,
|
||||||
iconUrl: String(formData.get("icon") ?? "")
|
position: reqUInt(formData, "position"),
|
||||||
.normalize("NFC")
|
createdAt: now,
|
||||||
.trim()
|
updatedAt: now,
|
||||||
.slice(0, 255),
|
})) as unknown as [ResultSetHeader];
|
||||||
color: String(formData.get("color") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
costs: reqUInt(formData, "costs"),
|
|
||||||
giveRank: optUInt(formData, "giveRank"),
|
|
||||||
credits: optUInt(formData, "credits"),
|
|
||||||
duckets: optUInt(formData, "duckets"),
|
|
||||||
diamonds: optUInt(formData, "diamonds"),
|
|
||||||
badges:
|
|
||||||
String(formData.get("badges") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
position: reqUInt(formData, "position"),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "shop_create",
|
action: "shop_create",
|
||||||
description: `Created shop package "${name}" (${created.costs} costs)`,
|
description: `Created shop package "${name}" (${costs} costs)`,
|
||||||
targetType: "shop_article",
|
targetType: "shop_article",
|
||||||
targetId: Number(created.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logServerError("admin.shop_create_failed", error, {
|
logServerError("admin.shop_create_failed", error, {
|
||||||
@@ -103,9 +104,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteShopArticles.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteShopArticles)
|
||||||
data: {
|
.set({
|
||||||
name,
|
name,
|
||||||
info: String(formData.get("info") ?? "")
|
info: String(formData.get("info") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -131,8 +132,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
|||||||
.slice(0, 255) || null,
|
.slice(0, 255) || null,
|
||||||
position: reqUInt(formData, "position"),
|
position: reqUInt(formData, "position"),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
})
|
||||||
});
|
.where(eq(WebsiteShopArticles.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "shop_update",
|
action: "shop_update",
|
||||||
@@ -159,7 +160,7 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteShopArticles.delete({ where: { id } });
|
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "shop_delete",
|
action: "shop_delete",
|
||||||
|
|||||||
@@ -2,18 +2,34 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
taggables: { deleteMany: vi.fn() },
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||||
$transaction: vi.fn(),
|
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("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
@@ -26,12 +42,18 @@ const fakeForm = (data: Record<string, string>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
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", () => {
|
describe("createTag", () => {
|
||||||
it("creates a tag and revalidates", async () => {
|
it("creates a tag and revalidates", async () => {
|
||||||
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
|
|
||||||
|
|
||||||
await createTag(
|
await createTag(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
name: "News",
|
name: "News",
|
||||||
@@ -39,10 +61,8 @@ describe("createTag", () => {
|
|||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(prisma.tags.create).toHaveBeenCalledWith(
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({ name: "News" }),
|
||||||
data: expect.objectContaining({ name: "News" }),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
@@ -50,23 +70,19 @@ describe("createTag", () => {
|
|||||||
|
|
||||||
it("returns early when name is empty", async () => {
|
it("returns early when name is empty", async () => {
|
||||||
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
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 () => {
|
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);
|
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
||||||
|
|
||||||
expect(prisma.tags.create).toHaveBeenCalledWith(
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({ backgroundColor: "#888888" }),
|
||||||
data: expect.objectContaining({ backgroundColor: "#888888" }),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles db error gracefully", async () => {
|
it("handles db error gracefully", async () => {
|
||||||
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error"));
|
insertValues.mockRejectedValue(new Error("DB error"));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||||
@@ -77,8 +93,6 @@ describe("createTag", () => {
|
|||||||
|
|
||||||
describe("updateTag", () => {
|
describe("updateTag", () => {
|
||||||
it("updates a tag and revalidates", async () => {
|
it("updates a tag and revalidates", async () => {
|
||||||
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
|
|
||||||
|
|
||||||
await updateTag(
|
await updateTag(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
id: "42",
|
id: "42",
|
||||||
@@ -87,54 +101,34 @@ describe("updateTag", () => {
|
|||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(prisma.tags.update).toHaveBeenCalledWith({
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
where: { id: BigInt(42) },
|
|
||||||
data: expect.objectContaining({ name: "Updated" }),
|
|
||||||
});
|
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is invalid", async () => {
|
it("returns early when id is invalid", async () => {
|
||||||
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
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 () => {
|
it("returns early when name is empty after update", async () => {
|
||||||
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||||
expect(prisma.tags.update).not.toHaveBeenCalled();
|
expect(updateWhere).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteTag", () => {
|
describe("deleteTag", () => {
|
||||||
it("deletes a tag and its taggables", async () => {
|
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);
|
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
|
||||||
expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({
|
expect(transaction).toHaveBeenCalled();
|
||||||
where: { tagId: BigInt(42) },
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
});
|
|
||||||
expect(prisma.tags.delete).toHaveBeenCalledWith({
|
|
||||||
where: { id: BigInt(42) },
|
|
||||||
});
|
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is invalid", async () => {
|
it("returns early when id is invalid", async () => {
|
||||||
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, Taggables, Tags } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -40,15 +42,18 @@ export async function createTag(formData: FormData): Promise<void> {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await prisma.tags.create({
|
const [result] = (await db.insert(Tags).values({
|
||||||
data: { name, backgroundColor, createdAt: now, updatedAt: now },
|
name,
|
||||||
});
|
backgroundColor,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "tag_create",
|
action: "tag_create",
|
||||||
description: `Created tag "${name}" (#${created.id})`,
|
description: `Created tag "${name}" (#${result.insertId})`,
|
||||||
targetType: "tag",
|
targetType: "tag",
|
||||||
targetId: Number(created.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Fail soft — DB unavailable or duplicate.
|
// Fail soft — DB unavailable or duplicate.
|
||||||
@@ -66,10 +71,10 @@ export async function updateTag(formData: FormData): Promise<void> {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.tags.update({
|
await db
|
||||||
where: { id },
|
.update(Tags)
|
||||||
data: { name, backgroundColor, updatedAt: new Date() },
|
.set({ name, backgroundColor, updatedAt: new Date() })
|
||||||
});
|
.where(eq(Tags.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "tag_update",
|
action: "tag_update",
|
||||||
@@ -90,10 +95,10 @@ export async function deleteTag(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove the tag and any taggable links pointing at it.
|
// Remove the tag and any taggable links pointing at it.
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.taggables.deleteMany({ where: { tagId: id } }),
|
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
|
||||||
prisma.tags.delete({ where: { id } }),
|
await tx.delete(Tags).where(eq(Tags.id, id));
|
||||||
]);
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "tag_delete",
|
action: "tag_delete",
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { createTeam, deleteTeam } from "./admin-teams";
|
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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } },
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
},
|
||||||
|
WebsiteTeams: { id: "id" },
|
||||||
}));
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
@@ -20,6 +29,8 @@ const fakeForm = (data: Record<string, string | null>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createTeam", () => {
|
describe("createTeam", () => {
|
||||||
@@ -27,25 +38,22 @@ describe("createTeam", () => {
|
|||||||
await createTeam(
|
await createTeam(
|
||||||
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
data: expect.objectContaining({ rankName: "Moderator" }),
|
expect.objectContaining({ rankName: "Moderator" }),
|
||||||
});
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when rankName is empty", async () => {
|
it("returns early when rankName is empty", async () => {
|
||||||
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
|
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
|
||||||
expect(prisma.websiteTeams.create).not.toHaveBeenCalled();
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteTeam", () => {
|
describe("deleteTeam", () => {
|
||||||
it("deletes a team entry", async () => {
|
it("deletes a team entry", async () => {
|
||||||
vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never);
|
|
||||||
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
|
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
where: { id: BigInt(42) },
|
|
||||||
});
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+11
-12
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteTeams } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function createTeam(formData: FormData): Promise<void> {
|
export async function createTeam(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
await requirePermission(PERMS.USERS_EDIT);
|
||||||
@@ -26,16 +27,14 @@ export async function createTeam(formData: FormData): Promise<void> {
|
|||||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteTeams.create({
|
await db.insert(WebsiteTeams).values({
|
||||||
data: {
|
rankName: rankName.slice(0, 255),
|
||||||
rankName: rankName.slice(0, 255),
|
badge: badge ? badge.slice(0, 255) : null,
|
||||||
badge: badge ? badge.slice(0, 255) : null,
|
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
|
||||||
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
|
staffColor: staffColor.slice(0, 255),
|
||||||
staffColor: staffColor.slice(0, 255),
|
hiddenRank,
|
||||||
hiddenRank,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/teams");
|
revalidatePath("/admin/teams");
|
||||||
@@ -45,7 +44,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.USERS_EDIT);
|
await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
|
||||||
const id = BigInt(String(formData.get("id")));
|
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");
|
revalidatePath("/admin/teams");
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
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;
|
const CUSTOM_CSS_MAX = 20000;
|
||||||
|
|
||||||
async function writeSetting(key: string, value: string): Promise<void> {
|
async function writeSetting(key: string, value: string): Promise<void> {
|
||||||
await prisma.websiteSetting.upsert({
|
await db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||||
create: { key, value, comment: "Theme (housekeeping)" },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTheme(formData: FormData): Promise<void> {
|
export async function saveTheme(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
import { positiveBigInt } from "@/lib/api";
|
||||||
|
import { db, WebsiteShopVouchers } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import {
|
import {
|
||||||
type ActionResult,
|
type ActionResult,
|
||||||
actionError,
|
actionError,
|
||||||
@@ -45,19 +47,17 @@ export async function createVoucher(input: {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await prisma.websiteShopVouchers.create({
|
const [result] = (await db.insert(WebsiteShopVouchers).values({
|
||||||
data: {
|
code,
|
||||||
code,
|
amount: Math.floor(amount),
|
||||||
amount: Math.floor(amount),
|
maxUses,
|
||||||
maxUses,
|
useCount: 0,
|
||||||
useCount: 0,
|
expiresAt,
|
||||||
expiresAt,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
})) as unknown as [ResultSetHeader];
|
||||||
},
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/vouchers");
|
revalidatePath("/admin/vouchers");
|
||||||
return actionOk({ id: String(created.id) });
|
return actionOk({ id: String(result.insertId) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logServerError("admin.voucher_create_failed", error);
|
logServerError("admin.voucher_create_failed", error);
|
||||||
return actionError("Could not create voucher (code may already exist)");
|
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");
|
if (!id) return actionError("Missing voucher id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteShopVouchers.delete({ where: { id } });
|
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
|
||||||
revalidatePath("/admin/vouchers");
|
revalidatePath("/admin/vouchers");
|
||||||
return actionOk();
|
return actionOk();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { saveVpn } from "./admin-vpn";
|
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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: { websiteSetting: { upsert: vi.fn() } },
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mockValues })),
|
||||||
|
},
|
||||||
|
WebsiteSetting: { key: "key", value: "value" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/services/site-settings", () => ({
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
siteSettings: { reload: vi.fn() },
|
siteSettings: { reload: vi.fn() },
|
||||||
@@ -27,7 +37,10 @@ const fakeForm = (data: Record<string, string | null>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never);
|
mockValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
|
});
|
||||||
|
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveVpn", () => {
|
describe("saveVpn", () => {
|
||||||
@@ -39,7 +52,8 @@ describe("saveVpn", () => {
|
|||||||
vpn_api_key: "abc123",
|
vpn_api_key: "abc123",
|
||||||
}) as unknown as FormData,
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4);
|
expect(mockValues).toHaveBeenCalledTimes(4);
|
||||||
|
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
|
||||||
expect(siteSettings.reload).toHaveBeenCalled();
|
expect(siteSettings.reload).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -21,11 +21,10 @@ async function writeSetting(
|
|||||||
value: string,
|
value: string,
|
||||||
comment: string,
|
comment: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await prisma.websiteSetting.upsert({
|
await db
|
||||||
where: { key },
|
.insert(WebsiteSetting)
|
||||||
update: { value },
|
.values({ key, value, comment })
|
||||||
create: { key, value, comment },
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveVpn(formData: FormData): Promise<void> {
|
export async function saveVpn(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteWordfilter } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import {
|
import {
|
||||||
type ActionResult,
|
type ActionResult,
|
||||||
actionError,
|
actionError,
|
||||||
@@ -23,11 +25,13 @@ export async function addWord(input: {
|
|||||||
if (!word) return actionError("Word is required");
|
if (!word) return actionError("Word is required");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await prisma.websiteWordfilter.create({ data: { word } });
|
const [result] = (await db
|
||||||
|
.insert(WebsiteWordfilter)
|
||||||
|
.values({ word })) as unknown as [ResultSetHeader];
|
||||||
reloadWordFilter();
|
reloadWordFilter();
|
||||||
await rcon.updateWordFilter();
|
await rcon.updateWordFilter();
|
||||||
revalidatePath("/admin/wordfilter");
|
revalidatePath("/admin/wordfilter");
|
||||||
return actionOk({ id: String(created.id) });
|
return actionOk({ id: String(result.insertId) });
|
||||||
} catch {
|
} catch {
|
||||||
return actionError("Could not add word (it may already exist)");
|
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");
|
if (!raw) return actionError("Missing word id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
|
await db
|
||||||
|
.delete(WebsiteWordfilter)
|
||||||
|
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
|
||||||
reloadWordFilter();
|
reloadWordFilter();
|
||||||
await rcon.updateWordFilter();
|
await rcon.updateWordFilter();
|
||||||
revalidatePath("/admin/wordfilter");
|
revalidatePath("/admin/wordfilter");
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteWriteableBoxes } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
|
// 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();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
const created = await prisma.websiteWriteableBoxes.create({
|
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
|
||||||
data: {
|
title,
|
||||||
title,
|
icon:
|
||||||
icon:
|
String(formData.get("icon") ?? "")
|
||||||
String(formData.get("icon") ?? "")
|
.normalize("NFC")
|
||||||
.normalize("NFC")
|
.trim()
|
||||||
.trim()
|
.slice(0, 255) || null,
|
||||||
.slice(0, 255) || null,
|
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
position: reqInt(formData, "position"),
|
||||||
position: reqInt(formData, "position"),
|
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||||
isActive:
|
createdAt: now,
|
||||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
updatedAt: now,
|
||||||
createdAt: now,
|
})) as unknown as [ResultSetHeader];
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "writeable_box_create",
|
action: "writeable_box_create",
|
||||||
description: `Created writeable box "${title}" (#${created.id})`,
|
description: `Created writeable box "${title}" (#${result.insertId})`,
|
||||||
targetType: "writeable_box",
|
targetType: "writeable_box",
|
||||||
targetId: Number(created.id),
|
targetId: Number(result.insertId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// DB unavailable — swallow and re-render.
|
// DB unavailable — swallow and re-render.
|
||||||
@@ -95,9 +94,9 @@ export async function updateBox(formData: FormData): Promise<void> {
|
|||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteWriteableBoxes.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteWriteableBoxes)
|
||||||
data: {
|
.set({
|
||||||
title,
|
title,
|
||||||
icon:
|
icon:
|
||||||
String(formData.get("icon") ?? "")
|
String(formData.get("icon") ?? "")
|
||||||
@@ -109,8 +108,8 @@ export async function updateBox(formData: FormData): Promise<void> {
|
|||||||
isActive:
|
isActive:
|
||||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
})
|
||||||
});
|
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "writeable_box_update",
|
action: "writeable_box_update",
|
||||||
@@ -132,7 +131,9 @@ export async function deleteBox(formData: FormData): Promise<void> {
|
|||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteWriteableBoxes.delete({ where: { id } });
|
await db
|
||||||
|
.delete(WebsiteWriteableBoxes)
|
||||||
|
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "writeable_box_delete",
|
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";
|
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteWriteableBoxes.update({
|
await db
|
||||||
where: { id },
|
.update(WebsiteWriteableBoxes)
|
||||||
data: { isActive: next, updatedAt: new Date() },
|
.set({ isActive: next, updatedAt: new Date() })
|
||||||
});
|
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "writeable_box_toggle",
|
action: "writeable_box_toggle",
|
||||||
|
|||||||
@@ -2,64 +2,83 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { applyStaff, applyTeam } from "./applications";
|
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/auth", () => ({ auth: vi.fn() }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: { websiteStaffApplications: { findFirst: vi.fn(), create: vi.fn() } },
|
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("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
const fakeForm = (data) => ({
|
||||||
get: (key: string) => data[key] ?? null,
|
get: (key) => data[key] ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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(clientIp).mockResolvedValue("127.0.0.1");
|
||||||
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||||
|
selectLimit.mockResolvedValue([]);
|
||||||
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyStaff", () => {
|
describe("applyStaff", () => {
|
||||||
it("submits staff application", async () => {
|
it("submits staff application", async () => {
|
||||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
await applyStaff(
|
await applyStaff(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
rankId: "3",
|
rankId: "3",
|
||||||
content: "I want to help!",
|
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");
|
expect(redirect).toHaveBeenCalledWith("/apply/staff?submitted=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects to login when not authenticated", async () => {
|
it("redirects to login when not authenticated", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth).mockResolvedValue(null);
|
||||||
await applyStaff(fakeForm({}) as unknown as FormData);
|
await applyStaff(fakeForm({}));
|
||||||
expect(redirect).toHaveBeenCalledWith("/login");
|
expect(redirect).toHaveBeenCalledWith("/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns duplicate status when application exists", async () => {
|
it("returns duplicate status when application exists", async () => {
|
||||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue({
|
selectLimit.mockResolvedValue([{ id: 1 }]);
|
||||||
id: 1,
|
|
||||||
} as never);
|
|
||||||
await applyStaff(
|
await applyStaff(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
rankId: "3",
|
rankId: "3",
|
||||||
content: "I want to help!",
|
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");
|
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=duplicate");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +88,7 @@ describe("applyStaff", () => {
|
|||||||
fakeForm({
|
fakeForm({
|
||||||
rankId: "3",
|
rankId: "3",
|
||||||
content: "I want to help!",
|
content: "I want to help!",
|
||||||
}) as unknown as FormData,
|
}),
|
||||||
);
|
);
|
||||||
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit");
|
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit");
|
||||||
});
|
});
|
||||||
@@ -77,25 +96,25 @@ describe("applyStaff", () => {
|
|||||||
|
|
||||||
describe("applyTeam", () => {
|
describe("applyTeam", () => {
|
||||||
it("submits team application", async () => {
|
it("submits team application", async () => {
|
||||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
await applyTeam(
|
await applyTeam(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
teamId: "2",
|
teamId: "2",
|
||||||
content: "I want to join team!",
|
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");
|
expect(redirect).toHaveBeenCalledWith("/apply/team?submitted=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects to login when not authenticated", async () => {
|
it("redirects to login when not authenticated", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth).mockResolvedValue(null);
|
||||||
await applyTeam(fakeForm({}) as unknown as FormData);
|
await applyTeam(fakeForm({}));
|
||||||
expect(redirect).toHaveBeenCalledWith("/login");
|
expect(redirect).toHaveBeenCalledWith("/login");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+34
-13
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
// 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) {
|
if (content.length < CONTENT_MIN) {
|
||||||
outcome = "empty";
|
outcome = "empty";
|
||||||
} else {
|
} else {
|
||||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
const [existing] = await db
|
||||||
where: { userId, rankId },
|
.select({ id: WebsiteStaffApplications.id })
|
||||||
select: { id: true },
|
.from(WebsiteStaffApplications)
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteStaffApplications.userId, userId),
|
||||||
|
eq(WebsiteStaffApplications.rankId, rankId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
outcome = "duplicate";
|
outcome = "duplicate";
|
||||||
} else {
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteStaffApplications.create({
|
await db.insert(WebsiteStaffApplications).values({
|
||||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
userId,
|
||||||
|
rankId,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
outcome = "submitted";
|
outcome = "submitted";
|
||||||
}
|
}
|
||||||
@@ -120,16 +131,26 @@ export async function applyTeam(formData: FormData): Promise<void> {
|
|||||||
if (content.length < CONTENT_MIN) {
|
if (content.length < CONTENT_MIN) {
|
||||||
outcome = "empty";
|
outcome = "empty";
|
||||||
} else {
|
} else {
|
||||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
const [existing] = await db
|
||||||
where: { userId, rankId },
|
.select({ id: WebsiteStaffApplications.id })
|
||||||
select: { id: true },
|
.from(WebsiteStaffApplications)
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteStaffApplications.userId, userId),
|
||||||
|
eq(WebsiteStaffApplications.rankId, rankId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
outcome = "duplicate";
|
outcome = "duplicate";
|
||||||
} else {
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteStaffApplications.create({
|
await db.insert(WebsiteStaffApplications).values({
|
||||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
userId,
|
||||||
|
rankId,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
outcome = "submitted";
|
outcome = "submitted";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
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 { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { isAllowed } from "@/lib/services/moderation";
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
@@ -83,23 +84,22 @@ export async function postComment(formData: FormData): Promise<void> {
|
|||||||
outcome = "invalid";
|
outcome = "invalid";
|
||||||
} else {
|
} else {
|
||||||
const articleId = BigInt(articleIdRaw);
|
const articleId = BigInt(articleIdRaw);
|
||||||
const article = await prisma.websiteArticles.findUnique({
|
const [article] = await db
|
||||||
where: { id: articleId },
|
.select({ slug: WebsiteArticles.slug })
|
||||||
select: { slug: true },
|
.from(WebsiteArticles)
|
||||||
});
|
.where(eq(WebsiteArticles.id, articleId))
|
||||||
|
.limit(1);
|
||||||
if (!article) {
|
if (!article) {
|
||||||
outcome = "not_found";
|
outcome = "not_found";
|
||||||
} else {
|
} else {
|
||||||
slug = article.slug;
|
slug = article.slug;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteArticleComments.create({
|
await db.insert(WebsiteArticleComments).values({
|
||||||
data: {
|
articleId,
|
||||||
articleId,
|
userId,
|
||||||
userId,
|
comment,
|
||||||
comment,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
outcome = "posted";
|
outcome = "posted";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { db, WebsiteArticleReactions, WebsiteArticles } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
// The reaction set the UI offers. The action rejects anything outside this list
|
// 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 {
|
} else {
|
||||||
const articleId = BigInt(articleIdRaw);
|
const articleId = BigInt(articleIdRaw);
|
||||||
|
|
||||||
const article = await prisma.websiteArticles.findUnique({
|
const [article] = await db
|
||||||
where: { id: articleId },
|
.select({ slug: WebsiteArticles.slug })
|
||||||
select: { slug: true },
|
.from(WebsiteArticles)
|
||||||
});
|
.where(eq(WebsiteArticles.id, articleId))
|
||||||
|
.limit(1);
|
||||||
if (!article) {
|
if (!article) {
|
||||||
outcome = "not_found";
|
outcome = "not_found";
|
||||||
} else {
|
} else {
|
||||||
slug = article.slug;
|
slug = article.slug;
|
||||||
|
|
||||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
const [existing] = await db
|
||||||
where: { userId, articleId, reaction },
|
.select({
|
||||||
select: { id: true, active: true },
|
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) {
|
if (existing?.active) {
|
||||||
await prisma.websiteArticleReactions.update({
|
await db
|
||||||
where: { id: existing.id },
|
.update(WebsiteArticleReactions)
|
||||||
data: { active: false },
|
.set({ active: false })
|
||||||
});
|
.where(eq(WebsiteArticleReactions.id, existing.id));
|
||||||
} else {
|
} else {
|
||||||
await prisma.websiteArticleReactions.updateMany({
|
await db
|
||||||
where: { userId, articleId, active: true },
|
.update(WebsiteArticleReactions)
|
||||||
data: { active: false },
|
.set({ active: false })
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticleReactions.userId, userId),
|
||||||
|
eq(WebsiteArticleReactions.articleId, articleId),
|
||||||
|
eq(WebsiteArticleReactions.active, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.websiteArticleReactions.update({
|
await db
|
||||||
where: { id: existing.id },
|
.update(WebsiteArticleReactions)
|
||||||
data: { active: true },
|
.set({ active: true })
|
||||||
});
|
.where(eq(WebsiteArticleReactions.id, existing.id));
|
||||||
} else {
|
} else {
|
||||||
await prisma.websiteArticleReactions.create({
|
await db.insert(WebsiteArticleReactions).values({
|
||||||
data: { userId, articleId, reaction, active: true },
|
userId,
|
||||||
|
articleId,
|
||||||
|
reaction,
|
||||||
|
active: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { checkLogin } from "@/lib/auth/password";
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { precheckLogin } from "./auth-precheck";
|
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("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
|
||||||
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
|
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/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||||
vi.mock("@/lib/services/captcha", () => ({
|
vi.mock("@/lib/services/captcha", () => ({
|
||||||
captchaConfig: vi.fn(),
|
captchaConfig: vi.fn(),
|
||||||
@@ -25,26 +46,31 @@ beforeEach(() => {
|
|||||||
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||||
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
|
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
|
||||||
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
||||||
|
selectLimit.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("precheckLogin", () => {
|
describe("precheckLogin", () => {
|
||||||
it("returns ok for valid login without 2FA", async () => {
|
it("returns ok for valid login without 2FA", async () => {
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
selectLimit.mockResolvedValue([
|
||||||
password: "hash",
|
{
|
||||||
twoFactorConfirmedAt: null,
|
password: "hash",
|
||||||
mail: null,
|
twoFactorConfirmedAt: null,
|
||||||
mailVerified: "0",
|
mail: null,
|
||||||
} as never);
|
mailVerified: "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(await precheckLogin("user", "pass")).toBe("ok");
|
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns twofactor when 2FA is set up", async () => {
|
it("returns twofactor when 2FA is set up", async () => {
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
selectLimit.mockResolvedValue([
|
||||||
password: "hash",
|
{
|
||||||
twoFactorConfirmedAt: new Date(),
|
password: "hash",
|
||||||
mail: null,
|
twoFactorConfirmedAt: new Date(),
|
||||||
mailVerified: "0",
|
mail: null,
|
||||||
} as never);
|
mailVerified: "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,29 +83,33 @@ describe("precheckLogin", () => {
|
|||||||
provider: "hcaptcha",
|
provider: "hcaptcha",
|
||||||
} as never);
|
} as never);
|
||||||
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
selectLimit.mockResolvedValue([
|
||||||
password: "hash",
|
{
|
||||||
twoFactorConfirmedAt: null,
|
password: "hash",
|
||||||
mail: null,
|
twoFactorConfirmedAt: null,
|
||||||
mailVerified: "0",
|
mail: null,
|
||||||
} as never);
|
mailVerified: "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns invalid when user not found (dummy hash check)", async () => {
|
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");
|
const result = await precheckLogin("nonexistent", "pass");
|
||||||
expect(result).toBe("invalid");
|
expect(result).toBe("invalid");
|
||||||
expect(checkLogin).toHaveBeenCalled();
|
expect(checkLogin).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns unverified when email verification required", async () => {
|
it("returns unverified when email verification required", async () => {
|
||||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
selectLimit.mockResolvedValue([
|
||||||
password: "hash",
|
{
|
||||||
twoFactorConfirmedAt: null,
|
password: "hash",
|
||||||
mail: "[email protected]",
|
twoFactorConfirmedAt: null,
|
||||||
mailVerified: "0",
|
mail: "[email protected]",
|
||||||
} as never);
|
mailVerified: "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
||||||
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { checkLogin } from "@/lib/auth/password";
|
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 { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -45,15 +46,17 @@ export async function precheckLogin(
|
|||||||
mailVerified: string;
|
mailVerified: string;
|
||||||
} | null;
|
} | null;
|
||||||
try {
|
try {
|
||||||
user = await prisma.user.findUnique({
|
const [row] = await db
|
||||||
where: { username: u },
|
.select({
|
||||||
select: {
|
password: User.password,
|
||||||
password: true,
|
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
|
||||||
twoFactorConfirmedAt: true,
|
mail: User.mail,
|
||||||
mail: true,
|
mailVerified: User.mailVerified,
|
||||||
mailVerified: true,
|
})
|
||||||
},
|
.from(User)
|
||||||
});
|
.where(eq(User.username, u))
|
||||||
|
.limit(1);
|
||||||
|
user = row ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return "invalid";
|
return "invalid";
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -1,16 +1,21 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteBadges } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function getBadgeData({ code }: { code: string }) {
|
export async function getBadgeData({ code }: { code: string }) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const badge = await prisma.websiteBadges.findUnique({
|
const [badge] = await db
|
||||||
where: { badgeKey: code },
|
.select({
|
||||||
select: { badgeName: true, badgeDescription: true },
|
badgeName: WebsiteBadges.badgeName,
|
||||||
});
|
badgeDescription: WebsiteBadges.badgeDescription,
|
||||||
|
})
|
||||||
|
.from(WebsiteBadges)
|
||||||
|
.where(eq(WebsiteBadges.badgeKey, code))
|
||||||
|
.limit(1);
|
||||||
if (!badge) return { ok: false as const, data: null };
|
if (!badge) return { ok: false as const, data: null };
|
||||||
return {
|
return {
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
@@ -28,16 +33,22 @@ export async function updateBadge({
|
|||||||
desc: string;
|
desc: string;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await prisma.websiteBadges.upsert({
|
const now = new Date();
|
||||||
where: { badgeKey: code },
|
await db
|
||||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
.insert(WebsiteBadges)
|
||||||
create: {
|
.values({
|
||||||
badgeKey: code,
|
badgeKey: code,
|
||||||
badgeName: name,
|
badgeName: name,
|
||||||
badgeDescription: desc,
|
badgeDescription: desc,
|
||||||
createdAt: new Date(),
|
createdAt: now,
|
||||||
updatedAt: new Date(),
|
updatedAt: now,
|
||||||
},
|
})
|
||||||
});
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
badgeName: name,
|
||||||
|
badgeDescription: desc,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
revalidatePath("/admin/import/badges");
|
revalidatePath("/admin/import/badges");
|
||||||
}
|
}
|
||||||
+17
-8
@@ -1,8 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { db, WebsiteBanner } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
@@ -22,15 +24,18 @@ const bannerSchema = z.object({
|
|||||||
export const createBanner = adminAction(
|
export const createBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||||
async (ctx) => {
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "banner_create",
|
action: "banner_create",
|
||||||
target: "WebsiteBanner",
|
target: "WebsiteBanner",
|
||||||
targetId: banner.id,
|
targetId: id,
|
||||||
after: { title: banner.title },
|
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 },
|
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
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");
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "banner_update",
|
action: "banner_update",
|
||||||
@@ -60,7 +69,7 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteBanner = adminAction(
|
export const deleteBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
|
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "banner_delete",
|
action: "banner_delete",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import {
|
import {
|
||||||
bulkBan,
|
bulkBan,
|
||||||
@@ -10,14 +9,78 @@ import {
|
|||||||
bulkUnban,
|
bulkUnban,
|
||||||
} from "./bulk-users";
|
} 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/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: {
|
db: {
|
||||||
ban: { deleteMany: vi.fn(), create: vi.fn() },
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
user: { update: vi.fn() },
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
usersCurrency: { upsert: vi.fn() },
|
update: vi.fn(() => ({
|
||||||
usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: 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", () => ({
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
@@ -35,11 +98,22 @@ const staff = { id: 1, rank: 7, username: "admin" };
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
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", () => {
|
describe("bulkUnban", () => {
|
||||||
it("unbans users", async () => {
|
it("unbans users", async () => {
|
||||||
vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never);
|
|
||||||
const r = await bulkUnban({ userIds: [1, 2, 3] });
|
const r = await bulkUnban({ userIds: [1, 2, 3] });
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
expect(r.data).toEqual({ unbanned: 3, total: 3 });
|
expect(r.data).toEqual({ unbanned: 3, total: 3 });
|
||||||
@@ -48,7 +122,6 @@ describe("bulkUnban", () => {
|
|||||||
|
|
||||||
describe("bulkBan", () => {
|
describe("bulkBan", () => {
|
||||||
it("bans users", async () => {
|
it("bans users", async () => {
|
||||||
vi.mocked(prisma.ban.create).mockResolvedValue({} as never);
|
|
||||||
const r = await bulkBan({
|
const r = await bulkBan({
|
||||||
userIds: [1, 2],
|
userIds: [1, 2],
|
||||||
reason: "Spam",
|
reason: "Spam",
|
||||||
@@ -56,12 +129,12 @@ describe("bulkBan", () => {
|
|||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
expect(r.data.banned).toBe(2);
|
expect(r.data.banned).toBe(2);
|
||||||
|
expect(insertValues).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bulkGiveCurrency", () => {
|
describe("bulkGiveCurrency", () => {
|
||||||
it("gives credits", async () => {
|
it("gives credits", async () => {
|
||||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
|
||||||
const r = await bulkGiveCurrency({
|
const r = await bulkGiveCurrency({
|
||||||
userIds: [1],
|
userIds: [1],
|
||||||
amount: 100,
|
amount: 100,
|
||||||
@@ -69,10 +142,10 @@ describe("bulkGiveCurrency", () => {
|
|||||||
});
|
});
|
||||||
expect(r.data.given).toBe(1);
|
expect(r.data.given).toBe(1);
|
||||||
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
||||||
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives pixels", async () => {
|
it("gives pixels", async () => {
|
||||||
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
|
|
||||||
const r = await bulkGiveCurrency({
|
const r = await bulkGiveCurrency({
|
||||||
userIds: [2],
|
userIds: [2],
|
||||||
amount: 50,
|
amount: 50,
|
||||||
@@ -80,10 +153,10 @@ describe("bulkGiveCurrency", () => {
|
|||||||
});
|
});
|
||||||
expect(r.data.given).toBe(1);
|
expect(r.data.given).toBe(1);
|
||||||
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
|
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
|
||||||
|
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives points", async () => {
|
it("gives points", async () => {
|
||||||
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
|
|
||||||
const r = await bulkGiveCurrency({
|
const r = await bulkGiveCurrency({
|
||||||
userIds: [3],
|
userIds: [3],
|
||||||
amount: 25,
|
amount: 25,
|
||||||
@@ -91,17 +164,17 @@ describe("bulkGiveCurrency", () => {
|
|||||||
});
|
});
|
||||||
expect(r.data.given).toBe(1);
|
expect(r.data.given).toBe(1);
|
||||||
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
|
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
|
||||||
|
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bulkGiveBadge", () => {
|
describe("bulkGiveBadge", () => {
|
||||||
it("gives badge to user", async () => {
|
it("gives badge to user", async () => {
|
||||||
vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null);
|
selectLimit.mockResolvedValueOnce([]);
|
||||||
vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({
|
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
|
||||||
_max: { slotId: 5 },
|
|
||||||
} as never);
|
|
||||||
vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never);
|
|
||||||
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
||||||
expect(r.data.given).toBe(1);
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(insertValues).toHaveBeenCalled();
|
||||||
|
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+77
-61
@@ -1,10 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -15,18 +22,19 @@ export async function bulkUnban({
|
|||||||
userIds: number[];
|
userIds: number[];
|
||||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const result = await prisma.ban.deleteMany({
|
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
|
||||||
where: { userId: { in: userIds } },
|
const unbanned = Number(
|
||||||
});
|
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||||
|
);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "bulk_unban",
|
action: "bulk_unban",
|
||||||
description: `Unbanned ${result.count} user(s)`,
|
description: `Unbanned ${unbanned} user(s)`,
|
||||||
targetType: "user",
|
targetType: "user",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: true as const,
|
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) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
await prisma.ban.create({
|
await db.insert(Ban).values({
|
||||||
data: {
|
userId,
|
||||||
userId,
|
ip: "",
|
||||||
ip: "",
|
machineId: "",
|
||||||
machineId: "",
|
userStaffId: staff.id,
|
||||||
userStaffId: staff.id,
|
timestamp: now,
|
||||||
timestamp: now,
|
banExpire: duration > 0 ? now + duration : 0,
|
||||||
banExpire: duration > 0 ? now + duration : 0,
|
banReason: reason,
|
||||||
banReason: reason,
|
type: "account",
|
||||||
type: "account",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
banned++;
|
banned++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -94,24 +100,26 @@ export async function bulkGiveCurrency({
|
|||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
if (type === "credits") {
|
if (type === "credits") {
|
||||||
await prisma.user.update({
|
await db
|
||||||
where: { id: userId },
|
.update(User)
|
||||||
data: { credits: { increment: amount } },
|
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||||
});
|
.where(eq(User.id, userId));
|
||||||
await rcon.giveCredits(userId, amount);
|
await rcon.giveCredits(userId, amount);
|
||||||
} else if (type === "pixels") {
|
} else if (type === "pixels") {
|
||||||
await prisma.usersCurrency.upsert({
|
await db
|
||||||
where: { userId_type: { userId, type: 0 } },
|
.insert(UsersCurrency)
|
||||||
update: { amount: { increment: amount } },
|
.values({ userId, type: 0, amount })
|
||||||
create: { userId, type: 0, amount },
|
.onDuplicateKeyUpdate({
|
||||||
});
|
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||||
|
});
|
||||||
await rcon.giveDuckets(userId, amount);
|
await rcon.giveDuckets(userId, amount);
|
||||||
} else if (type === "points") {
|
} else if (type === "points") {
|
||||||
await prisma.usersCurrency.upsert({
|
await db
|
||||||
where: { userId_type: { userId, type: 101 } },
|
.insert(UsersCurrency)
|
||||||
update: { amount: { increment: amount } },
|
.values({ userId, type: 101, amount })
|
||||||
create: { userId, type: 101, amount },
|
.onDuplicateKeyUpdate({
|
||||||
});
|
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||||
|
});
|
||||||
await rcon.givePointsGotw(userId, amount);
|
await rcon.givePointsGotw(userId, amount);
|
||||||
}
|
}
|
||||||
given++;
|
given++;
|
||||||
@@ -151,19 +159,23 @@ export async function bulkGiveBadge({
|
|||||||
|
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.usersBadges.findFirst({
|
const [existing] = await db
|
||||||
where: { userId, badgeCode },
|
.select({ id: UsersBadges.id })
|
||||||
select: { id: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(UsersBadges.userId, userId),
|
||||||
|
eq(UsersBadges.badgeCode, badgeCode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const max = await prisma.usersBadges.aggregate({
|
const [agg] = await db
|
||||||
where: { userId },
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||||
_max: { slotId: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(eq(UsersBadges.userId, userId));
|
||||||
const slotId = (max._max.slotId ?? 0) + 1;
|
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||||
await prisma.usersBadges.create({
|
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
|
||||||
data: { userId, slotId, badgeCode },
|
|
||||||
});
|
|
||||||
await rcon.giveBadge(userId, badgeCode);
|
await rcon.giveBadge(userId, badgeCode);
|
||||||
}
|
}
|
||||||
given++;
|
given++;
|
||||||
@@ -228,31 +240,35 @@ export async function bulkAdjustCurrency({
|
|||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
if (type === "credits") {
|
if (type === "credits") {
|
||||||
const user = await prisma.user.findUnique({
|
const [user] = await db
|
||||||
where: { id: userId },
|
.select({ credits: User.credits })
|
||||||
select: { credits: true },
|
.from(User)
|
||||||
});
|
.where(eq(User.id, userId))
|
||||||
|
.limit(1);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
failedIds.push({ userId, reason: "Not found" });
|
failedIds.push({ userId, reason: "Not found" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const next = Math.max(0, user.credits - take);
|
const next = Math.max(0, user.credits - take);
|
||||||
await prisma.user.update({
|
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
|
||||||
where: { id: userId },
|
|
||||||
data: { credits: next },
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const currencyType = type === "pixels" ? 0 : 101;
|
const currencyType = type === "pixels" ? 0 : 101;
|
||||||
const row = await prisma.usersCurrency.findUnique({
|
const [row] = await db
|
||||||
where: { userId_type: { userId, type: currencyType } },
|
.select({ amount: UsersCurrency.amount })
|
||||||
});
|
.from(UsersCurrency)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(UsersCurrency.userId, userId),
|
||||||
|
eq(UsersCurrency.type, currencyType),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
const current = row?.amount ?? 0;
|
const current = row?.amount ?? 0;
|
||||||
const next = Math.max(0, current - take);
|
const next = Math.max(0, current - take);
|
||||||
await prisma.usersCurrency.upsert({
|
await db
|
||||||
where: { userId_type: { userId, type: currencyType } },
|
.insert(UsersCurrency)
|
||||||
update: { amount: next },
|
.values({ userId, type: currencyType, amount: next })
|
||||||
create: { userId, type: currencyType, amount: next },
|
.onDuplicateKeyUpdate({ set: { amount: next } });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
adjusted++;
|
adjusted++;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+84
-75
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -55,10 +56,10 @@ export async function updateBcPage({
|
|||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
return { ok: false as const, error: "No valid fields to update" };
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await db
|
||||||
where: { id },
|
.update(CatalogPagesBc)
|
||||||
data: data as any,
|
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||||
});
|
.where(eq(CatalogPagesBc.id, id));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -73,7 +74,7 @@ export async function updateBcPage({
|
|||||||
|
|
||||||
export async function deleteBcItem({ id }: { id: number }) {
|
export async function deleteBcItem({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
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 rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -101,10 +102,10 @@ export async function updateBcItem({
|
|||||||
if (Object.keys(safe).length === 0) {
|
if (Object.keys(safe).length === 0) {
|
||||||
return { ok: false as const, error: "No valid fields to update" };
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
}
|
}
|
||||||
await prisma.catalogItemsBc.update({
|
await db
|
||||||
where: { id },
|
.update(CatalogItemsBc)
|
||||||
data: safe as any,
|
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||||
});
|
.where(eq(CatalogItemsBc.id, id));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -128,19 +129,18 @@ export async function createBcItem({
|
|||||||
extradata: string;
|
extradata: string;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const created = await prisma.catalogItemsBc.create({
|
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
|
||||||
data: { pageId, ...data },
|
const createdId = Number(result.insertId);
|
||||||
});
|
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "bc_item_create",
|
action: "bc_item_create",
|
||||||
description: `Created BC catalog item #${created.id}`,
|
description: `Created BC catalog item #${createdId}`,
|
||||||
targetType: "catalog_item_bc",
|
targetType: "catalog_item_bc",
|
||||||
targetId: created.id,
|
targetId: createdId,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
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({
|
export async function toggleBcPage({
|
||||||
@@ -151,15 +151,19 @@ export async function toggleBcPage({
|
|||||||
field: "enabled" | "visible";
|
field: "enabled" | "visible";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const page = await prisma.catalogPagesBc.findUnique({
|
const [page] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { enabled: true, visible: true },
|
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" };
|
if (!page) return { ok: false as const, error: "Page not found" };
|
||||||
await prisma.catalogPagesBc.update({
|
await db
|
||||||
where: { id },
|
.update(CatalogPagesBc)
|
||||||
data: { [field]: page[field] === "1" ? "0" : "1" },
|
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||||
});
|
.where(eq(CatalogPagesBc.id, id));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
@@ -176,31 +180,30 @@ export async function createBcPage(input: {
|
|||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const created = await prisma.catalogPagesBc.create({
|
const [result] = await db.insert(CatalogPagesBc).values({
|
||||||
data: {
|
caption: input.caption,
|
||||||
caption: input.caption,
|
parentId: input.parentId,
|
||||||
parentId: input.parentId,
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
pageLayout: input.pageLayout ?? "default_3x3",
|
iconColor: input.iconColor ?? 0,
|
||||||
iconColor: input.iconColor ?? 0,
|
iconImage: input.iconImage ?? 0,
|
||||||
iconImage: input.iconImage ?? 0,
|
orderNum: input.orderNum ?? 0,
|
||||||
orderNum: input.orderNum ?? 0,
|
visible: input.visible ?? "1",
|
||||||
visible: input.visible ?? "1",
|
enabled: input.enabled ?? "1",
|
||||||
enabled: input.enabled ?? "1",
|
pageHeadline: "",
|
||||||
pageHeadline: "",
|
pageTeaser: "",
|
||||||
pageTeaser: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const createdId = Number(result.insertId);
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "bc_page_create",
|
action: "bc_page_create",
|
||||||
description: `Created BC catalog page "${input.caption}"`,
|
description: `Created BC catalog page "${input.caption}"`,
|
||||||
targetType: "catalog_page_bc",
|
targetType: "catalog_page_bc",
|
||||||
targetId: created.id,
|
targetId: createdId,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
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> {
|
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) {
|
if (currentId === pageId) {
|
||||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||||
}
|
}
|
||||||
const parent = await prisma.catalogPagesBc.findUnique({
|
const [parent] = await db
|
||||||
where: { id: currentId },
|
.select({ parentId: CatalogPagesBc.parentId })
|
||||||
select: { parentId: true },
|
.from(CatalogPagesBc)
|
||||||
});
|
.where(eq(CatalogPagesBc.id, currentId))
|
||||||
|
.limit(1);
|
||||||
if (!parent || parent.parentId <= 0) break;
|
if (!parent || parent.parentId <= 0) break;
|
||||||
currentId = parent.parentId;
|
currentId = parent.parentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await db
|
||||||
where: { id: pageId },
|
.update(CatalogPagesBc)
|
||||||
data: { parentId: newParentId },
|
.set({ parentId: newParentId })
|
||||||
});
|
.where(eq(CatalogPagesBc.id, pageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderBcTreePage(input: {
|
export async function reorderBcTreePage(input: {
|
||||||
@@ -240,10 +244,10 @@ export async function reorderBcTreePage(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await db
|
||||||
where: { id: input.pageId },
|
.update(CatalogPagesBc)
|
||||||
data: { orderNum: input.newOrderNum },
|
.set({ orderNum: input.newOrderNum })
|
||||||
});
|
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
@@ -255,43 +259,48 @@ export async function deleteBcTreePage(input: {
|
|||||||
mode: "reparent" | "cascade";
|
mode: "reparent" | "cascade";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const page = await prisma.catalogPagesBc.findUnique({
|
const [page] = await db
|
||||||
where: { id: input.pageId },
|
.select({ parentId: CatalogPagesBc.parentId })
|
||||||
select: { parentId: true },
|
.from(CatalogPagesBc)
|
||||||
});
|
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||||
|
.limit(1);
|
||||||
if (!page) return { ok: false as const, error: "Page not found" };
|
if (!page) return { ok: false as const, error: "Page not found" };
|
||||||
|
|
||||||
if (input.mode === "reparent") {
|
if (input.mode === "reparent") {
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.catalogPagesBc.updateMany({
|
await tx
|
||||||
where: { parentId: input.pageId },
|
.update(CatalogPagesBc)
|
||||||
data: { parentId: page.parentId },
|
.set({ parentId: page.parentId })
|
||||||
}),
|
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
||||||
prisma.catalogItemsBc.deleteMany({ where: { pageId: input.pageId } }),
|
await tx
|
||||||
prisma.catalogPagesBc.delete({ where: { id: input.pageId } }),
|
.delete(CatalogItemsBc)
|
||||||
]);
|
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
||||||
|
await tx
|
||||||
|
.delete(CatalogPagesBc)
|
||||||
|
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const toDelete: number[] = [input.pageId];
|
const toDelete: number[] = [input.pageId];
|
||||||
const queue: number[] = [input.pageId];
|
const queue: number[] = [input.pageId];
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const children = await prisma.catalogPagesBc.findMany({
|
const children = await db
|
||||||
where: { parentId: { in: queue } },
|
.select({ id: CatalogPagesBc.id })
|
||||||
select: { id: true },
|
.from(CatalogPagesBc)
|
||||||
});
|
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
toDelete.push(child.id);
|
toDelete.push(child.id);
|
||||||
queue.push(child.id);
|
queue.push(child.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.catalogItemsBc.deleteMany({
|
await tx
|
||||||
where: { pageId: { in: toDelete } },
|
.delete(CatalogItemsBc)
|
||||||
}),
|
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||||
prisma.catalogPagesBc.deleteMany({
|
await tx
|
||||||
where: { id: { in: toDelete } },
|
.delete(CatalogPagesBc)
|
||||||
}),
|
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { sql } from "drizzle-orm";
|
import { eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -87,7 +87,7 @@ async function insertCatalogItemRow(data: {
|
|||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const pageIdStr = String(data.pageId);
|
const pageIdStr = String(data.pageId);
|
||||||
return allocateCatalogItemId(async (nextId) => {
|
return allocateCatalogItemId(async (nextId) => {
|
||||||
await prisma.$executeRaw`
|
await db.execute(sql`
|
||||||
INSERT INTO catalog_items (
|
INSERT INTO catalog_items (
|
||||||
id, page_id, item_ids, catalog_name,
|
id, page_id, item_ids, catalog_name,
|
||||||
cost_credits, cost_points, points_type, amount,
|
cost_credits, cost_points, points_type, amount,
|
||||||
@@ -100,7 +100,7 @@ async function insertCatalogItemRow(data: {
|
|||||||
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
|
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
|
||||||
${data.haveOffer}, ${data.clubOnly}
|
${data.haveOffer}, ${data.clubOnly}
|
||||||
)
|
)
|
||||||
`;
|
`);
|
||||||
return nextId;
|
return nextId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -127,10 +127,14 @@ export async function createCatalogItem(data: {
|
|||||||
if (!catalogName) {
|
if (!catalogName) {
|
||||||
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||||
if (firstId > 0) {
|
if (firstId > 0) {
|
||||||
const base = await prisma.itemsBase.findUnique({
|
const [base] = await db
|
||||||
where: { id: firstId },
|
.select({
|
||||||
select: { publicName: true, itemName: true },
|
publicName: ItemsBase.publicName,
|
||||||
});
|
itemName: ItemsBase.itemName,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(eq(ItemsBase.id, firstId))
|
||||||
|
.limit(1);
|
||||||
catalogName = base?.publicName || base?.itemName || String(firstId);
|
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 baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||||
const bases = await prisma.itemsBase.findMany({
|
const bases = await db
|
||||||
where: { id: { in: baseIds } },
|
.select({
|
||||||
select: { id: true, publicName: true, itemName: true },
|
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]));
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
@@ -225,7 +233,7 @@ export async function bulkCreateCatalogItems({
|
|||||||
|
|
||||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
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 rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -249,11 +257,14 @@ export async function moveCatalogItems({
|
|||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
const pageIdStr = String(targetPageId);
|
const pageIdStr = String(targetPageId);
|
||||||
await prisma.$executeRaw`
|
await db.execute(sql`
|
||||||
UPDATE catalog_items
|
UPDATE catalog_items
|
||||||
SET page_id = ${pageIdStr}
|
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();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
@@ -266,7 +277,10 @@ export async function reorderCatalogItems({
|
|||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
for (const { id, orderNumber } of orders) {
|
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();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
@@ -292,25 +306,25 @@ export async function updateCatalogItem({
|
|||||||
const pageIdRaw = safeCatalog.pageId;
|
const pageIdRaw = safeCatalog.pageId;
|
||||||
if (pageIdRaw !== undefined) {
|
if (pageIdRaw !== undefined) {
|
||||||
const pageIdStr = String(pageIdRaw);
|
const pageIdStr = String(pageIdRaw);
|
||||||
await prisma.$executeRaw`
|
await db.execute(sql`
|
||||||
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||||
`;
|
`);
|
||||||
delete safeCatalog.pageId;
|
delete safeCatalog.pageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(safeCatalog).length > 0) {
|
if (Object.keys(safeCatalog).length > 0) {
|
||||||
await prisma.catalogItems.update({
|
await db
|
||||||
where: { id },
|
.update(CatalogItems)
|
||||||
data: safeCatalog as any,
|
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||||
});
|
.where(eq(CatalogItems.id, id));
|
||||||
}
|
}
|
||||||
if (baseItem) {
|
if (baseItem) {
|
||||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||||
if (Object.keys(safeBase).length > 0) {
|
if (Object.keys(safeBase).length > 0) {
|
||||||
await prisma.itemsBase.update({
|
await db
|
||||||
where: { id: baseItem.id },
|
.update(ItemsBase)
|
||||||
data: safeBase as any,
|
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||||
});
|
.where(eq(ItemsBase.id, baseItem.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
@@ -355,16 +369,17 @@ export async function translateCatalogItems(input: {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const base = await prisma.itemsBase.findUnique({
|
const [base] = await db
|
||||||
where: { id: item.id },
|
.select({
|
||||||
select: {
|
id: ItemsBase.id,
|
||||||
id: true,
|
publicName: ItemsBase.publicName,
|
||||||
publicName: true,
|
itemName: ItemsBase.itemName,
|
||||||
itemName: true,
|
type: ItemsBase.type,
|
||||||
type: true,
|
spriteId: ItemsBase.spriteId,
|
||||||
spriteId: true,
|
})
|
||||||
},
|
.from(ItemsBase)
|
||||||
});
|
.where(eq(ItemsBase.id, item.id))
|
||||||
|
.limit(1);
|
||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
|
|
||||||
const nextName = item.publicName?.trim() ?? "";
|
const nextName = item.publicName?.trim() ?? "";
|
||||||
@@ -372,28 +387,31 @@ export async function translateCatalogItems(input: {
|
|||||||
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
||||||
|
|
||||||
if (nameChanged) {
|
if (nameChanged) {
|
||||||
await prisma.itemsBase.update({
|
await db
|
||||||
where: { id: base.id },
|
.update(ItemsBase)
|
||||||
data: { publicName: nextName },
|
.set({ publicName: nextName })
|
||||||
});
|
.where(eq(ItemsBase.id, base.id));
|
||||||
const idStr = String(base.id);
|
const idStr = String(base.id);
|
||||||
const related = await prisma.catalogItems.findMany({
|
const related = await db
|
||||||
where: {
|
.select({
|
||||||
OR: [
|
id: CatalogItems.id,
|
||||||
{ itemIds: idStr },
|
catalogName: CatalogItems.catalogName,
|
||||||
{ itemIds: { startsWith: `${idStr};` } },
|
})
|
||||||
{ itemIds: { contains: `;${idStr};` } },
|
.from(CatalogItems)
|
||||||
{ itemIds: { endsWith: `;${idStr}` } },
|
.where(
|
||||||
],
|
or(
|
||||||
},
|
eq(CatalogItems.itemIds, idStr),
|
||||||
select: { id: true, catalogName: true },
|
like(CatalogItems.itemIds, `${idStr};%`),
|
||||||
});
|
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||||
|
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
for (const row of related) {
|
for (const row of related) {
|
||||||
if (row.catalogName !== nextName) {
|
if (row.catalogName !== nextName) {
|
||||||
await prisma.catalogItems.update({
|
await db
|
||||||
where: { id: row.id },
|
.update(CatalogItems)
|
||||||
data: { catalogName: nextName },
|
.set({ catalogName: nextName })
|
||||||
});
|
.where(eq(CatalogItems.id, row.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
namesUpdated++;
|
namesUpdated++;
|
||||||
|
|||||||
+41
-37
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
import { deletePage, movePage } from "@/lib/services/catalog-tree";
|
import { deletePage, movePage } from "@/lib/services/catalog-tree";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -54,10 +55,10 @@ export async function updateCatalogPage({
|
|||||||
if (typeof data.caption === "string" && !data.captionSave) {
|
if (typeof data.caption === "string" && !data.captionSave) {
|
||||||
data.captionSave = data.caption.slice(0, 25);
|
data.captionSave = data.caption.slice(0, 25);
|
||||||
}
|
}
|
||||||
await prisma.catalogPages.update({
|
await db
|
||||||
where: { id },
|
.update(CatalogPages)
|
||||||
data: data as any,
|
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||||
});
|
.where(eq(CatalogPages.id, id));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -93,17 +94,21 @@ export async function toggleCatalogPage({
|
|||||||
action: "toggleEnabled" | "toggleVisible";
|
action: "toggleEnabled" | "toggleVisible";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const page = await prisma.catalogPages.findUnique({
|
const [page] = await db
|
||||||
where: { id },
|
.select({
|
||||||
select: { enabled: true, visible: true },
|
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" };
|
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||||
await prisma.catalogPages.update({
|
await db
|
||||||
where: { id },
|
.update(CatalogPages)
|
||||||
data: { [field]: current === "1" ? "0" : "1" },
|
.set({ [field]: current === "1" ? "0" : "1" })
|
||||||
});
|
.where(eq(CatalogPages.id, id));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
@@ -121,35 +126,34 @@ export async function createCatalogPage(input: {
|
|||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
}): Promise<ActionResult<{ id: number }>> {
|
}): Promise<ActionResult<{ id: number }>> {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const created = await prisma.catalogPages.create({
|
const [result] = await db.insert(CatalogPages).values({
|
||||||
data: {
|
caption: input.caption,
|
||||||
caption: input.caption,
|
parentId: input.parentId,
|
||||||
parentId: input.parentId,
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
pageLayout: input.pageLayout ?? "default_3x3",
|
captionSave: input.caption.slice(0, 25),
|
||||||
captionSave: input.caption.slice(0, 25),
|
iconColor: input.iconColor ?? 0,
|
||||||
iconColor: input.iconColor ?? 0,
|
iconImage: input.iconImage ?? 0,
|
||||||
iconImage: input.iconImage ?? 0,
|
minRank: input.minRank ?? 1,
|
||||||
minRank: input.minRank ?? 1,
|
orderNum: input.orderNum ?? 0,
|
||||||
orderNum: input.orderNum ?? 0,
|
visible: input.visible ?? "1",
|
||||||
visible: input.visible ?? "1",
|
enabled: input.enabled ?? "1",
|
||||||
enabled: input.enabled ?? "1",
|
clubOnly: "0",
|
||||||
clubOnly: "0",
|
vipOnly: "0",
|
||||||
vipOnly: "0",
|
pageHeadline: "",
|
||||||
pageHeadline: "",
|
pageTeaser: "",
|
||||||
pageTeaser: "",
|
includes: "",
|
||||||
includes: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const createdId = Number(result.insertId);
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "catalog_page_create",
|
action: "catalog_page_create",
|
||||||
description: `Created catalog page "${input.caption}"`,
|
description: `Created catalog page "${input.caption}"`,
|
||||||
targetType: "catalog_page",
|
targetType: "catalog_page",
|
||||||
targetId: created.id,
|
targetId: createdId,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
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: {
|
export async function reorderTreePage(input: {
|
||||||
@@ -168,10 +172,10 @@ export async function reorderTreePage(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await prisma.catalogPages.update({
|
await db
|
||||||
where: { id: input.pageId },
|
.update(CatalogPages)
|
||||||
data: { orderNum: input.newOrderNum },
|
.set({ orderNum: input.newOrderNum })
|
||||||
});
|
.where(eq(CatalogPages.id, input.pageId));
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -199,15 +200,22 @@ export const setRank = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const staffRank = Number(ctx.session.user.rank);
|
const staffRank = Number(ctx.session.user.rank);
|
||||||
const isSuper = ctx.permissions.isSuperAdmin;
|
const isSuper = ctx.permissions.isSuperAdmin;
|
||||||
const target = await prisma.user.findUnique({
|
const [target] = await db
|
||||||
where: { id: ctx.data.userId },
|
.select({ rank: User.rank })
|
||||||
select: { rank: true },
|
.from(User)
|
||||||
});
|
.where(eq(User.id, ctx.data.userId))
|
||||||
|
.limit(1);
|
||||||
if (!target) throw new ActionError("User not found");
|
if (!target) throw new ActionError("User not found");
|
||||||
|
|
||||||
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
|
let rankExists: { id: number }[] = [];
|
||||||
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
|
try {
|
||||||
`.catch(() => [] as { id: number }[]);
|
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 (rankExists.length === 0) throw new ActionError("Rank does not exist");
|
||||||
|
|
||||||
if (!isSuper) {
|
if (!isSuper) {
|
||||||
@@ -222,10 +230,10 @@ export const setRank = adminAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||||
await prisma.user.update({
|
await db
|
||||||
where: { id: ctx.data.userId },
|
.update(User)
|
||||||
data: { rank: ctx.data.rank },
|
.set({ rank: ctx.data.rank })
|
||||||
});
|
.where(eq(User.id, ctx.data.userId));
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-25
@@ -1,9 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, max, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
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 { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
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) {
|
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
|
||||||
outcome = "ratelimit";
|
outcome = "ratelimit";
|
||||||
} else {
|
} else {
|
||||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
const [badge] = await db
|
||||||
where: { id: BigInt(rawId) },
|
.select({
|
||||||
select: { id: true, badgePath: true, published: true },
|
id: WebsiteDrawbadges.id,
|
||||||
});
|
badgePath: WebsiteDrawbadges.badgePath,
|
||||||
|
published: WebsiteDrawbadges.published,
|
||||||
|
})
|
||||||
|
.from(WebsiteDrawbadges)
|
||||||
|
.where(eq(WebsiteDrawbadges.id, BigInt(rawId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!badge?.published) {
|
if (!badge?.published) {
|
||||||
outcome = "invalid";
|
outcome = "invalid";
|
||||||
@@ -78,34 +84,43 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
const price = await resolvePrice();
|
const price = await resolvePrice();
|
||||||
|
|
||||||
// Re-read the buyer's live credit balance and verify it covers the cost.
|
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||||
const buyer = await prisma.user.findUnique({
|
const [buyer] = await db
|
||||||
where: { id: userId },
|
.select({ credits: User.credits })
|
||||||
select: { credits: true },
|
.from(User)
|
||||||
});
|
.where(eq(User.id, userId))
|
||||||
|
.limit(1);
|
||||||
if (!buyer || buyer.credits < price) {
|
if (!buyer || buyer.credits < price) {
|
||||||
outcome = "credits";
|
outcome = "credits";
|
||||||
} else {
|
} else {
|
||||||
// Atomically deduct credits and persist the badge so a failure
|
// Atomically deduct credits and persist the badge so a failure
|
||||||
// between the two operations cannot orphan the user.
|
// between the two operations cannot orphan the user.
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.user.update({
|
await tx
|
||||||
where: { id: userId },
|
.update(User)
|
||||||
data: { credits: { decrement: price } },
|
.set({ credits: sql`${User.credits} - ${price}` })
|
||||||
});
|
.where(eq(User.id, userId));
|
||||||
|
|
||||||
const existing = await tx.usersBadges.findFirst({
|
const [existing] = await tx
|
||||||
where: { userId, badgeCode: code },
|
.select({ id: UsersBadges.id })
|
||||||
select: { id: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(UsersBadges.userId, userId),
|
||||||
|
eq(UsersBadges.badgeCode, code),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const max = await tx.usersBadges.aggregate({
|
const [agg] = await tx
|
||||||
where: { userId },
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||||
_max: { slotId: true },
|
.from(UsersBadges)
|
||||||
});
|
.where(eq(UsersBadges.userId, userId));
|
||||||
const slotId = (max._max.slotId ?? 0) + 1;
|
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||||
await tx.usersBadges.create({
|
await tx.insert(UsersBadges).values({
|
||||||
data: { userId, slotId, badgeCode: code },
|
userId,
|
||||||
|
slotId,
|
||||||
|
badgeCode: code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
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", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
prisma: { emulatorSettings: { upsert: vi.fn() } },
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
},
|
||||||
|
EmulatorSettings: { key: "key", value: "value" },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
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/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
||||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||||
@@ -18,7 +29,6 @@ vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
|
|||||||
|
|
||||||
describe("saveEmulatorSettings", () => {
|
describe("saveEmulatorSettings", () => {
|
||||||
it("saves settings and calls rcon update", async () => {
|
it("saves settings and calls rcon update", async () => {
|
||||||
vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never);
|
|
||||||
const handler = (await import("./emulator").then(
|
const handler = (await import("./emulator").then(
|
||||||
(m) => m.saveEmulatorSettings,
|
(m) => m.saveEmulatorSettings,
|
||||||
)) as unknown as (ctx: {
|
)) as unknown as (ctx: {
|
||||||
@@ -31,7 +41,7 @@ describe("saveEmulatorSettings", () => {
|
|||||||
session: { user: { id: "1" } },
|
session: { user: { id: "1" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2);
|
expect(insertValues).toHaveBeenCalledTimes(2);
|
||||||
expect(rcon.updateConfig).toHaveBeenCalled();
|
expect(rcon.updateConfig).toHaveBeenCalled();
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { db, EmulatorSettings } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from "@/lib/safe-action-shared";
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
@@ -18,11 +18,10 @@ export const saveEmulatorSettings = adminAction(
|
|||||||
const entries = Object.entries(ctx.data.settings);
|
const entries = Object.entries(ctx.data.settings);
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
await prisma.emulatorSettings.upsert({
|
await db
|
||||||
where: { key },
|
.insert(EmulatorSettings)
|
||||||
update: { value: String(value) },
|
.values({ key, value: String(value) })
|
||||||
create: { key, value: String(value) },
|
.onDuplicateKeyUpdate({ set: { value: String(value) } });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await rcon.updateConfig();
|
await rcon.updateConfig();
|
||||||
|
|||||||
+103
-56
@@ -1,9 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, count, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
WebsiteEvent,
|
||||||
|
WebsiteEventPrize,
|
||||||
|
WebsiteEventRegistration,
|
||||||
|
WebsiteEventType,
|
||||||
|
WebsiteEventWinner,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
@@ -21,17 +29,16 @@ import {
|
|||||||
export const createEventType = adminAction(
|
export const createEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const eventType = await prisma.websiteEventType.create({
|
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
|
||||||
data: ctx.data,
|
const eventTypeId = Number(result.insertId);
|
||||||
});
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_type_create",
|
action: "event_type_create",
|
||||||
target: "WebsiteEventType",
|
target: "WebsiteEventType",
|
||||||
targetId: eventType.id,
|
targetId: eventTypeId,
|
||||||
after: { name: eventType.name },
|
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 },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const { id, ...data } = ctx.data;
|
||||||
const existing = await prisma.websiteEventType.findUnique({
|
const [existing] = await db
|
||||||
where: { id },
|
.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");
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_type_update",
|
action: "event_type_update",
|
||||||
@@ -68,12 +80,16 @@ const deleteEventTypeInput = z.object({
|
|||||||
export const deleteEventType = adminAction(
|
export const deleteEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const existing = await prisma.websiteEventType.findUnique({
|
const [existing] = await db
|
||||||
where: { id: ctx.data.id },
|
.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");
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_type_delete",
|
action: "event_type_delete",
|
||||||
@@ -90,20 +106,21 @@ export const deleteEventType = adminAction(
|
|||||||
export const createEvent = adminAction(
|
export const createEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const event = await prisma.websiteEvent.create({
|
const now = new Date();
|
||||||
data: {
|
const [result] = await db.insert(WebsiteEvent).values({
|
||||||
...ctx.data,
|
...ctx.data,
|
||||||
hostUserId: Number(ctx.session.user.id),
|
hostUserId: Number(ctx.session.user.id),
|
||||||
},
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
const eventId = Number(result.insertId);
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_create",
|
action: "event_create",
|
||||||
target: "WebsiteEvent",
|
target: "WebsiteEvent",
|
||||||
targetId: event.id,
|
targetId: eventId,
|
||||||
after: { title: event.title },
|
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 },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
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");
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_update",
|
action: "event_update",
|
||||||
@@ -138,12 +166,14 @@ const deleteEventInput = z.object({
|
|||||||
export const deleteEvent = adminAction(
|
export const deleteEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const existing = await prisma.websiteEvent.findUnique({
|
const [existing] = await db
|
||||||
where: { id: ctx.data.id },
|
.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");
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_delete",
|
action: "event_delete",
|
||||||
@@ -160,8 +190,8 @@ export const deleteEvent = adminAction(
|
|||||||
export const addEventPrize = adminAction(
|
export const addEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
|
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
|
||||||
return actionOk({ id: prize.id });
|
return actionOk({ id: Number(result.insertId) });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,7 +200,9 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteEventPrize = adminAction(
|
export const deleteEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
|
await db
|
||||||
|
.delete(WebsiteEventPrize)
|
||||||
|
.where(eq(WebsiteEventPrize.id, ctx.data.id));
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -180,19 +212,20 @@ export const deleteEventPrize = adminAction(
|
|||||||
export const addEventWinner = adminAction(
|
export const addEventWinner = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||||
async (ctx) => {
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "event_winner_add",
|
action: "event_winner_add",
|
||||||
target: "WebsiteEventWinner",
|
target: "WebsiteEventWinner",
|
||||||
targetId: winner.id,
|
targetId: winnerId,
|
||||||
after: {
|
after: {
|
||||||
eventId: ctx.data.eventId,
|
eventId: ctx.data.eventId,
|
||||||
userId: ctx.data.userId,
|
userId: ctx.data.userId,
|
||||||
position: ctx.data.position,
|
position: ctx.data.position,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return actionOk({ id: winner.id });
|
return actionOk({ id: winnerId });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -211,13 +244,18 @@ export const registerForEvent = authAction(
|
|||||||
return actionError("Unauthorized");
|
return actionError("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await prisma.websiteEvent.findUnique({
|
const [event] = await db
|
||||||
where: { id: ctx.data.eventId },
|
.select({
|
||||||
include: {
|
id: WebsiteEvent.id,
|
||||||
type: true,
|
status: WebsiteEvent.status,
|
||||||
_count: { select: { registrations: true } },
|
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) return actionError("Event not found");
|
||||||
if (event.status !== "published") {
|
if (event.status !== "published") {
|
||||||
@@ -226,28 +264,37 @@ export const registerForEvent = authAction(
|
|||||||
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
|
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
|
||||||
return actionError("This event has already ended");
|
return actionError("This event has already ended");
|
||||||
}
|
}
|
||||||
if (event.type.minRank > 0) {
|
if (event.minRank > 0) {
|
||||||
const rank = Number(ctx.session.user.rank ?? 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");
|
return actionError("Your rank is too low to join this event");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (event.maxPlayers != null) {
|
||||||
event.maxPlayers != null &&
|
const [regCount] = await db
|
||||||
event._count.registrations >= event.maxPlayers
|
.select({ value: count() })
|
||||||
) {
|
.from(WebsiteEventRegistration)
|
||||||
return actionError("This event is full");
|
.where(eq(WebsiteEventRegistration.eventId, event.id));
|
||||||
|
if ((regCount?.value ?? 0) >= event.maxPlayers) {
|
||||||
|
return actionError("This event is full");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.websiteEventRegistration.findUnique({
|
const [existing] = await db
|
||||||
where: {
|
.select({ id: WebsiteEventRegistration.id })
|
||||||
eventId_userId: { eventId: event.id, userId },
|
.from(WebsiteEventRegistration)
|
||||||
},
|
.where(
|
||||||
});
|
and(
|
||||||
|
eq(WebsiteEventRegistration.eventId, event.id),
|
||||||
|
eq(WebsiteEventRegistration.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
if (existing) return actionError("You are already registered");
|
if (existing) return actionError("You are already registered");
|
||||||
|
|
||||||
await prisma.websiteEventRegistration.create({
|
await db.insert(WebsiteEventRegistration).values({
|
||||||
data: { eventId: event.id, userId },
|
eventId: event.id,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/events");
|
revalidatePath("/events");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { db, WebsiteUserGuestbooks } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { isAllowed } from "@/lib/services/moderation";
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
@@ -78,14 +78,12 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
|||||||
outcome = "moderated";
|
outcome = "moderated";
|
||||||
} else {
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteUserGuestbooks.create({
|
await db.insert(WebsiteUserGuestbooks).values({
|
||||||
data: {
|
profileId,
|
||||||
profileId,
|
userId,
|
||||||
userId,
|
message,
|
||||||
message,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
outcome = "posted";
|
outcome = "posted";
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-67
@@ -1,11 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { auth } from "@/lib/auth";
|
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 { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
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)) {
|
if (!/ban\s*appeal/i.test(ticketTitle)) {
|
||||||
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
|
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
|
||||||
}
|
}
|
||||||
const existing = await prisma.websiteHelpCenterCategories.findFirst(
|
const [existing] = await db
|
||||||
{
|
.select({ id: WebsiteHelpCenterCategories.id })
|
||||||
where: { name: { equals: "Ban appeal" } },
|
.from(WebsiteHelpCenterCategories)
|
||||||
select: { id: true },
|
.where(eq(WebsiteHelpCenterCategories.name, "Ban appeal"))
|
||||||
},
|
.limit(1);
|
||||||
);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
categoryId = existing.id;
|
categoryId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const created = await prisma.websiteHelpCenterCategories.create(
|
const [created] = await db
|
||||||
{
|
.insert(WebsiteHelpCenterCategories)
|
||||||
data: {
|
.values({
|
||||||
name: "Ban appeal",
|
name: "Ban appeal",
|
||||||
content:
|
content:
|
||||||
"Appeals for account bans. Staff can lift bans from the ticket.",
|
"Appeals for account bans. Staff can lift bans from the ticket.",
|
||||||
position: 0,
|
position: 0,
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
categoryId = created.id;
|
|
||||||
} catch {
|
|
||||||
const again =
|
|
||||||
await prisma.websiteHelpCenterCategories.findFirst({
|
|
||||||
where: { name: { equals: "Ban appeal" } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
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;
|
categoryId = again?.id ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,16 +150,14 @@ export async function createTicket(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.create({
|
await db.insert(WebsiteHelpCenterTickets).values({
|
||||||
data: {
|
userId,
|
||||||
userId,
|
title: ticketTitle,
|
||||||
title: ticketTitle,
|
content,
|
||||||
content,
|
categoryId,
|
||||||
categoryId,
|
open: true,
|
||||||
open: true,
|
createdAt: now,
|
||||||
createdAt: now,
|
updatedAt: now,
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
outcome = "created";
|
outcome = "created";
|
||||||
}
|
}
|
||||||
@@ -204,10 +204,15 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
outcome = "invalid";
|
outcome = "invalid";
|
||||||
} else {
|
} else {
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, userId: true, open: true },
|
id: WebsiteHelpCenterTickets.id,
|
||||||
});
|
userId: WebsiteHelpCenterTickets.userId,
|
||||||
|
open: WebsiteHelpCenterTickets.open,
|
||||||
|
})
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!ticket || ticket.userId !== userId) {
|
if (!ticket || ticket.userId !== userId) {
|
||||||
outcome = "not_found";
|
outcome = "not_found";
|
||||||
@@ -223,30 +228,37 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!moderated) {
|
if (!moderated) {
|
||||||
const created = await prisma.$transaction((tx) =>
|
const created = await db.transaction(async (tx) =>
|
||||||
createOwnedTicketReply(
|
createOwnedTicketReply(
|
||||||
{
|
{
|
||||||
findTicket: (id) =>
|
findTicket: async (id) => {
|
||||||
tx.websiteHelpCenterTickets.findUnique({
|
const [row] = await tx
|
||||||
where: { id },
|
.select({
|
||||||
select: { id: true, userId: true, open: true },
|
id: WebsiteHelpCenterTickets.id,
|
||||||
}),
|
userId: WebsiteHelpCenterTickets.userId,
|
||||||
createReply: (data) =>
|
open: WebsiteHelpCenterTickets.open,
|
||||||
tx.websiteHelpCenterTicketReplies.create({
|
})
|
||||||
data,
|
.from(WebsiteHelpCenterTickets)
|
||||||
select: {
|
.where(eq(WebsiteHelpCenterTickets.id, id))
|
||||||
id: true,
|
.limit(1);
|
||||||
userId: true,
|
return row ?? null;
|
||||||
content: true,
|
},
|
||||||
createdAt: true,
|
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) =>
|
touchTicket: (id, updatedAt) =>
|
||||||
tx.websiteHelpCenterTickets.update({
|
tx
|
||||||
where: { id },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { updatedAt },
|
.set({ updatedAt })
|
||||||
select: { id: true },
|
.where(eq(WebsiteHelpCenterTickets.id, id)),
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -289,10 +301,15 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
|
|||||||
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
|
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
|
||||||
outcome = "ratelimit";
|
outcome = "ratelimit";
|
||||||
} else {
|
} else {
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const [ticket] = await db
|
||||||
where: { id: ticketId },
|
.select({
|
||||||
select: { id: true, userId: true, open: true },
|
id: WebsiteHelpCenterTickets.id,
|
||||||
});
|
userId: WebsiteHelpCenterTickets.userId,
|
||||||
|
open: WebsiteHelpCenterTickets.open,
|
||||||
|
})
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!ticket || ticket.userId !== userId) {
|
if (!ticket || ticket.userId !== userId) {
|
||||||
outcome = "not_found";
|
outcome = "not_found";
|
||||||
@@ -300,10 +317,10 @@ export async function closeHelpTicket(formData: FormData): Promise<void> {
|
|||||||
outcome = "closed_ticket";
|
outcome = "closed_ticket";
|
||||||
} else {
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.update({
|
await db
|
||||||
where: { id: ticketId },
|
.update(WebsiteHelpCenterTickets)
|
||||||
data: { open: false, updatedAt: now },
|
.set({ open: false, updatedAt: now })
|
||||||
});
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
outcome = "closed";
|
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