Compare commits
99
Commits
v1.0.2
..
89c3f58773
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89c3f58773 | ||
|
|
9854719cfd | ||
|
|
67656a9aad | ||
|
|
20b85381fe | ||
|
|
e5ff7ec9e5 | ||
|
|
7f7971f578 | ||
|
|
d1807ca814 | ||
|
|
ef5e706ee1 | ||
|
|
c0bbcae5d6 | ||
|
|
2f030deb42 | ||
|
|
9a8905c726 | ||
|
|
beae86194d | ||
|
|
56061e41d4 | ||
|
|
9d1c71d926 | ||
|
|
744e224fd0 | ||
|
|
a2acac2c4c | ||
|
|
0224b34f15 | ||
|
|
1127807aaa | ||
|
|
3ac5d6f4f6 | ||
|
|
3e584aadaf | ||
|
|
bfbc02c75d | ||
|
|
98613af875 | ||
|
|
7138ae4445 | ||
|
|
3ad3f9512c | ||
|
|
fe46bd0544 | ||
|
|
822dfd6a1c | ||
|
|
525f58cd24 | ||
|
|
d805e54053 | ||
|
|
583d05eee9 | ||
|
|
58fae1f90f | ||
|
|
474de0717b | ||
|
|
ed5b9a6f7c | ||
|
|
6eab5e5343 | ||
|
|
686279eb25 | ||
|
|
c0a2c9db5e | ||
|
|
d8bb117114 | ||
|
|
63ad651b9b | ||
|
|
b03dbb295f | ||
|
|
4b2d893905 | ||
|
|
e07da3d052 | ||
|
|
10932a8799 | ||
|
|
4ec75c2c1d | ||
|
|
1e3b7bc31d | ||
|
|
ea7d861e69 | ||
|
|
c433e5a52f | ||
|
|
540bce911f | ||
|
|
749dc237f1 | ||
|
|
8c193936f6 | ||
|
|
d3068ce88b | ||
|
|
340ecb42c8 | ||
|
|
39b211084d | ||
|
|
22162fa8b9 | ||
|
|
ae2b9328b9 | ||
|
|
84f64b6615 | ||
|
|
91357aca3b | ||
|
|
cc95a0d690 | ||
|
|
da390e447f | ||
|
|
4bd717d627 | ||
|
|
1311d36933 | ||
|
|
ded8fa62af | ||
|
|
3bf0644a9a | ||
|
|
d87c4284fe | ||
|
|
9cdb0b85fb | ||
|
|
7cdb785218 | ||
|
|
7f58e428ed | ||
|
|
a8d86a10bc | ||
|
|
b926ffa93c | ||
|
|
65fae257ba | ||
|
|
22a9fc13f7 | ||
|
|
3b853efba0 | ||
|
|
72079050f4 | ||
|
|
e08e366266 | ||
|
|
1e661a2b41 | ||
|
|
a637d09ca5 | ||
|
|
15eeab8a59 | ||
|
|
54fa1aecdd | ||
|
|
5140784dc7 | ||
|
|
41e41f0d22 | ||
|
|
46db76219f | ||
|
|
5b9e2166df | ||
|
|
738d7b8223 | ||
|
|
ab63de000b | ||
|
|
3532972357 | ||
|
|
e644362d09 | ||
|
|
b6b8625246 | ||
|
|
01126207dc | ||
|
|
9177230dc2 | ||
|
|
db39fb335c | ||
|
|
9ea67ecf72 | ||
|
|
15a76ffe84 | ||
|
|
9c0b339736 | ||
|
|
7384041bb6 | ||
|
|
a72646c933 | ||
|
|
a513d9b7bd | ||
|
|
3827f3e686 | ||
|
|
e408fdd5e6 | ||
|
|
78fab3c9ac | ||
|
|
e69c2fbb04 | ||
|
|
2e2aba11af |
No files matched your search
+45
-71
@@ -1,95 +1,69 @@
|
||||
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
||||
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
||||
# it does NOT own or migrate the emulator tables. Format:
|
||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||
# ==============================================================================
|
||||
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Optional pool tuning (defaults shown)
|
||||
DATABASE_POOL_SIZE=10
|
||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
REDIS_CACHE_TTL_DEFAULT=7200
|
||||
|
||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||
HOTEL_NAME=Atom
|
||||
APP_URL=http://localhost:3000
|
||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
AUTH_URL=http://localhost:3000
|
||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||
NODE_ENV=production
|
||||
PORT=3002
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
UV_THREADPOOL_SIZE=16
|
||||
|
||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
||||
AUTH_SECRET=
|
||||
APP_KEY=
|
||||
CONVERT_PASSWORDS=false
|
||||
# --- HOTEL & URLS ---
|
||||
HOTEL_NAME=EPIC WEB CONTROL
|
||||
APP_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||
AUTH_URL=http://localhost:3002
|
||||
|
||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
||||
# column). Existing accounts in either format still verify on login.
|
||||
PASSWORD_HASH=bcrypt
|
||||
# --- IMAGER ---
|
||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||
|
||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
||||
# Leave unset to disable badge uploads.
|
||||
BADGE_UPLOAD_DIR=
|
||||
# --- SECURITY & HASHING ---
|
||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
CONVERT_PASSWORDS=true
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
||||
EMULATOR_JAR_PATH=
|
||||
EMULATOR_BACKUP_DIR=
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||
EMULATOR_BACKUP_KEEP=7
|
||||
|
||||
# RCON link to the Arcturus emulator
|
||||
# --- RCON (Low Latency Loop) ---
|
||||
RCON_HOST=127.0.0.1
|
||||
RCON_PORT=3001
|
||||
RCON_PORT=3003
|
||||
EMU_PORT=3004
|
||||
RCON_TIMEOUT_MS=2000
|
||||
|
||||
# Public imager URL — overrides the default /imaging relative path.
|
||||
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
||||
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
||||
|
||||
# Preferred email provider (HTTP API). Used before SMTP when set.
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Optional SMTP fallback (password reset / alert emails)
|
||||
# --- EMAIL & NOTIFICATIONS ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
SMTP_FROM=[email protected]
|
||||
|
||||
# Optional alerting (jobs worker / alert service)
|
||||
# --- ALERTING & MONITORING ---
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
|
||||
# Optional AI content moderation (user comments / guestbook).
|
||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
||||
# --- MODERATION & PAYMENTS ---
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional PayPal top-up (sandbox by default)
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
|
||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
||||
# in-process memory: limits reset on restart and do not work across instances.
|
||||
# Deploy logs a loud warning when this is unset in production.
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
||||
# 'debug' in development. Production logs use structured JSON via pino.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional Sentry error monitoring (no-op when unset).
|
||||
# Server/edge use SENTRY_DSN; browser uses NEXT_PUBLIC_SENTRY_DSN.
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
# Optional source-map upload during CI builds (requires SENTRY_AUTH_TOKEN).
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Optional release tag shown in Sentry (e.g. git sha).
|
||||
# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha.
|
||||
APP_VERSION=
|
||||
NEXT_PUBLIC_APP_VERSION=
|
||||
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
|
||||
LOG_LEVEL=error
|
||||
SENTRY_DSN=https://[email protected]/0
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
|
||||
+504
-2
@@ -1,11 +1,18 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Typecheck, lint, and test
|
||||
@@ -30,12 +37,507 @@ jobs:
|
||||
git checkout -f "${REF}"
|
||||
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
export ARGON2_MEMORY_SIZE=1024
|
||||
export ARGON2_ITERATIONS=1
|
||||
export NODE_ENV=test
|
||||
export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4"
|
||||
export AUTH_SECRET="ci-test-secret-key-that-is-long-enough"
|
||||
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm install --frozen-lockfile
|
||||
# Generate Prisma type stubs for facade type-checking (dev only).
|
||||
pnpm prisma:generate
|
||||
# Run lint + typecheck + tests on the Drizzle-backed codebase.
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
echo "--- CI checks passed ---"
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
if: gitea.event_name == 'push' && gitea.ref_name == 'main'
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
|
||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||
|
||||
echo "--- Deploying ---"
|
||||
|
||||
LIVE="/var/www/atom-nexst"
|
||||
STAGE=""
|
||||
CUTOVER_STARTED=0
|
||||
|
||||
error_handler() {
|
||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||
# Roll back the build artifact if cutover already moved .next into place.
|
||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
||||
echo "Rolling back .next to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/.next" || true
|
||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
||||
fi
|
||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
fi
|
||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
trap 'error_handler $LINENO' ERR
|
||||
|
||||
docker image prune -f
|
||||
|
||||
DEPLOY_USER="$(id -un)"
|
||||
DEPLOY_GROUP="$(id -gn)"
|
||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||
git config --global --add safe.directory "${LIVE}"
|
||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||
|
||||
echo "Fetching origin/main..."
|
||||
git -C "${LIVE}" fetch origin --prune
|
||||
|
||||
echo "Clearing sticky git index bits (if any)..."
|
||||
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
||||
if [ -n "${STICKY_LIST}" ]; then
|
||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||
echo "APP_VERSION=${APP_VERSION}"
|
||||
|
||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
||||
|
||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||
|
||||
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||
fi
|
||||
|
||||
cd "${STAGE}"
|
||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||
rm -rf .output dist .next .next/types .next/dev
|
||||
|
||||
# Restore build cache from last deploy so Turbopack can do
|
||||
# incremental compilation (much faster rebuilds).
|
||||
if [ -d "${LIVE}/.next/cache" ]; then
|
||||
mkdir -p .next/cache
|
||||
cp -r "${LIVE}/.next/cache/." .next/cache/
|
||||
fi
|
||||
|
||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||
# tiny so install/test/build cannot exhaust max_connections.
|
||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# Generate Prisma type stubs (for facade type-checking) — no DB connection needed.
|
||||
# Drizzle schema (src/db/schema.ts) is committed and does not require generation.
|
||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
||||
pnpm prisma:generate
|
||||
unset DATABASE_URL
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||
pnpm build
|
||||
|
||||
if [ ! -d "${STAGE}/.next" ]; then
|
||||
echo "ERROR: stage build produced no .next/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
||||
CUTOVER_STARTED=1
|
||||
pm2 stop next --kill-timeout 10000 || true
|
||||
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
||||
sleep 10
|
||||
|
||||
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
||||
# the old process still holds DATABASE_POOL_SIZE connections.
|
||||
cd "${STAGE}"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${LIVE}"
|
||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||
git reset --hard origin/main
|
||||
# Keep env, uploads, and deps we are about to replace from stage.
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache -e node_modules -e .next -e .next.prev
|
||||
|
||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||
git diff --stat HEAD -- src >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified live src/ matches HEAD"
|
||||
|
||||
# Save current .next as backup before swapping (kept until health check passes).
|
||||
if [ -d .next ]; then
|
||||
mv .next .next.prev
|
||||
fi
|
||||
mv "${STAGE}/.next" .next
|
||||
|
||||
# Use the exact node_modules the stage build resolved against.
|
||||
rm -rf node_modules
|
||||
mv "${STAGE}/node_modules" node_modules
|
||||
|
||||
# Regenerate Prisma type stubs into live src/generated/ (gitignored build artifact).
|
||||
pnpm prisma:generate
|
||||
|
||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||
|
||||
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
|
||||
# Export PORT before start so the process matches health checks.
|
||||
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
|
||||
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
|
||||
export PORT="${DEPLOY_PORT}"
|
||||
echo "PM2/health PORT=${PORT}"
|
||||
|
||||
free_tcp_port() {
|
||||
local port="$1"
|
||||
[ -n "${port}" ] || return 0
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "${port}/tcp" 2>/dev/null || true
|
||||
elif command -v lsof >/dev/null 2>&1; then
|
||||
# Portable fallback when fuser is unavailable.
|
||||
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Starting PM2 with a clean PORT=${PORT} listener..."
|
||||
cd "${LIVE}"
|
||||
# Cutover already stopped the app, but failed deploys can leave orphans
|
||||
# on 3002 (or an old PM2 env still bound to 3000).
|
||||
pm2 stop next --kill-timeout 10000 2>/dev/null || true
|
||||
free_tcp_port "${PORT}"
|
||||
free_tcp_port 3000
|
||||
sleep 1
|
||||
pm2 delete next 2>/dev/null || true
|
||||
PORT="${PORT}" pm2 start pnpm --name next -- start
|
||||
pm2 save 2>/dev/null || true
|
||||
|
||||
sleep 3
|
||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||
echo "ERROR: PM2 next failed to start!" >&2
|
||||
pm2 logs next --lines 20 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check on port ${PORT}..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 20); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
echo "Listeners on PORT ${PORT}:" >&2
|
||||
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
|
||||
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning stage worktree and previous .next backup..."
|
||||
rm -rf "${LIVE}/.next.prev"
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
STAGE=""
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
|
||||
release:
|
||||
if: startsWith(gitea.ref_name, 'v')
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Build and deploy
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
WORK="$(mktemp -d /var/tmp/epicnext-deploy.XXXXXX)"
|
||||
cleanup() { rm -rf "${WORK}"; }
|
||||
trap cleanup EXIT
|
||||
echo "=== Deploying ${VERSION} ==="
|
||||
git clone --depth 50 \
|
||||
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
|
||||
"${WORK}"
|
||||
cd "${WORK}"
|
||||
git checkout "${VERSION}"
|
||||
export NODE_ENV=production
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
pnpm install --frozen-lockfile
|
||||
# prisma generate only needs a resolvable URL — no live DB connection.
|
||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
||||
pnpm prisma:generate
|
||||
unset DATABASE_URL
|
||||
# Tag releases must apply CMS SQL migrations against the live DB
|
||||
# (same path as push-to-main deploy), using the production .env.
|
||||
LIVE="/var/www/atom-nexst"
|
||||
ln -sfn "${LIVE}/.env" "${WORK}/.env"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
pnpm build
|
||||
pm2 restart next --update-env
|
||||
pm2 save
|
||||
echo "=== Deploy complete ==="
|
||||
- name: Create Release
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
GITEA_API: ${{ gitea.api_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
||||
echo "=== Creating release for ${VERSION} ==="
|
||||
|
||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
||||
|
||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
||||
else
|
||||
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
||||
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
||||
fi
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
||||
|
||||
# Pin the other components at their current commits so the release is reproducible.
|
||||
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
||||
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
{
|
||||
echo "# EpicNext-CMS ${VERSION}"
|
||||
echo ""
|
||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Drizzle ORM. Integrates with Polaris / Arcturus Morningstar databases."
|
||||
echo ""
|
||||
echo "## Menu"
|
||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||
echo "- [System Requirements](#system-requirements)"
|
||||
echo "- [Installation Wizard](#installation-wizard)"
|
||||
echo "- [How it is used](#how-it-is-used)"
|
||||
echo "- [Changes](#changes)"
|
||||
echo "- [Linked repositories](#linked-repositories)"
|
||||
echo ""
|
||||
echo '<a id="what-is-epicnext-cms"></a>'
|
||||
echo "## What is EpicNext-CMS?"
|
||||
echo ""
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo ""
|
||||
echo '<a id="system-requirements"></a>'
|
||||
echo "## System Requirements"
|
||||
echo ""
|
||||
echo "What you need to install before running the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Version | Notes |"
|
||||
echo "| --------- | ------- | ----- |"
|
||||
echo "| Node.js | >= 22 | Required by Next.js 16 |"
|
||||
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
||||
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
||||
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
||||
echo "| Java | 17+ | Only if building the emulator |"
|
||||
echo "| Maven | 3.9+ | Only if building the emulator |"
|
||||
echo ""
|
||||
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
||||
echo ""
|
||||
echo '<a id="installation-wizard"></a>'
|
||||
echo "## Installation Wizard"
|
||||
echo ""
|
||||
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
||||
echo ""
|
||||
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
||||
echo ""
|
||||
echo "### 1. Clone & Install the CMS"
|
||||
echo '```bash'
|
||||
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
||||
echo "cd EpicNext-Cms"
|
||||
echo "pnpm install"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 2. Database Setup"
|
||||
echo ""
|
||||
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
||||
echo '```sql'
|
||||
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 3. Configure Environment"
|
||||
echo '```bash'
|
||||
echo "cp .env.example .env"
|
||||
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 ""
|
||||
echo "### 4. Generate Prisma Type Stubs (Dev Only)"
|
||||
echo '```bash'
|
||||
echo "pnpm prisma:generate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Generates TypeScript types in src/generated/prisma/ for the Prisma compatibility facade (types only — no runtime Prisma engine in production). New code should use Drizzle ORM directly via '@/lib/db'."
|
||||
echo ""
|
||||
echo "### 5. Run CMS Migrations"
|
||||
echo '```bash'
|
||||
echo "pnpm db:migrate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
|
||||
echo ""
|
||||
echo "### 6. Polaris Emulator"
|
||||
echo ""
|
||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
||||
echo "cd /var/www/emulator/Emulator"
|
||||
echo "mvn clean package"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
||||
echo '```bash'
|
||||
echo "./update-Nitrov3.sh"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 7. Nitro V3 & Renderer"
|
||||
echo ""
|
||||
echo "Clone both Nitro repos and build the client:"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
||||
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
||||
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
||||
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
||||
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 ""
|
||||
echo "### 8. Catalogus (catalog & gamedata)"
|
||||
echo ""
|
||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||
echo '```bash'
|
||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 9. Build & Start the CMS"
|
||||
echo '```bash'
|
||||
echo "# Development (hot reload)"
|
||||
echo "pnpm dev"
|
||||
echo ""
|
||||
echo "# Production"
|
||||
echo "pnpm build && pnpm start"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Open http://localhost:3000 in your browser."
|
||||
echo ""
|
||||
echo "### 10. First Login"
|
||||
echo ""
|
||||
echo "1. Register at /register, or log in with an existing emulator account."
|
||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
||||
echo ""
|
||||
echo '<a id="how-it-is-used"></a>'
|
||||
echo "## How it is used"
|
||||
echo ""
|
||||
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
||||
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
||||
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
||||
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
||||
echo ""
|
||||
echo '<a id="changes"></a>'
|
||||
echo "## Changes"
|
||||
echo '```'
|
||||
echo "${CHANGELOG}"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo '<a id="linked-repositories"></a>'
|
||||
echo "## Linked repositories (exact commits)"
|
||||
echo ""
|
||||
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Repository | Commit |"
|
||||
echo "|-----------|------------|--------|"
|
||||
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
||||
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
||||
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
||||
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
||||
echo ""
|
||||
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Automated release from Gitea Actions*"
|
||||
} > /tmp/release-body.md
|
||||
|
||||
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
||||
|
||||
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
|
||||
if [ "${HTTP_CODE}" = "409" ]; then
|
||||
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}")"
|
||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
fi
|
||||
|
||||
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
||||
echo "SUCCESS: Release ${VERSION} created/updated"
|
||||
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
||||
else
|
||||
echo "FAILED HTTP ${HTTP_CODE}"
|
||||
cat /tmp/release-resp.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,417 +0,0 @@
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
jobs:
|
||||
release:
|
||||
if: startsWith(gitea.ref_name, 'v')
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Create Release
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
GITEA_API: ${{ gitea.api_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
||||
echo "=== Creating release for ${VERSION} ==="
|
||||
|
||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
||||
|
||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
||||
else
|
||||
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
||||
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
||||
fi
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
||||
|
||||
# Pin the other components at their current commits so the release is reproducible.
|
||||
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
||||
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
{
|
||||
echo "# EpicNext-CMS ${VERSION}"
|
||||
echo ""
|
||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Prisma 7. Integrates with Polaris / Arcturus Morningstar databases."
|
||||
echo ""
|
||||
echo "## Menu"
|
||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||
echo "- [System Requirements](#system-requirements)"
|
||||
echo "- [Installation Wizard](#installation-wizard)"
|
||||
echo "- [How it is used](#how-it-is-used)"
|
||||
echo "- [Changes](#changes)"
|
||||
echo "- [Linked repositories](#linked-repositories)"
|
||||
echo ""
|
||||
echo '<a id="what-is-epicnext-cms"></a>'
|
||||
echo "## What is EpicNext-CMS?"
|
||||
echo ""
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo ""
|
||||
echo '<a id="system-requirements"></a>'
|
||||
echo "## System Requirements"
|
||||
echo ""
|
||||
echo "What you need to install before running the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Version | Notes |"
|
||||
echo "| --------- | ------- | ----- |"
|
||||
echo "| Node.js | >= 22 | Required by Next.js 16 |"
|
||||
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
||||
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
||||
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
||||
echo "| Java | 17+ | Only if building the emulator |"
|
||||
echo "| Maven | 3.9+ | Only if building the emulator |"
|
||||
echo ""
|
||||
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
||||
echo ""
|
||||
echo '<a id="installation-wizard"></a>'
|
||||
echo "## Installation Wizard"
|
||||
echo ""
|
||||
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
||||
echo ""
|
||||
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
||||
echo ""
|
||||
echo "### 1. Clone & Install the CMS"
|
||||
echo '```bash'
|
||||
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
||||
echo "cd EpicNext-Cms"
|
||||
echo "pnpm install"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 2. Database Setup"
|
||||
echo ""
|
||||
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
||||
echo '```sql'
|
||||
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 3. Configure Environment"
|
||||
echo '```bash'
|
||||
echo "cp .env.example .env"
|
||||
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 ""
|
||||
echo "### 4. Generate Prisma Client"
|
||||
echo '```bash'
|
||||
echo "pnpm prisma:generate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 5. Run CMS Migrations"
|
||||
echo '```bash'
|
||||
echo "pnpm db:migrate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
|
||||
echo ""
|
||||
echo "### 6. Polaris Emulator"
|
||||
echo ""
|
||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
||||
echo "cd /var/www/emulator/Emulator"
|
||||
echo "mvn clean package"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
||||
echo '```bash'
|
||||
echo "./update-Nitrov3.sh"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 7. Nitro V3 & Renderer"
|
||||
echo ""
|
||||
echo "Clone both Nitro repos and build the client:"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
||||
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
||||
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
||||
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
||||
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 ""
|
||||
echo "### 8. Catalogus (catalog & gamedata)"
|
||||
echo ""
|
||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||
echo '```bash'
|
||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 9. Build & Start the CMS"
|
||||
echo '```bash'
|
||||
echo "# Development (hot reload)"
|
||||
echo "pnpm dev"
|
||||
echo ""
|
||||
echo "# Production"
|
||||
echo "pnpm build && pnpm start"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Open http://localhost:3000 in your browser."
|
||||
echo ""
|
||||
echo "### 10. First Login"
|
||||
echo ""
|
||||
echo "1. Register at /register, or log in with an existing emulator account."
|
||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
||||
echo ""
|
||||
echo '<a id="how-it-is-used"></a>'
|
||||
echo "## How it is used"
|
||||
echo ""
|
||||
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
||||
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
||||
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
||||
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
||||
echo ""
|
||||
echo '<a id="changes"></a>'
|
||||
echo "## Changes"
|
||||
echo '```'
|
||||
echo "${CHANGELOG}"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo '<a id="linked-repositories"></a>'
|
||||
echo "## Linked repositories (exact commits)"
|
||||
echo ""
|
||||
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Repository | Commit |"
|
||||
echo "|-----------|------------|--------|"
|
||||
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
||||
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
||||
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
||||
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
||||
echo ""
|
||||
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Automated release from Gitea Actions*"
|
||||
} > /tmp/release-body.md
|
||||
|
||||
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
||||
|
||||
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
|
||||
if [ "${HTTP_CODE}" = "409" ]; then
|
||||
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}")"
|
||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
fi
|
||||
|
||||
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
||||
echo "SUCCESS: Release ${VERSION} created/updated"
|
||||
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
||||
else
|
||||
echo "FAILED HTTP ${HTTP_CODE}"
|
||||
cat /tmp/release-resp.json
|
||||
exit 1
|
||||
fi
|
||||
deploy:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
|
||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||
|
||||
echo "--- Deploying ---"
|
||||
|
||||
LIVE="/var/www/atom-nexst"
|
||||
STAGE=""
|
||||
CUTOVER_STARTED=0
|
||||
|
||||
error_handler() {
|
||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||
# Roll back the build artifact if cutover already moved .next into place.
|
||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
||||
echo "Rolling back .next to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/.next" || true
|
||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
||||
fi
|
||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
fi
|
||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
trap 'error_handler $LINENO' ERR
|
||||
|
||||
docker image prune -f
|
||||
|
||||
DEPLOY_USER="$(id -un)"
|
||||
DEPLOY_GROUP="$(id -gn)"
|
||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||
git config --global --add safe.directory "${LIVE}"
|
||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||
|
||||
echo "Fetching origin/main..."
|
||||
git -C "${LIVE}" fetch origin --prune
|
||||
|
||||
echo "Clearing sticky git index bits (if any)..."
|
||||
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
||||
if [ -n "${STICKY_LIST}" ]; then
|
||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||
echo "APP_VERSION=${APP_VERSION}"
|
||||
|
||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
||||
|
||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||
|
||||
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||
fi
|
||||
|
||||
cd "${STAGE}"
|
||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||
rm -rf .output dist .next .next/types .next/dev
|
||||
|
||||
# Restore build cache from last deploy so Turbopack can do
|
||||
# incremental compilation (much faster rebuilds).
|
||||
if [ -d "${LIVE}/.next/cache" ]; then
|
||||
mkdir -p .next/cache
|
||||
cp -r "${LIVE}/.next/cache/." .next/cache/
|
||||
fi
|
||||
|
||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||
# tiny so install/test/build cannot exhaust max_connections.
|
||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# prisma generate does not need a live DB connection.
|
||||
pnpm prisma:generate
|
||||
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||
pnpm build
|
||||
|
||||
if [ ! -d "${STAGE}/.next" ]; then
|
||||
echo "ERROR: stage build produced no .next/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
||||
CUTOVER_STARTED=1
|
||||
pm2 stop next --kill-timeout 10000 || true
|
||||
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
||||
sleep 10
|
||||
|
||||
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
||||
# the old process still holds DATABASE_POOL_SIZE connections.
|
||||
cd "${STAGE}"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${LIVE}"
|
||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||
git reset --hard origin/main
|
||||
# Keep env, uploads, and deps we are about to replace from stage.
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache -e node_modules -e .next -e .next.prev
|
||||
|
||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||
git diff --stat HEAD -- src >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified live src/ matches HEAD"
|
||||
|
||||
# Save current .next as backup before swapping (kept until health check passes).
|
||||
if [ -d .next ]; then
|
||||
mv .next .next.prev
|
||||
fi
|
||||
mv "${STAGE}/.next" .next
|
||||
|
||||
# Use the exact node_modules the stage build resolved against.
|
||||
rm -rf node_modules
|
||||
mv "${STAGE}/node_modules" node_modules
|
||||
|
||||
# Prisma client is gitignored — regenerate into live src/generated.
|
||||
pnpm prisma:generate
|
||||
|
||||
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
|
||||
|
||||
echo "Starting PM2 (zero-downtime reload)..."
|
||||
pm2 reload next --update-env || pm2 start next --update-env
|
||||
|
||||
sleep 3
|
||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||
echo "ERROR: PM2 next failed to start!" >&2
|
||||
pm2 logs next --lines 20 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 15); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/15 failed, retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning stage worktree and previous .next backup..."
|
||||
rm -rf "${LIVE}/.next.prev"
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
STAGE=""
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
@@ -11,9 +11,8 @@ jobs:
|
||||
- name: Self-hosted Renovate
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
||||
|
||||
# Ensure cache dir exists before Docker starts
|
||||
mkdir -p /var/tmp/renovate-cache
|
||||
|
||||
docker run --rm \
|
||||
@@ -25,4 +24,4 @@ jobs:
|
||||
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||
-e LOG_LEVEL=info \
|
||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
@@ -1,8 +1,8 @@
|
||||
# EpicNext-CMS v1.0.1
|
||||
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +37,9 @@ The CMS shares a database with the Polaris/Arcturus emulator. Use an existing da
|
||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them.
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
||||
|
||||
> **Note:** The CMS does **not** own the database schema — it maps to tables that are managed by the emulator. All Drizzle schema definitions use `drizzle-orm`'s runtime mapping (no `drizzle-kit push/migrate` is ever run against the emulator schema). CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL files in `prisma/migrations/`.
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
@@ -56,13 +58,45 @@ APP_URL=http://localhost:3000
|
||||
|
||||
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. Generate Prisma Client
|
||||
### 4. ORM Setup & Type Generation
|
||||
|
||||
#### Drizzle ORM (Primary Data Layer)
|
||||
|
||||
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
|
||||
|
||||
```ts
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm/expressions";
|
||||
|
||||
const found = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "hello"));
|
||||
```
|
||||
|
||||
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
|
||||
|
||||
| Command | What it does |
|
||||
| ------- | ------------ |
|
||||
| `npx drizzle-kit generate --dialect mysql --schema src/db/schema.ts --out src/db/migrations` | Inspect the Drizzle schema and emit migration SQL |
|
||||
| `npx drizzle-kit studio` | Open a local DB browser (dev only) |
|
||||
| `npx drizzle-kit introspect` | Reverse-engineer an existing DB into a Drizzle schema |
|
||||
|
||||
> 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).
|
||||
|
||||
#### Prisma Compatibility Facade (Backwards Compatibility)
|
||||
|
||||
A Prisma-compatible facade at `@/lib/prisma` allows existing code to keep calling `prisma.users.findMany()` without refactoring. At runtime, the facade routes every query through Drizzle. **There is zero Prisma client or query-engine overhead in production.**
|
||||
|
||||
Generate the Prisma type stubs used for type-checking the facade:
|
||||
|
||||
```bash
|
||||
pnpm prisma:generate
|
||||
```
|
||||
|
||||
Generates TypeScript types in `src/generated/prisma/`.
|
||||
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
|
||||
|
||||
@@ -130,10 +164,19 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||
| `pnpm db:migrate:status` | Show migration status |
|
||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from Prisma + live DB (needs `DATABASE_URL`) |
|
||||
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
**Drizzle CLI (dev only, run with `npx`):**
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `drizzle-kit generate` | Generate migration SQL from Drizzle schema |
|
||||
| `drizzle-kit studio` | Local Drizzle Studio database browser |
|
||||
| `drizzle-kit introspect` | Reverse-engineer DB → Drizzle schema |
|
||||
|
||||
---
|
||||
|
||||
## Performance Features
|
||||
@@ -161,20 +204,26 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
|
||||
```
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only (legacy)
|
||||
│ └── migrations/ # 19 SQL migrations for CMS tables (idempotent, never re-run)
|
||||
├── scripts/
|
||||
│ ├── apply-migrations.ts # Custom migration runner
|
||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||
│ ├── jobs-worker.ts # Background task scheduler
|
||||
│ └── sql-statements.ts # SQL parsing utilities
|
||||
│ ├── merge-config.cjs # Utility: merge split config files
|
||||
│ └── generate-drizzle-schema.mjs # One-off: generate src/db/schema.ts from schema.prisma
|
||||
├── src/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # Drizzle ORM schema (176 tables — runtime data layer)
|
||||
│ │ └── migrations/ # Drizzle migration files (local dev only)
|
||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── components/ # UI components
|
||||
│ ├── lib/
|
||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||
│ │ ├── prisma.ts # Database connection singleton
|
||||
│ │ ├── prisma.ts # Prisma-compatible facade (routes to Drizzle at runtime)
|
||||
│ │ ├── prisma-facade.ts # Drizzle-backed implementation of Prisma API surface
|
||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||
│ │ ├── redis.ts # Redis client (ioredis)
|
||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||
│ │ ├── cache.ts # In-memory cache fallback
|
||||
@@ -195,9 +244,19 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| Component | Type | Migrations |
|
||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (19 files) |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||
|
||||
### ORM Architecture
|
||||
|
||||
The CMS uses a dual-layer approach:
|
||||
|
||||
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton connected to the MySQL/MariaDB database. All new code should use this directly. The schema is defined in `src/db/schema.ts` with 176 tables typed against the existing database columns.
|
||||
- **Legacy Compatibility (Prisma Facade)**: `@/lib/prisma` provides a Prisma-compatible API surface backed by Drizzle. This allows existing code to continue working without refactoring. The facade (`@/lib/prisma-facade.ts`) implements the Prisma client API (`findMany`, `findUnique`, `create`, `$transaction`, `$queryRaw`, etc.) but routes all queries through Drizzle at runtime — **no Prisma client engine or query engine is loaded in production**.
|
||||
- **Type Generation**: `src/generated/prisma/` (regenerated via `pnpm prisma:generate`) exists solely for TypeScript type-checking. It is `gitignore`d and is never bundled in the production build.
|
||||
|
||||
Migration path: new database access should use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`. The facade is maintained for backwards compatibility but is not recommended for new code.
|
||||
|
||||
---
|
||||
|
||||
## Optional Integrations
|
||||
@@ -261,6 +320,8 @@ pnpm biome:check # Lint and format
|
||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
||||
4. SQL migrations in `prisma/migrations/` must be idempotent
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// The schema is generated by scripts/generate-drizzle-schema.mjs from
|
||||
// prisma/schema.prisma + live DB introspection. This DB is shared live with the
|
||||
// Arcturus emulator, so we NEVER run `drizzle-kit migrate`/`push` against it —
|
||||
// CMS-only schema changes stay in prisma/migrations/*.sql via `pnpm db:migrate`.
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Admin panel", () => {
|
||||
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
|
||||
test("admin page has login form", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication flows", () => {
|
||||
test("login form validates required fields", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("login with invalid credentials shows error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="username"]', "nonexistent");
|
||||
await page.fill('input[name="password"]', "wrongpassword");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("register page has password confirmation field", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("register form validates password match", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.fill('input[name="password"]', "Password123!");
|
||||
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("homepage has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/Magic Hotel|Atom/i);
|
||||
});
|
||||
|
||||
test("register page loads", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page).toHaveTitle(/registreer|register|konto/i);
|
||||
});
|
||||
|
||||
test("login page loads", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveTitle(/inloggen|login/i);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Public navigation", () => {
|
||||
test("homepage loads with expected elements", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("nav")).toBeVisible();
|
||||
await expect(page.locator("footer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("community page loads", async ({ page }) => {
|
||||
await page.goto("/community");
|
||||
await expect(page).toHaveTitle(/community/i);
|
||||
});
|
||||
|
||||
test("shop page loads", async ({ page }) => {
|
||||
await page.goto("/shop");
|
||||
await expect(page.locator("h1, h2").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("404 page for unknown routes", async ({ page }) => {
|
||||
const response = await page.goto("/this-page-does-not-exist");
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "next",
|
||||
script: ".next/standalone/server.js",
|
||||
exec_mode: "cluster",
|
||||
instances: 0,
|
||||
max_memory_restart: "512M",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 3002,
|
||||
RCON_PORT: 3003,
|
||||
NEXT_PHASE: "phase-production-server",
|
||||
},
|
||||
merge_logs: true,
|
||||
error_file: ".pm2/errors.log",
|
||||
out_file: ".pm2/out.log",
|
||||
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
url: ["http://localhost:3000"],
|
||||
numberOfRuns: 3,
|
||||
},
|
||||
assert: {
|
||||
preset: "lighthouse:no-pwa",
|
||||
assertions: {
|
||||
"categories:performance": ["error", { minScore: 0.9 }],
|
||||
"categories:accessibility": ["error", { minScore: 0.9 }],
|
||||
"categories:best-practices": ["error", { minScore: 0.9 }],
|
||||
"categories:seo": ["error", { minScore: 0.8 }],
|
||||
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
|
||||
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
|
||||
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+10
-11
@@ -21,25 +21,24 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {},
|
||||
serverExternalPackages: [
|
||||
"@prisma/adapter-mariadb",
|
||||
"mariadb",
|
||||
"@prisma/client",
|
||||
"lzma",
|
||||
"sharp",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
],
|
||||
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
// Enable React Compiler for automatic memoization
|
||||
reactCompiler: true,
|
||||
|
||||
// Compress responses with gzip
|
||||
// Compress responses with gzip/brotli
|
||||
compress: true,
|
||||
|
||||
// Disable Next.js telemetry
|
||||
// Disable Next.js telemetry and browser sourcemaps in production
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Standalone output for smaller, faster Docker deployments and cold starts
|
||||
output: "standalone",
|
||||
|
||||
experimental: {
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
|
||||
// Add caching headers for static assets
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
+21
-40
@@ -11,28 +11,22 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"prisma:generate": "prisma generate",
|
||||
"typecheck": "tsc6 --noEmit --incremental false",
|
||||
"typecheck": "tsc --noEmit --incremental",
|
||||
"biome:check": "biome check --write .",
|
||||
"biome:lint": "biome lint .",
|
||||
"biome:format": "biome format --write .",
|
||||
"knip": "knip",
|
||||
"analyze": "ANALYZE=true pnpm build",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
||||
"lhci:collect": "lhci collect",
|
||||
"lhci:assert": "lhci assert",
|
||||
"lhci:server": "lhci server",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write",
|
||||
"eslint --fix --max-warnings=50"
|
||||
"biome check --write"
|
||||
],
|
||||
"*.{json,md,css,scss,html}": [
|
||||
"biome format --write"
|
||||
@@ -43,39 +37,35 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@sentry/nextjs": "^10.68.0",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"@hookform/resolvers": "^5.5.8",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"croner": "^10.0.1",
|
||||
"framer-motion": "^12.42.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"isomorphic-dompurify": "^3.21.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lenis": "^1.3.25",
|
||||
"lucide-react": "^1.27.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"lzma": "^2.3.2",
|
||||
"motion": "^12.43.0",
|
||||
"music-metadata": "^11.14.0",
|
||||
"mysql2": "^3.23.2",
|
||||
"next": "^16.2.12",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "^4.13.4",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"otplib": "^13.4.1",
|
||||
"pino": "^10.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"resend": "^6.18.1",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -85,37 +75,28 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/eslint-plugin-next": "^16.2.12",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-security": "^4.0.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^6.29.0",
|
||||
"knip": "^6.31.0",
|
||||
"lint-staged": "^17.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.24",
|
||||
"postcss": "^8.5.25",
|
||||
"prisma": "^7.9.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "8.2.0",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Generated
+1668
-3521
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -20,7 +20,7 @@ overrides:
|
||||
uuid: "^9.0.1"
|
||||
fast-uri: "^3.1.3"
|
||||
"@hono/node-server": "^1.19.13"
|
||||
postcss: "^8.5.19"
|
||||
postcss: "^8.5.25"
|
||||
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
||||
tmp: "^0.2.6"
|
||||
# NIEUWE SECURITY PATCHES:
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/db/schema.ts from:
|
||||
// 1. prisma/schema.prisma -> TS field names (camelCase) + @map column names + table names
|
||||
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Usage: pnpm db:schema:generate
|
||||
import "dotenv/config";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MYSQL2_UNSUPPORTED_OPTIONS = [
|
||||
"connection_limit",
|
||||
"pool_timeout",
|
||||
"connect_timeout",
|
||||
];
|
||||
|
||||
function mysqlConnectionUrl(value) {
|
||||
const url = new URL(value);
|
||||
for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
|
||||
url.searchParams.delete(option);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SCHEMA_PRISMA = resolve(ROOT, "prisma/schema.prisma");
|
||||
const OUT = resolve(ROOT, "src/db/schema.ts");
|
||||
|
||||
const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8");
|
||||
|
||||
// ---------- Parse prisma/schema.prisma ----------
|
||||
const modelBlocks = [
|
||||
...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm),
|
||||
];
|
||||
const modelNames = new Set(modelBlocks.map((m) => m[1]));
|
||||
|
||||
/** parse a model block -> { name, table, fields, ids, uniques, maps } */
|
||||
function parseModel(block) {
|
||||
const [_full, name, body] = block;
|
||||
const table =
|
||||
body.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? name.toLowerCase();
|
||||
|
||||
const fields = [];
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("@@") ||
|
||||
trimmed.startsWith("///")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const m = trimmed.match(/^(\w+)\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
const [fieldName, rest] = [m[1], m[2]];
|
||||
const typeMatch = rest.match(/^([^\s]+)/);
|
||||
const prismaType = typeMatch[1];
|
||||
const baseType = prismaType.replace(/\?$/, "").replace(/\[\]$/, "");
|
||||
const isList = prismaType.endsWith("[]");
|
||||
// relation field (points at another model) -> skip
|
||||
if (modelNames.has(baseType)) continue;
|
||||
|
||||
const attrs = rest;
|
||||
const optional = prismaType.endsWith("?");
|
||||
const map = attrs.match(/@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? fieldName;
|
||||
const isId = /@id\b/.test(attrs);
|
||||
const isUnique = /@unique\b/.test(attrs);
|
||||
const autoIncrement = /@default\(autoincrement\(\)\)/.test(attrs);
|
||||
const updatedAt = /@updatedAt\b/.test(attrs);
|
||||
const dbHint = attrs.match(/@db\.(\w+)(?:\((\d+)(?:\s*,\s*(\d+))?\))?/);
|
||||
fields.push({
|
||||
fieldName,
|
||||
column: map,
|
||||
prismaType: baseType,
|
||||
optional,
|
||||
isList,
|
||||
isId,
|
||||
isUnique,
|
||||
autoIncrement,
|
||||
updatedAt,
|
||||
attrs,
|
||||
dbHint: dbHint
|
||||
? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// model-level keys
|
||||
const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1];
|
||||
const ids = compIds
|
||||
? compIds.split(",").map((s) => s.trim().replace(/`/g, ""))
|
||||
: null;
|
||||
|
||||
const uniques = [];
|
||||
for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) {
|
||||
uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, "")));
|
||||
}
|
||||
|
||||
return { name, table, fields, ids, uniques };
|
||||
}
|
||||
|
||||
const models = modelBlocks.map(parseModel);
|
||||
|
||||
// ---------- Introspect live MySQL ----------
|
||||
let url;
|
||||
try {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL is required");
|
||||
url = mysqlConnectionUrl(raw);
|
||||
} catch (err) {
|
||||
console.error("[schema-gen] No DATABASE_URL:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
|
||||
const [cols] = await conn.query(
|
||||
`SELECT table_name, column_name, data_type, column_type, is_nullable,
|
||||
column_key, column_default, extra
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()`,
|
||||
);
|
||||
await conn.end();
|
||||
|
||||
const colByTable = new Map();
|
||||
for (const c of cols) {
|
||||
const key = `${c.table_name}.${c.column_name}`;
|
||||
colByTable.set(key, c);
|
||||
}
|
||||
|
||||
// ---------- Build drizzle column builders ----------
|
||||
const used = new Set(["mysqlTable", "primaryKey", "uniqueIndex", "customType"]);
|
||||
let usedSql = false;
|
||||
|
||||
function use(name) {
|
||||
used.add(name);
|
||||
}
|
||||
|
||||
function markSqlUsed() {
|
||||
usedSql = true;
|
||||
}
|
||||
|
||||
function quote(v) {
|
||||
return `"${String(v).replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Map a DB column row to a drizzle column expression string. */
|
||||
function columnExpr(field, dbCol) {
|
||||
const col = field.column;
|
||||
let expr = "";
|
||||
let type = "";
|
||||
|
||||
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
||||
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
||||
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
||||
|
||||
// Prisma enum types -> mysqlEnum
|
||||
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
||||
use("mysqlEnum");
|
||||
const values = enumMatch
|
||||
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
: field.prismaType === "users_gender"
|
||||
? ["M", "F"]
|
||||
: ["account", "ip", "machine", "super"];
|
||||
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
||||
}
|
||||
|
||||
switch (dbCol?.data_type) {
|
||||
case "int":
|
||||
use("int");
|
||||
type = unsigned
|
||||
? `int(${quote(col)}, { unsigned: true })`
|
||||
: `int(${quote(col)})`;
|
||||
break;
|
||||
case "tinyint": {
|
||||
const isBool =
|
||||
dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean";
|
||||
if (isBool) {
|
||||
use("boolean");
|
||||
type = `boolean(${quote(col)})`;
|
||||
} else {
|
||||
use("tinyint");
|
||||
type = unsigned
|
||||
? `tinyint(${quote(col)}, { unsigned: true })`
|
||||
: `tinyint(${quote(col)})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "smallint":
|
||||
use("smallint");
|
||||
type = unsigned
|
||||
? `smallint(${quote(col)}, { unsigned: true })`
|
||||
: `smallint(${quote(col)})`;
|
||||
break;
|
||||
case "mediumint":
|
||||
use("mediumint");
|
||||
type = unsigned
|
||||
? `mediumint(${quote(col)}, { unsigned: true })`
|
||||
: `mediumint(${quote(col)})`;
|
||||
break;
|
||||
case "bigint":
|
||||
use("bigint");
|
||||
type = `bigint(${quote(col)}, { mode: "bigint", unsigned: ${unsigned} })`;
|
||||
break;
|
||||
case "varchar":
|
||||
case "enum": {
|
||||
// DB enums become plain varchar in the schema: TS contract is `string`
|
||||
// (only users_gender / bans_type are typed as mysqlEnum above).
|
||||
use("varchar");
|
||||
const maxLen = enumMatch
|
||||
? Math.max(
|
||||
...[...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].length),
|
||||
1,
|
||||
)
|
||||
: Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 255);
|
||||
type = `varchar(${quote(col)}, { length: ${maxLen} })`;
|
||||
break;
|
||||
}
|
||||
case "char":
|
||||
use("char");
|
||||
type = `char(${quote(col)}, { length: ${Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 8)} })`;
|
||||
break;
|
||||
case "text":
|
||||
use("text");
|
||||
type = `text(${quote(col)})`;
|
||||
break;
|
||||
case "mediumtext":
|
||||
use("mediumtext");
|
||||
type = `mediumtext(${quote(col)})`;
|
||||
break;
|
||||
case "longtext":
|
||||
use("longtext");
|
||||
type = `longtext(${quote(col)})`;
|
||||
break;
|
||||
case "datetime": {
|
||||
use("datetime");
|
||||
const fsp = dbCol.column_type.match(/datetime\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `datetime(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `datetime(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "timestamp": {
|
||||
use("timestamp");
|
||||
const fsp = dbCol.column_type.match(/timestamp\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `timestamp(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `timestamp(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "double":
|
||||
use("double");
|
||||
type = `double(${quote(col)})`;
|
||||
break;
|
||||
case "float":
|
||||
use("float");
|
||||
type = `float(${quote(col)})`;
|
||||
break;
|
||||
case "decimal":
|
||||
use("decimal");
|
||||
type = `decimal(${quote(col)}, { precision: ${Number(decimalMatch?.[1] ?? 10)}, scale: ${Number(decimalMatch?.[2] ?? 0)}, mode: "number" })`;
|
||||
break;
|
||||
case "json":
|
||||
use("json");
|
||||
type = `json(${quote(col)})`;
|
||||
break;
|
||||
case "date":
|
||||
type = `dateAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "time":
|
||||
type = `timeAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "blob":
|
||||
case "tinyblob":
|
||||
case "mediumblob":
|
||||
case "longblob":
|
||||
use("binary");
|
||||
type = `binary(${quote(col)})`;
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col} (prisma:${field.prismaType})`,
|
||||
);
|
||||
use("varchar");
|
||||
type = `varchar(${quote(col)}, { length: 255 })`;
|
||||
}
|
||||
expr += type;
|
||||
|
||||
return expr;
|
||||
}
|
||||
|
||||
function modifiers(field, dbCol) {
|
||||
let expr = "";
|
||||
if (dbCol?.extra?.includes("auto_increment") || field.autoIncrement) {
|
||||
expr += `.autoincrement()`;
|
||||
}
|
||||
if (field.isId) {
|
||||
expr += `.primaryKey()`;
|
||||
} else if (dbCol?.column_key === "UNI" && !field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
} else if (field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
}
|
||||
// Mirror the Prisma TS contract: required fields (no `?`) are not-null on
|
||||
// select, and fields with `@default` are optional on insert.
|
||||
if (!field.optional) {
|
||||
expr += `.notNull()`;
|
||||
}
|
||||
expr += parseDefault(field, dbCol);
|
||||
return expr;
|
||||
}
|
||||
|
||||
/** Extract `@default(...)` and emit a drizzle `.default(...)` (or ""). */
|
||||
function parseDefault(field, dbCol) {
|
||||
const m = field.attrs.match(/@default\(/);
|
||||
if (!m) return "";
|
||||
const start = m.index + "@default(".length;
|
||||
let depth = 0;
|
||||
let content = "";
|
||||
for (let i = start; i < field.attrs.length; i++) {
|
||||
const ch = field.attrs[i];
|
||||
if (ch === "(") {
|
||||
depth++;
|
||||
} else if (ch === ")") {
|
||||
if (depth === 0) {
|
||||
content = field.attrs.slice(start, i);
|
||||
break;
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
if (!content) return "";
|
||||
if (content === "now()") {
|
||||
markSqlUsed();
|
||||
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
||||
}
|
||||
if (content === "autoincrement()" || content.startsWith("dbgenerated("))
|
||||
return "";
|
||||
if (content === "true" || content === "false") return `.default(${content})`;
|
||||
if (/^-?\d+$/.test(content)) {
|
||||
// integer literal; bigint64 data type is `bigint`, others are `number`
|
||||
return dbCol?.data_type === "bigint"
|
||||
? `.default(${content}n)`
|
||||
: `.default(${content})`;
|
||||
}
|
||||
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
||||
// quoted string or bare enum/string identifier (e.g. @default(M))
|
||||
const s =
|
||||
content.startsWith('"') && content.endsWith('"')
|
||||
? content.slice(1, -1)
|
||||
: content;
|
||||
return `.default(${JSON.stringify(s)})`;
|
||||
}
|
||||
|
||||
function modelTable(model) {
|
||||
const rows = [];
|
||||
for (const f of model.fields) {
|
||||
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
||||
if (!dbCol) {
|
||||
// Column not found in live DB. Prisma schema may be ahead of the DB.
|
||||
// Fall back to a best-effort type from the Prisma @db hint / base type.
|
||||
const fallback = fallbackColumn(f) + modifiers(f, null);
|
||||
rows.push(`\t${f.fieldName}: ${fallback},`);
|
||||
console.warn(
|
||||
`[schema-gen] WARN ${model.table}.${f.column} (${f.fieldName}) missing in DB, used fallback`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rows.push(
|
||||
`\t${f.fieldName}: ${columnExpr(f, dbCol)}${modifiers(f, dbCol)},`,
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueRows = [];
|
||||
if (model.ids) {
|
||||
const idCols = model.ids
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (idCols.length)
|
||||
uniqueRows.push(`\tprimaryKey({ columns: [${idCols.join(", ")}] }),`);
|
||||
}
|
||||
for (const u of model.uniques) {
|
||||
const cols = u
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (cols.length)
|
||||
uniqueRows.push(
|
||||
`\tuniqueIndex("${model.table}_${u.join("_")}").on(${cols.join(", ")}),`,
|
||||
);
|
||||
}
|
||||
|
||||
if (uniqueRows.length) {
|
||||
return `export const ${model.name} = mysqlTable(
|
||||
"${model.table}",
|
||||
{
|
||||
${rows.join("\n")}
|
||||
},
|
||||
(t) => [
|
||||
${uniqueRows.join("\n")}
|
||||
],
|
||||
);`;
|
||||
}
|
||||
return `export const ${model.name} = mysqlTable(
|
||||
"${model.table}",
|
||||
{
|
||||
${rows.join("\n")}
|
||||
},
|
||||
);`;
|
||||
}
|
||||
|
||||
function fallbackColumn(field) {
|
||||
const hint = field.dbHint;
|
||||
const name = field.column;
|
||||
switch (hint?.type) {
|
||||
case "VarChar":
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: ${Number(hint.param1 ?? 191)} })`;
|
||||
case "Char":
|
||||
use("char");
|
||||
return `char(${quote(name)}, { length: ${Number(hint.param1 ?? 8)} })`;
|
||||
case "Text":
|
||||
use("text");
|
||||
return `text(${quote(name)})`;
|
||||
case "MediumText":
|
||||
use("mediumtext");
|
||||
return `mediumtext(${quote(name)})`;
|
||||
case "LongText":
|
||||
use("longtext");
|
||||
return `longtext(${quote(name)})`;
|
||||
case "Decimal":
|
||||
use("decimal");
|
||||
return `decimal(${quote(name)}, { precision: ${Number(hint.param1 ?? 10)}, scale: ${Number(hint.param2 ?? 0)}, mode: "number" })`;
|
||||
case "UnsignedBigInt":
|
||||
use("bigint");
|
||||
return `bigint(${quote(name)}, { mode: "bigint", unsigned: true })`;
|
||||
case "UnsignedInt":
|
||||
use("int");
|
||||
return `int(${quote(name)}, { unsigned: true })`;
|
||||
case "Double":
|
||||
use("double");
|
||||
return `double(${quote(name)})`;
|
||||
case "Float":
|
||||
use("float");
|
||||
return `float(${quote(name)})`;
|
||||
case "Json":
|
||||
use("json");
|
||||
return `json(${quote(name)})`;
|
||||
case "Timestamp":
|
||||
use("timestamp");
|
||||
return `timestamp(${quote(name)})`;
|
||||
case "Time":
|
||||
return `timeAsDate(${quote(name)})`;
|
||||
case "Date":
|
||||
return `dateAsDate(${quote(name)})`;
|
||||
case "TinyInt":
|
||||
use("tinyint");
|
||||
return `tinyint(${quote(name)})`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (field.prismaType) {
|
||||
case "Int":
|
||||
use("int");
|
||||
return `int(${quote(name)})`;
|
||||
case "BigInt":
|
||||
use("bigint");
|
||||
return `bigint(${quote(name)}, { mode: "bigint" })`;
|
||||
case "Boolean":
|
||||
use("boolean");
|
||||
return `boolean(${quote(name)})`;
|
||||
case "DateTime":
|
||||
use("datetime");
|
||||
return `datetime(${quote(name)})`;
|
||||
case "String":
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
case "Float":
|
||||
use("double");
|
||||
return `double(${quote(name)})`;
|
||||
case "Decimal":
|
||||
use("decimal");
|
||||
return `decimal(${quote(name)}, { precision: 10, scale: 2, mode: "number" })`;
|
||||
case "Json":
|
||||
use("json");
|
||||
return `json(${quote(name)})`;
|
||||
case "Bytes":
|
||||
use("binary");
|
||||
return `binary(${quote(name)})`;
|
||||
default:
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Generate file ----------
|
||||
const body = models.map(modelTable).join("\n\n");
|
||||
|
||||
const IMPORTABLE = [
|
||||
"bigint",
|
||||
"binary",
|
||||
"boolean",
|
||||
"char",
|
||||
"customType",
|
||||
"date",
|
||||
"datetime",
|
||||
"decimal",
|
||||
"double",
|
||||
"float",
|
||||
"int",
|
||||
"json",
|
||||
"longtext",
|
||||
"mediumint",
|
||||
"mediumtext",
|
||||
"mysqlEnum",
|
||||
"mysqlTable",
|
||||
"primaryKey",
|
||||
"smallint",
|
||||
"text",
|
||||
"time",
|
||||
"timestamp",
|
||||
"tinyint",
|
||||
"uniqueIndex",
|
||||
"varchar",
|
||||
];
|
||||
const importList = IMPORTABLE.filter((b) => used.has(b));
|
||||
|
||||
const helpers = [
|
||||
"// MySQL TIME / DATE columns are hydrated by Prisma as JS Date (epoch 1970-01-01",
|
||||
"// for TIME). Mirror that so existing call sites keep working unchanged.",
|
||||
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "time";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(11, 19);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
||||
" return new Date(`1970-01-01T${value}Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"const dateAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "date";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(0, 10);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
||||
" return new Date(`${value}T00:00:00Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
||||
// TS field names mirror prisma/schema.prisma (camelCase); column names are the
|
||||
// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes.
|
||||
import {
|
||||
${importList.map((b) => `\t${b},`).join("\n")}
|
||||
} from "drizzle-orm/mysql-core";
|
||||
${usedSql ? `import { sql } from "drizzle-orm";\n` : ""}
|
||||
${helpers}${body}
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, out);
|
||||
console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`);
|
||||
@@ -0,0 +1,53 @@
|
||||
const fs = require("node:fs");
|
||||
const JSON5 = require("json5");
|
||||
|
||||
const exampleFile = process.argv[2];
|
||||
const targetFile = process.argv[3];
|
||||
|
||||
if (!exampleFile || !targetFile) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function deepMerge(target, source) {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
if (
|
||||
source[key] !== null &&
|
||||
typeof source[key] === "object" &&
|
||||
!Array.isArray(source[key])
|
||||
) {
|
||||
result[key] = deepMerge(target[key] || {}, source[key]);
|
||||
} else {
|
||||
if (!(key in result)) {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let example;
|
||||
try {
|
||||
const content = fs.readFileSync(exampleFile, "utf-8");
|
||||
example = JSON5.parse(content);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let current = {};
|
||||
try {
|
||||
if (fs.existsSync(targetFile)) {
|
||||
const content = fs.readFileSync(targetFile, "utf-8");
|
||||
current = JSON5.parse(content);
|
||||
}
|
||||
} catch {
|
||||
current = {};
|
||||
}
|
||||
|
||||
const merged = deepMerge(current, example);
|
||||
|
||||
const isJson5 = targetFile.endsWith(".json5");
|
||||
const output = isJson5
|
||||
? JSON5.stringify(merged, null, 4)
|
||||
: JSON.stringify(merged, null, 4);
|
||||
fs.writeFileSync(targetFile, `${output}\n`);
|
||||
@@ -1,66 +0,0 @@
|
||||
# ===========================================================================
|
||||
# Polaris Emulator — reference config.ini
|
||||
# Copy this to /var/www/emulator/Emulator/target/config.ini and fill in the
|
||||
# values marked CHANGE_ME. The emulator reads this file on startup.
|
||||
# Full guide: https://github.com/duckietm/Complete-Retro-on-Ubuntu
|
||||
# ===========================================================================
|
||||
|
||||
# ---- Database configuration ----
|
||||
db.hostname=127.0.0.1
|
||||
db.port=3306
|
||||
db.database=habbo # DB name (shared with the CMS)
|
||||
db.username=root # Username
|
||||
db.password=CHANGE_ME # Password
|
||||
db.params=?characterEncoding=utf8&useSSL=false&serverTimezone=Europe/Amsterdam
|
||||
db.pool.minsize=25
|
||||
db.pool.maxsize=100
|
||||
db.pool.connection_timeout_ms=10000
|
||||
db.pool.idle_timeout_ms=600000
|
||||
db.pool.max_lifetime_ms=1800000
|
||||
db.pool.validation_timeout_ms=5000
|
||||
db.pool.leak_detection_ms=20000 # set to 0 to disable leak detection
|
||||
|
||||
# ---- Game configuration ----
|
||||
# Host IP. Use 0.0.0.0 in most cases. Use 127.0.0.1 for LAN only.
|
||||
game.host=0.0.0.0
|
||||
game.port=3000
|
||||
|
||||
# ---- RCON configuration ----
|
||||
# Leave host at 127.0.0.1 if the CMS runs on the same server as the emulator.
|
||||
rcon.host=127.0.0.1
|
||||
rcon.port=3001
|
||||
rcon.allowed=127.0.0.1;127.0.0.2
|
||||
|
||||
# ---- Nitro secure runtime assets (disabled by default) ----
|
||||
nitro.secure.assets.enabled=false
|
||||
nitro.secure.api.enabled=false
|
||||
nitro.secure.session_ttl_sec=900
|
||||
nitro.secure.config.root=
|
||||
nitro.secure.gamedata.root=
|
||||
# Set a persistent secret when using Cloudflare or multiple backend requests.
|
||||
nitro.secure.master_key=change-me-to-a-long-random-secret
|
||||
|
||||
# ---- Login ----
|
||||
login.remember.enabled=true
|
||||
login.remember.duration.days=30
|
||||
# Optional: set a persistent remember-me JWT secret here.
|
||||
login.remember.jwt.secret=
|
||||
login.news.limit=5
|
||||
|
||||
# ---- Secure runtime ECDH session TTL in seconds ----
|
||||
# (kept for compatibility; unused when secure assets are disabled)
|
||||
|
||||
# ---- WebSockets (Nitro client) ----
|
||||
ws.enabled=true
|
||||
ws.host=0.0.0.0
|
||||
ws.port=2096
|
||||
# Header used to obtain the real client IP when behind a proxy
|
||||
# (usually X-Forwarded-For, or CF-Connecting-IP behind Cloudflare).
|
||||
ws.ip.header=X-Forwarded-For
|
||||
|
||||
# ---- Console / emulator_settings ----
|
||||
# Run these SQL statements ONCE, after importing the emulator database:
|
||||
# USE habbo;
|
||||
# UPDATE emulator_settings SET `value` = '0' WHERE `key` = 'console.mode';
|
||||
# UPDATE emulator_settings SET `value` = 'MY_DOMAIN.COM,*.MY_DOMAIN.COM,localhost,127.0.0.1' WHERE `key` = 'websockets.whitelist';
|
||||
# console.mode MUST be 0 and the whitelist MUST match your domain.
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Launcher for the Polaris emulator.
|
||||
# Place at /var/www/emulator/Emulator/target/emulator and chmod +x.
|
||||
# Update the JAR name to match the version built by `mvn clean package`.
|
||||
|
||||
file_name_emulator=emulator.log
|
||||
current_time=$(date "+%H%M_%d-%m-%Y")
|
||||
file_name=$file_name_emulator.$current_time
|
||||
|
||||
# Rotate the previous log
|
||||
mkdir -p /var/log/emu
|
||||
mv /var/log/emu/emulator.log /var/log/emu/$file_name 2>/dev/null || true
|
||||
|
||||
# -Xmx4096m = 4 GB. 1 GB=1024, 2 GB=2048, 3 GB=3072, 4 GB=4096
|
||||
java -Dfile.encoding=UTF8 -Xmx4096m \
|
||||
-jar /var/www/emulator/Emulator/target/Habbo-*-jar-with-dependencies.jar \
|
||||
>/var/log/emu/emulator.log
|
||||
@@ -1,25 +0,0 @@
|
||||
[Unit]
|
||||
Description=Habbo Emulator
|
||||
|
||||
# Make sure the database has started before the emulator can start
|
||||
After=mariadb.service
|
||||
Requires=mariadb.service
|
||||
|
||||
# If you want to run as a less privileged account, change User below.
|
||||
# Make sure that account has the right permissions on the working directory and target folders.
|
||||
[Service]
|
||||
User=root
|
||||
|
||||
# Working directory where config.ini and the JAR live
|
||||
WorkingDirectory=/var/www/emulator/Emulator/target
|
||||
|
||||
# The launcher script we created below. It is a shell wrapper that calls the JAR.
|
||||
ExecStart=/var/www/emulator/Emulator/target/emulator
|
||||
|
||||
SuccessExitStatus=143
|
||||
TimeoutStopSec=10
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"distObfuscationEnabled": false,
|
||||
"secureAssetsEnabled": false,
|
||||
"secureApiEnabled": false,
|
||||
"apiBaseUrl": "https://MY_DOMAIN:2096",
|
||||
"plainConfigBaseUrl": "https://MY_DOMAIN/configuration/",
|
||||
"plainGamedataBaseUrl": "https://MY_DOMAIN/gamedata/"
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"socket.url": "wss://MY_DOMAIN.COM:2096",
|
||||
"crypto.ws.enabled": false,
|
||||
"crypto.ws.signing.enabled": false,
|
||||
"crypto.ws.signing.public_key": "",
|
||||
"api.url": "https://MY_DOMAIN.COM:2096",
|
||||
"asset.url": "https://MY_DOMAIN.COM/gamedata",
|
||||
"image.library.url": "http://MY_DOMAIN.COM/gamedata/c_images/",
|
||||
"hof.furni.url": "https://MY_DOMAIN.COM",
|
||||
"images.url": "${asset.url}/images",
|
||||
"gamedata.url": "${asset.url}",
|
||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
||||
"external.texts.url": [
|
||||
"${gamedata.url}/config/ExternalTexts.json?v=1",
|
||||
"${gamedata.url}/config/UITexts.json?v=1"
|
||||
],
|
||||
"external.samples.url": "${gamedata.url}/sounds/sound_machine_sample_%sample%.mp3",
|
||||
"furnidata.url": "${gamedata.url}/config/FurnitureData.json?v=1",
|
||||
"productdata.url": "${gamedata.url}/config/ProductData.json?v=1",
|
||||
"avatar.actions.url": "${gamedata.url}/config/HabboAvatarActions.json?v=1",
|
||||
"avatar.figuredata.url": "${gamedata.url}/config/FigureData.json?v=1",
|
||||
"avatar.figuremap.url": "${gamedata.url}/config/FigureMap.json?v=1",
|
||||
"avatar.effectmap.url": "${gamedata.url}/config/EffectMap.json?v=1",
|
||||
"avatar.asset.url": "${asset.url}/clothes/%libname%.nitro",
|
||||
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
|
||||
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
|
||||
"furni.asset.icon.url": "http://MY_DOMAIN.COM/gamedata/icons/%libname%%param%_icon.png",
|
||||
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
|
||||
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
|
||||
"room.asset.url": "${asset.url}/room/%libname%/%libname%.json",
|
||||
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
||||
"badge.asset.group.url": "http://MY_DOMAIN.COM/habbo-imaging/badge/%badgedata%",
|
||||
"badge.asset.group.external.url": "",
|
||||
"badge.asset.grouparts.url": "https://MY_DOMAIN.COM/gamedata/badgeparts/badgepart_%part%.png",
|
||||
"furni.rotation.bounce.steps": 20,
|
||||
"furni.rotation.bounce.height": 0.0625,
|
||||
"room.color.skip.transition": false,
|
||||
"enable.avatar.arrow": false,
|
||||
"system.animation.fps": 60,
|
||||
"system.limits.fps": false,
|
||||
"system.dispatcher.log": false,
|
||||
"system.packet.log": false,
|
||||
"system.pong.manually": true,
|
||||
"system.pong.interval.ms": 20000,
|
||||
"room.color.skip.transition": true,
|
||||
"user.badges.group.slot.enabled": true,
|
||||
"timezone.settings": "Europe/Amsterdam",
|
||||
"login.screen.enabled": true,
|
||||
"login.endpoint": "${api.url}/api/auth/login",
|
||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
||||
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
||||
"login.health.endpoint": "${api.url}/api/auth/health",
|
||||
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
|
||||
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
|
||||
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
|
||||
"login.remember.endpoint": "${api.url}/api/auth/remember",
|
||||
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
||||
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
||||
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
||||
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
||||
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
||||
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
|
||||
"account.change-password.endpoint": "${api.url}/api/auth/change-password",
|
||||
"account.change-email.endpoint": "${api.url}/api/auth/change-email",
|
||||
"account.change-username.endpoint": "${api.url}/api/auth/change-username",
|
||||
"login.health.method": "GET",
|
||||
"login.news.url": "${asset.url}/news/news.json",
|
||||
"login.turnstile.enabled": false,
|
||||
"login.turnstile.sitekey": "",
|
||||
"avatar.mandatory.libraries": ["bd:1", "li:0"],
|
||||
"avatar.mandatory.effect.libraries": ["dance.1", "dance.2", "dance.3", "dance.4"]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
||||
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
||||
"camera.url": "https://MY_DOMAIN.COM/camera/photo",
|
||||
"thumbnails.url": "https://MY_DOMAIN.COM/camera/photo/thumb/%thumbnail%.png",
|
||||
"url.prefix": "",
|
||||
"habbopages.url": "/gamedata/habbopages/",
|
||||
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
|
||||
"guide.help.alpha.groupid": 0,
|
||||
"chat.viewer.height.percentage": 0.4,
|
||||
"widget.dimmer.colorwheel": false,
|
||||
"avatar.wardrobe.max.slots": 10,
|
||||
"user.badges.max.slots": 5,
|
||||
"camera.publish.disabled": false,
|
||||
"hc.disabled": false,
|
||||
"badge.descriptions.enabled": true,
|
||||
"motto.max.length": 38,
|
||||
"bot.name.max.length": 15,
|
||||
"wired.action.bot.talk.to.avatar.max.length": 64,
|
||||
"wired.action.bot.talk.max.length": 64,
|
||||
"wired.action.chat.max.length": 100,
|
||||
"wired.action.kick.from.room.max.length": 100,
|
||||
"wired.action.mute.user.max.length": 100,
|
||||
"game.center.enabled": false,
|
||||
"catalog.style.new": true,
|
||||
"show.google.ads": false,
|
||||
"loginview": {
|
||||
"images": {
|
||||
"background": "${asset.url}/c_images/reception/stretch_blue.png",
|
||||
"background.colour": "#6eadc8",
|
||||
"sun": "${asset.url}/c_images/reception/sun.png",
|
||||
"drape": "${asset.url}/c_images/reception/drape.png",
|
||||
"left": "${asset.url}/c_images/reception/ts.png",
|
||||
"right": "${asset.url}/c_images/reception/US_right.png",
|
||||
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ActionError } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createAd, deleteAd } from "./admin-ads";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class extends Error {},
|
||||
actionOk: vi.fn(() => "ok"),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (k: string) => data[k] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("createAd", () => {
|
||||
it("creates ad and redirects", async () => {
|
||||
vi.mocked(prisma.websiteAds.create).mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
} as never);
|
||||
await createAd(
|
||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteAds.create).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
});
|
||||
|
||||
it("returns early when image empty", async () => {
|
||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
||||
expect(prisma.websiteAds.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error on db failure", async () => {
|
||||
vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("db"));
|
||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAd", () => {
|
||||
it("deletes ad and returns ok", async () => {
|
||||
vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never);
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
expect(
|
||||
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("throws ActionError when not found", async () => {
|
||||
vi.mocked(prisma.websiteAds.delete).mockRejectedValue(new Error("nf"));
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
await expect(
|
||||
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
||||
).rejects.toThrow(ActionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendHotelAlert } from "./admin-alerts";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("sendHotelAlert", () => {
|
||||
it("sends hotel alert and revalidates", async () => {
|
||||
await sendHotelAlert(
|
||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||
);
|
||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||
});
|
||||
|
||||
it("returns early when message is empty", async () => {
|
||||
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
||||
expect(rcon.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { createBan, liftBan } from "./admin-bans";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
ban: { create: vi.fn(), delete: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("createBan", () => {
|
||||
it("creates a ban for valid inputs", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "baduser",
|
||||
} as never);
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "42",
|
||||
reason: "Spam",
|
||||
hours: "24",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.ban.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ userId: 42, type: "account" }),
|
||||
});
|
||||
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
|
||||
it("returns early when userId is invalid", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "0",
|
||||
hours: "1",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.ban.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liftBan", () => {
|
||||
it("deletes ban and revalidates", async () => {
|
||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(prisma.ban.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
type BanType = $Enums.bans_type;
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||
"account",
|
||||
"ip",
|
||||
@@ -45,7 +43,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as BanType,
|
||||
type: type as any,
|
||||
cfhTopic: -1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { disbandGuild } from "./admin-guilds";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
guilds: { findUnique: vi.fn(), delete: vi.fn() },
|
||||
guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() },
|
||||
guildsForumsComments: { deleteMany: vi.fn() },
|
||||
guildForumViews: { deleteMany: vi.fn() },
|
||||
guildsMembers: { deleteMany: vi.fn() },
|
||||
rooms: { updateMany: vi.fn() },
|
||||
items: { updateMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("disbandGuild", () => {
|
||||
it("disbands guild and cleans related data", async () => {
|
||||
vi.mocked(prisma.guilds.findUnique).mockResolvedValue({
|
||||
id: 1,
|
||||
name: "TestGuild",
|
||||
userId: 42,
|
||||
} as never);
|
||||
vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([
|
||||
{ id: 10 },
|
||||
] as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => {
|
||||
const tx = {
|
||||
guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) },
|
||||
guildsForumsThreads: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: 10 }]),
|
||||
deleteMany: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
guildForumViews: { deleteMany: vi.fn().mockResolvedValue({}) },
|
||||
guildsMembers: { deleteMany: vi.fn().mockResolvedValue({}) },
|
||||
rooms: { updateMany: vi.fn().mockResolvedValue({}) },
|
||||
items: { updateMany: vi.fn().mockResolvedValue({}) },
|
||||
guilds: { delete: vi.fn().mockResolvedValue({}) },
|
||||
} as never;
|
||||
await (fn as (tx: never) => Promise<void>)(tx);
|
||||
});
|
||||
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
||||
expect(prisma.guilds.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -25,12 +25,63 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||
const id = String(ticketId);
|
||||
revalidatePath("/admin/help-tickets");
|
||||
revalidatePath(`/admin/help-tickets/${id}`);
|
||||
revalidatePath("/mod/help-tickets");
|
||||
revalidatePath(`/mod/help-tickets/${id}`);
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
|
||||
export const liftBanFromHelpTicket = adminAction(
|
||||
{
|
||||
permission: PERMS.USERS_BAN,
|
||||
schema: helpCenterTicketIdSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true, title: true },
|
||||
});
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.userId == null) {
|
||||
throw new ActionError("Ticket has no requester to unban");
|
||||
}
|
||||
|
||||
const removed = await prisma.ban.deleteMany({
|
||||
where: { userId: ticket.userId },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
if (ticket.open) {
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban_via_help_ticket",
|
||||
target: "User",
|
||||
targetId: ticket.userId,
|
||||
after: {
|
||||
ticketId: String(ticketId),
|
||||
removedBans: removed.count,
|
||||
title: ticket.title,
|
||||
},
|
||||
});
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
||||
return actionOk({ removed: removed.count, userId: ticket.userId });
|
||||
},
|
||||
);
|
||||
|
||||
const HELP_TICKET_EDIT = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
|
||||
|
||||
export const replyHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
@@ -72,7 +123,7 @@ export const replyHelpCenterTicket = adminAction(
|
||||
);
|
||||
|
||||
export const closeHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
@@ -104,7 +155,7 @@ export const closeHelpCenterTicket = adminAction(
|
||||
);
|
||||
|
||||
export const reopenHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createHelpQuestion,
|
||||
deleteHelpQuestion,
|
||||
updateHelpQuestion,
|
||||
} from "./admin-help";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
websiteHelpCenterCategories: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("createHelpQuestion", () => {
|
||||
it("creates a help question and redirects", async () => {
|
||||
vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({
|
||||
id: BigInt(5),
|
||||
} as never);
|
||||
await createHelpQuestion(
|
||||
fakeForm({
|
||||
name: "FAQ",
|
||||
content: "<p>Answer</p>",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateHelpQuestion", () => {
|
||||
it("updates and redirects", async () => {
|
||||
vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
await updateHelpQuestion(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
content: "New",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteHelpQuestion", () => {
|
||||
it("deletes and redirects", async () => {
|
||||
vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
addBlacklist,
|
||||
addWhitelist,
|
||||
deleteBlacklist,
|
||||
deleteWhitelist,
|
||||
} from "./admin-ip";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() },
|
||||
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("addWhitelist", () => {
|
||||
it("creates whitelist entry", async () => {
|
||||
await addWhitelist(
|
||||
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({
|
||||
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false },
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
||||
});
|
||||
|
||||
it("returns early when ip is empty", async () => {
|
||||
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
||||
expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWhitelist", () => {
|
||||
it("deletes whitelist entry", async () => {
|
||||
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addBlacklist", () => {
|
||||
it("creates blacklist entry", async () => {
|
||||
await addBlacklist(
|
||||
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({
|
||||
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBlacklist", () => {
|
||||
it("deletes blacklist entry", async () => {
|
||||
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(99) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/media-storage", () => {
|
||||
const root = path.join("/tmp", "nexst-test-media");
|
||||
return {
|
||||
MEDIA_ROOT: root,
|
||||
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
||||
};
|
||||
});
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("uploadMedia", () => {
|
||||
it("returns error when no file provided", async () => {
|
||||
const result = await uploadMedia(new FormData());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("No file provided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadMediaAndReturn", () => {
|
||||
it("returns empty string when no file", async () => {
|
||||
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMedia", () => {
|
||||
it("deletes media file and revalidates", async () => {
|
||||
await deleteMedia("photo.png");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||
});
|
||||
|
||||
it("skips deletion when path is outside media root", async () => {
|
||||
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
||||
await deleteMedia("../../../etc/passwd");
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
hiddenGroups: z.array(z.string()),
|
||||
hiddenItems: z.array(z.string()),
|
||||
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
export const saveAdminNavConfig = adminAction(
|
||||
{
|
||||
permission: PERMS.SETTINGS_EDIT,
|
||||
schema,
|
||||
rateLimitKey: "admin-nav-config-save",
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const config: AdminNavConfig = {
|
||||
groupOrder: ctx.data.groupOrder,
|
||||
hiddenGroups: ctx.data.hiddenGroups,
|
||||
hiddenItems: ctx.data.hiddenItems,
|
||||
itemOrder: ctx.data.itemOrder,
|
||||
};
|
||||
await siteSettings.update(
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
serializeAdminNavConfig(config),
|
||||
);
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { deletePhoto } from "./admin-photos";
|
||||
|
||||
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
||||
const limit = vi.fn();
|
||||
const whereSelect = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where: whereSelect }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
const whereDelete = vi.fn();
|
||||
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
||||
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/admin/photo-files", () => ({
|
||||
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: (...args) => select(...args),
|
||||
delete: (...args) => deleteFn(...args),
|
||||
},
|
||||
CameraWeb: { id: "id", url: "url" },
|
||||
}));
|
||||
|
||||
const fakeForm = (data) => ({
|
||||
get: (key) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
||||
whereDelete.mockResolvedValue(undefined);
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePhoto", () => {
|
||||
it("deletes a photo and revalidates", async () => {
|
||||
await deletePhoto(fakeForm({ id: "42" }));
|
||||
expect(select).toHaveBeenCalled();
|
||||
expect(deleteFn).toHaveBeenCalled();
|
||||
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
||||
expect(logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "photo_delete",
|
||||
targetId: 42,
|
||||
}),
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await deletePhoto(fakeForm({ id: "0" }));
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
try {
|
||||
await prisma.cameraWeb.delete({ where: { id } });
|
||||
} catch {
|
||||
// Record may have already been removed; ignore.
|
||||
const [row] = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(eq(CameraWeb.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (row) {
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
await tryRemoveLocalPhotoFile(row.url);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "photo_delete",
|
||||
description: `Deleted camera photo #${id}`,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/photos");
|
||||
}
|
||||
|
||||
export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const raw = String(formData.get("ids") ?? "");
|
||||
const ids = raw
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const rows = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(inArray(CameraWeb.id, ids));
|
||||
|
||||
if (rows.length > 0) {
|
||||
await db.delete(CameraWeb).where(
|
||||
inArray(
|
||||
CameraWeb.id,
|
||||
rows.map((r) => r.id),
|
||||
),
|
||||
);
|
||||
await Promise.all(rows.map((r) => tryRemoveLocalPhotoFile(r.url)));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "photo_bulk_delete",
|
||||
description: `Deleted ${rows.length} camera photo(s)`,
|
||||
targetType: "camera_web",
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/photos");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
taggables: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("createTag", () => {
|
||||
it("creates a tag and revalidates", async () => {
|
||||
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
|
||||
|
||||
await createTag(
|
||||
fakeForm({
|
||||
name: "News",
|
||||
backgroundColor: "#ff0000",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(prisma.tags.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ name: "News" }),
|
||||
}),
|
||||
);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when name is empty", async () => {
|
||||
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
||||
expect(prisma.tags.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses default color when not provided", async () => {
|
||||
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
|
||||
|
||||
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
||||
|
||||
expect(prisma.tags.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ backgroundColor: "#888888" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles db error gracefully", async () => {
|
||||
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error"));
|
||||
|
||||
await expect(
|
||||
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||
).resolves.toBeUndefined();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTag", () => {
|
||||
it("updates a tag and revalidates", async () => {
|
||||
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
|
||||
|
||||
await updateTag(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
backgroundColor: "#00ff00",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(prisma.tags.update).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
data: expect.objectContaining({ name: "Updated" }),
|
||||
});
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when id is invalid", async () => {
|
||||
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
||||
expect(prisma.tags.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when name is empty after update", async () => {
|
||||
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||
expect(prisma.tags.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTag", () => {
|
||||
it("deletes a tag and its taggables", async () => {
|
||||
vi.mocked(prisma.taggables.deleteMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.tags.delete).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (ops: unknown) => {
|
||||
const arr = ops as [
|
||||
typeof prisma.taggables.deleteMany,
|
||||
typeof prisma.tags.delete,
|
||||
];
|
||||
await arr[0];
|
||||
await arr[1];
|
||||
});
|
||||
|
||||
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
|
||||
expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({
|
||||
where: { tagId: BigInt(42) },
|
||||
});
|
||||
expect(prisma.tags.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
});
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when id is invalid", async () => {
|
||||
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createTeam, deleteTeam } from "./admin-teams";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("createTeam", () => {
|
||||
it("creates a team entry", async () => {
|
||||
await createTeam(
|
||||
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ rankName: "Moderator" }),
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
});
|
||||
|
||||
it("returns early when rankName is empty", async () => {
|
||||
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
|
||||
expect(prisma.websiteTeams.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTeam", () => {
|
||||
it("deletes a team entry", async () => {
|
||||
vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never);
|
||||
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(42) },
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { saveVpn } from "./admin-vpn";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { websiteSetting: { upsert: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { reload: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("saveVpn", () => {
|
||||
it("saves VPN settings and redirects", async () => {
|
||||
await saveVpn(
|
||||
fakeForm({
|
||||
vpn_block_enabled: "1",
|
||||
vpn_provider: "proxycheck",
|
||||
vpn_api_key: "abc123",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4);
|
||||
expect(siteSettings.reload).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// @ts-nocheck
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { applyStaff, applyTeam } from "./applications";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { websiteStaffApplications: { findFirst: vi.fn(), create: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } } as never);
|
||||
vi.mocked(clientIp).mockResolvedValue("127.0.0.1");
|
||||
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("applyStaff", () => {
|
||||
it("submits staff application", async () => {
|
||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
await applyStaff(
|
||||
fakeForm({
|
||||
rankId: "3",
|
||||
content: "I want to help!",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteStaffApplications.create).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/apply/staff?submitted=1");
|
||||
});
|
||||
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
await applyStaff(fakeForm({}) as unknown as FormData);
|
||||
expect(redirect).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("returns duplicate status when application exists", async () => {
|
||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue({
|
||||
id: 1,
|
||||
} as never);
|
||||
await applyStaff(
|
||||
fakeForm({
|
||||
rankId: "3",
|
||||
content: "I want to help!",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteStaffApplications.create).not.toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=duplicate");
|
||||
});
|
||||
|
||||
it("handles rate limit", async () => {
|
||||
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||
await applyStaff(
|
||||
fakeForm({
|
||||
rankId: "3",
|
||||
content: "I want to help!",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTeam", () => {
|
||||
it("submits team application", async () => {
|
||||
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
await applyTeam(
|
||||
fakeForm({
|
||||
teamId: "2",
|
||||
content: "I want to join team!",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(prisma.websiteStaffApplications.create).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/apply/team?submitted=1");
|
||||
});
|
||||
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
await applyTeam(fakeForm({}) as unknown as FormData);
|
||||
expect(redirect).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-nocheck
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { precheckLogin } from "./auth-precheck";
|
||||
|
||||
vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
|
||||
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: { user: { findUnique: vi.fn() } } }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/services/captcha", () => ({
|
||||
captchaConfig: vi.fn(),
|
||||
verifyCaptcha: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { getBool: vi.fn() },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
|
||||
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
||||
});
|
||||
|
||||
describe("precheckLogin", () => {
|
||||
it("returns ok for valid login without 2FA", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns twofactor when 2FA is set up", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: new Date(),
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
||||
});
|
||||
|
||||
it("returns invalid for empty inputs", async () => {
|
||||
expect(await precheckLogin("", "")).toBe("invalid");
|
||||
});
|
||||
|
||||
it("returns captcha when captcha required", async () => {
|
||||
vi.mocked(captchaConfig).mockResolvedValue({
|
||||
provider: "hcaptcha",
|
||||
} as never);
|
||||
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
||||
});
|
||||
|
||||
it("returns invalid when user not found (dummy hash check)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const result = await precheckLogin("nonexistent", "pass");
|
||||
expect(result).toBe("invalid");
|
||||
expect(checkLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unverified when email verification required", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: "[email protected]",
|
||||
mailVerified: "0",
|
||||
} as never);
|
||||
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
||||
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// @ts-nocheck
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
bulkBan,
|
||||
bulkGiveBadge,
|
||||
bulkGiveCurrency,
|
||||
bulkUnban,
|
||||
} from "./bulk-users";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
ban: { deleteMany: vi.fn(), create: vi.fn() },
|
||||
user: { update: vi.fn() },
|
||||
usersCurrency: { upsert: vi.fn() },
|
||||
usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: {
|
||||
giveCredits: vi.fn(),
|
||||
giveDuckets: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
giveBadge: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("bulkUnban", () => {
|
||||
it("unbans users", async () => {
|
||||
vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never);
|
||||
const r = await bulkUnban({ userIds: [1, 2, 3] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.data).toEqual({ unbanned: 3, total: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkBan", () => {
|
||||
it("bans users", async () => {
|
||||
vi.mocked(prisma.ban.create).mockResolvedValue({} as never);
|
||||
const r = await bulkBan({
|
||||
userIds: [1, 2],
|
||||
reason: "Spam",
|
||||
duration: 3600,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.data.banned).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkGiveCurrency", () => {
|
||||
it("gives credits", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
const r = await bulkGiveCurrency({
|
||||
userIds: [1],
|
||||
amount: 100,
|
||||
type: "credits",
|
||||
});
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
||||
});
|
||||
|
||||
it("gives pixels", async () => {
|
||||
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
|
||||
const r = await bulkGiveCurrency({
|
||||
userIds: [2],
|
||||
amount: 50,
|
||||
type: "pixels",
|
||||
});
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
|
||||
});
|
||||
|
||||
it("gives points", async () => {
|
||||
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
|
||||
const r = await bulkGiveCurrency({
|
||||
userIds: [3],
|
||||
amount: 25,
|
||||
type: "points",
|
||||
});
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkGiveBadge", () => {
|
||||
it("gives badge to user", async () => {
|
||||
vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({
|
||||
_max: { slotId: 5 },
|
||||
} as never);
|
||||
vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never);
|
||||
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
||||
expect(r.data.given).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, Sanctions, User, UsersSettings } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
@@ -181,3 +183,177 @@ export async function bulkGiveBadge({
|
||||
data: { given, total: userIds.length, failedIds },
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkAdjustCurrency({
|
||||
userIds,
|
||||
amount,
|
||||
type,
|
||||
}: {
|
||||
userIds: number[];
|
||||
/** Positive = give, negative = take. Balances clamped at 0. */
|
||||
amount: number;
|
||||
type: "credits" | "pixels" | "points";
|
||||
}): Promise<
|
||||
ActionResult<{
|
||||
adjusted: number;
|
||||
total: number;
|
||||
failedIds: Array<{ userId: number; reason: string }>;
|
||||
}>
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
if (!Number.isFinite(amount) || amount === 0) {
|
||||
return { ok: false as const, error: "Amount must be a non-zero number" };
|
||||
}
|
||||
|
||||
if (amount > 0) {
|
||||
const given = await bulkGiveCurrency({ userIds, amount, type });
|
||||
if (!given.ok) return given;
|
||||
if (!given.data) {
|
||||
return { ok: false as const, error: "Currency adjustment failed" };
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
adjusted: given.data.given,
|
||||
total: given.data.total,
|
||||
failedIds: given.data.failedIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const take = Math.abs(Math.trunc(amount));
|
||||
let adjusted = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
if (!user) {
|
||||
failedIds.push({ userId, reason: "Not found" });
|
||||
continue;
|
||||
}
|
||||
const next = Math.max(0, user.credits - take);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: next },
|
||||
});
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
const row = await prisma.usersCurrency.findUnique({
|
||||
where: { userId_type: { userId, type: currencyType } },
|
||||
});
|
||||
const current = row?.amount ?? 0;
|
||||
const next = Math.max(0, current - take);
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: currencyType } },
|
||||
update: { amount: next },
|
||||
create: { userId, type: currencyType, amount: next },
|
||||
});
|
||||
}
|
||||
adjusted++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_adjust_currency",
|
||||
description: `Adjusted ${amount} ${type} for ${adjusted} user(s) (DB-only take; no RCON debit)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { adjusted, total: userIds.length, failedIds },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade`
|
||||
* via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online).
|
||||
*/
|
||||
export async function setTradeLock({
|
||||
userId,
|
||||
untilUnix,
|
||||
}: {
|
||||
userId: number;
|
||||
/** Unix seconds; 0 clears the lock. */
|
||||
untilUnix: number;
|
||||
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const until = Math.max(0, Math.trunc(untilUnix));
|
||||
const locked = until > 0;
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
return { ok: false as const, error: "User not found" };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: Sanctions.id })
|
||||
.from(Sanctions)
|
||||
.where(eq(Sanctions.habboId, userId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(Sanctions)
|
||||
.set({
|
||||
tradeLockedUntil: until,
|
||||
...(locked ? { reason: "Trade lock (CMS)" } : {}),
|
||||
})
|
||||
.where(eq(Sanctions.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(Sanctions).values({
|
||||
habboId: userId,
|
||||
tradeLockedUntil: until,
|
||||
reason: locked ? "Trade lock (CMS)" : "",
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(UsersSettings)
|
||||
.set({
|
||||
canTrade: locked ? "0" : "1",
|
||||
...(locked
|
||||
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(UsersSettings.userId, userId));
|
||||
});
|
||||
|
||||
await rcon.setTradeLock(userId, locked);
|
||||
await rcon.alertUser(
|
||||
userId,
|
||||
locked
|
||||
? "Trading has been disabled by staff."
|
||||
: "Trading has been re-enabled by staff.",
|
||||
);
|
||||
if (user.online === "1") {
|
||||
await rcon.disconnectUser(userId, user.username);
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: locked ? "trade_lock" : "trade_unlock",
|
||||
description: locked
|
||||
? `Trade-locked ${user.username} (#${userId}) until ${until}`
|
||||
: `Cleared trade lock for ${user.username} (#${userId})`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return { ok: true as const, data: { userId, untilUnix: until } };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -58,7 +57,7 @@ export async function updateBcPage({
|
||||
}
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: data as Prisma.CatalogPagesBcUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
@@ -104,7 +103,7 @@ export async function updateBcItem({
|
||||
}
|
||||
await prisma.catalogItemsBc.update({
|
||||
where: { id },
|
||||
data: safe as Prisma.CatalogItemsBcUpdateInput,
|
||||
data: safe as any,
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -252,7 +252,7 @@ export async function moveCatalogItems({
|
||||
await prisma.$executeRaw`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}
|
||||
WHERE id IN (${Prisma.join(ids)})
|
||||
WHERE id IN (${sql.join(ids, sql`, `)})
|
||||
`;
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
@@ -301,7 +301,7 @@ export async function updateCatalogItem({
|
||||
if (Object.keys(safeCatalog).length > 0) {
|
||||
await prisma.catalogItems.update({
|
||||
where: { id },
|
||||
data: safeCatalog as Prisma.CatalogItemsUpdateInput,
|
||||
data: safeCatalog as any,
|
||||
});
|
||||
}
|
||||
if (baseItem) {
|
||||
@@ -309,7 +309,7 @@ export async function updateCatalogItem({
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: baseItem.id },
|
||||
data: safeBase as Prisma.ItemsBaseUpdateInput,
|
||||
data: safeBase as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -57,7 +56,7 @@ export async function updateCatalogPage({
|
||||
}
|
||||
await prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: data as Prisma.CatalogPagesUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
|
||||
+19
-22
@@ -2,7 +2,6 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -89,29 +88,27 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { emulatorSettings: { upsert: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: vi.fn((_opts: unknown, fn: (...args: unknown[]) => unknown) => fn),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
|
||||
|
||||
describe("saveEmulatorSettings", () => {
|
||||
it("saves settings and calls rcon update", async () => {
|
||||
vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never);
|
||||
const handler = (await import("./emulator").then(
|
||||
(m) => m.saveEmulatorSettings,
|
||||
)) as unknown as (ctx: {
|
||||
data: { settings: Record<string, string> };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
|
||||
const result = await handler({
|
||||
data: { settings: { key1: "val1", key2: "val2" } },
|
||||
session: { user: { id: "1" } },
|
||||
});
|
||||
|
||||
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(rcon.updateConfig).toHaveBeenCalled();
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -99,12 +99,61 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const banAppeal =
|
||||
String(formData.get("banAppeal") ?? "") === "1" ||
|
||||
String(formData.get("banAppeal") ?? "") === "on";
|
||||
let ticketTitle = title;
|
||||
let categoryId: bigint | null = null;
|
||||
|
||||
if (banAppeal) {
|
||||
if (!/ban\s*appeal/i.test(ticketTitle)) {
|
||||
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
|
||||
}
|
||||
const existing = await prisma.websiteHelpCenterCategories.findFirst(
|
||||
{
|
||||
where: { name: { equals: "Ban appeal" } },
|
||||
select: { id: true },
|
||||
},
|
||||
);
|
||||
if (existing) {
|
||||
categoryId = existing.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await prisma.websiteHelpCenterCategories.create(
|
||||
{
|
||||
data: {
|
||||
name: "Ban appeal",
|
||||
content:
|
||||
"Appeals for account bans. Staff can lift bans from the ticket.",
|
||||
position: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
},
|
||||
);
|
||||
categoryId = created.id;
|
||||
} catch {
|
||||
const again =
|
||||
await prisma.websiteHelpCenterCategories.findFirst({
|
||||
where: { name: { equals: "Ban appeal" } },
|
||||
select: { id: true },
|
||||
});
|
||||
categoryId = again?.id ?? null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const rawCat = String(formData.get("categoryId") ?? "").trim();
|
||||
if (/^\d+$/.test(rawCat)) {
|
||||
categoryId = BigInt(rawCat);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
title: ticketTitle,
|
||||
content,
|
||||
categoryId,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const ITEMS_BASE_FIELDS = [
|
||||
"publicName",
|
||||
"itemName",
|
||||
"type",
|
||||
"spriteId",
|
||||
"width",
|
||||
"length",
|
||||
"stackHeight",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionType",
|
||||
"interactionModesCount",
|
||||
"vendingIds",
|
||||
"multiheight",
|
||||
"customparams",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
"clothingOnWalk",
|
||||
] as const;
|
||||
|
||||
const updateSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const updateItemsBase = adminAction(
|
||||
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||
async (ctx) => {
|
||||
const { id, fields } = ctx.data;
|
||||
const safe = pickAllowed(fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
throw new ActionError("No valid fields to update");
|
||||
}
|
||||
|
||||
const existing = await prisma.itemsBase.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new ActionError("Item not found");
|
||||
|
||||
// Coerce common numeric / decimal fields from form strings.
|
||||
const data: Record<string, unknown> = { ...safe };
|
||||
for (const key of [
|
||||
"spriteId",
|
||||
"width",
|
||||
"length",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionModesCount",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
]) {
|
||||
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||
}
|
||||
if (data.stackHeight !== undefined) {
|
||||
data.stackHeight = Number(data.stackHeight);
|
||||
}
|
||||
|
||||
await prisma.itemsBase.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
});
|
||||
await rcon.updateCatalog().catch(() => false);
|
||||
await logStaffActivity({
|
||||
staffId: Number(ctx.session.user.id),
|
||||
action: "items_base_update",
|
||||
description: `Updated items_base #${id}`,
|
||||
targetType: "items_base",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/items");
|
||||
revalidatePath(`/admin/items/${id}`);
|
||||
revalidatePath("/admin/catalog");
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
+160
-40
@@ -1,9 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AclModelPermission,
|
||||
AclModelRole,
|
||||
AclPermission,
|
||||
AclRole,
|
||||
db,
|
||||
User,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import {
|
||||
@@ -22,16 +31,15 @@ const createRankSchema = z.object({
|
||||
export const createRank = adminAction(
|
||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const id = await createEmulatorRank(prisma, ctx.data);
|
||||
await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${id}` },
|
||||
create: {
|
||||
const id = await createEmulatorRank(db, ctx.data);
|
||||
await db
|
||||
.insert(AclRole)
|
||||
.values({
|
||||
slug: `rank_${id}`,
|
||||
title: ctx.data.rank_name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
},
|
||||
update: { title: ctx.data.rank_name },
|
||||
});
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_create",
|
||||
@@ -50,21 +58,32 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||
const [userCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, ctx.data.id));
|
||||
const users = userCount?.total ?? 0;
|
||||
if (users > 0)
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
});
|
||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||
.limit(1);
|
||||
await deleteEmulatorRank(db, ctx.data.id);
|
||||
if (role) {
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({
|
||||
where: { modelId: role.id, modelType: "Role" },
|
||||
}),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
||||
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
@@ -87,12 +106,12 @@ const saveRankSchema = z.object({
|
||||
export const saveRank = adminAction(
|
||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
|
||||
if (typeof ctx.data.fields.rank_name === "string") {
|
||||
await prisma.aclRole.updateMany({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
data: { title: ctx.data.fields.rank_name },
|
||||
});
|
||||
await db
|
||||
.update(AclRole)
|
||||
.set({ title: ctx.data.fields.rank_name })
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
@@ -115,27 +134,33 @@ const setCmsPermsSchema = z.object({
|
||||
export const setCmsPermissions = adminAction(
|
||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const role = await prisma.aclRole.findUnique({
|
||||
where: { id: ctx.data.roleId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id, slug: AclRole.slug })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.id, ctx.data.roleId))
|
||||
.limit(1);
|
||||
if (!role) throw new ActionError("Role not found");
|
||||
const permissions = await prisma.aclPermission.findMany({
|
||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.aclModelPermission.deleteMany({
|
||||
where: { modelId: role.id, modelType: "Role" },
|
||||
});
|
||||
const permissions = await db
|
||||
.select({ id: AclPermission.id })
|
||||
.from(AclPermission)
|
||||
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
if (permissions.length) {
|
||||
await tx.aclModelPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
await tx.insert(AclModelPermission).values(
|
||||
permissions.map((permission) => ({
|
||||
modelId: role.id,
|
||||
modelType: "Role",
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
});
|
||||
);
|
||||
}
|
||||
});
|
||||
await logStaffActivity({
|
||||
@@ -149,3 +174,98 @@ export const setCmsPermissions = adminAction(
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-apply the same grant repair as migration 0018:
|
||||
* - ranks with admin.dashboard get all admin.*
|
||||
* - ranks >= 6 get admin.*.view + dashboard
|
||||
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
|
||||
*/
|
||||
export const repairAdminNavAclGrants = adminAction(
|
||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const [dashboardFillResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`acl_roles\` ar
|
||||
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND apdash.slug = 'admin.dashboard'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp2
|
||||
WHERE amp2.model_type = 'Role'
|
||||
AND amp2.model_id = ar.id
|
||||
AND amp2.permission_id = ap.id
|
||||
)
|
||||
`);
|
||||
|
||||
const [midRankViewsResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`permission_ranks\` pr
|
||||
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN \`acl_permissions\` ap ON (
|
||||
ap.slug = 'admin.dashboard'
|
||||
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
|
||||
)
|
||||
WHERE pr.id >= 6
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
)
|
||||
`);
|
||||
|
||||
const [highRankToolsResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`permission_ranks\` pr
|
||||
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN \`acl_permissions\` ap ON (
|
||||
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
|
||||
OR ap.slug IN (
|
||||
'admin.permissions.manage',
|
||||
'admin.rcon.execute',
|
||||
'admin.assets.import',
|
||||
'admin.export',
|
||||
'admin.analytics.export',
|
||||
'admin.users.ban',
|
||||
'admin.users.reset_password',
|
||||
'admin.room.delete'
|
||||
)
|
||||
)
|
||||
WHERE pr.id >= 7
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
)
|
||||
`);
|
||||
|
||||
const inserted =
|
||||
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
|
||||
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
|
||||
Number((highRankToolsResult as ResultSetHeader).affectedRows);
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "acl_nav_grants_repair",
|
||||
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
|
||||
targetType: "acl",
|
||||
targetId: 0,
|
||||
});
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk({ inserted });
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -17,7 +16,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
};
|
||||
await prisma.items.update({
|
||||
where: { id: itemId },
|
||||
data: data as Prisma.ItemsUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -111,7 +110,7 @@ export async function updateRoom({
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await prisma.rooms.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoomsUpdateInput,
|
||||
data: data as any,
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
|
||||
describe("setTradeLock drizzle + RCON contract", () => {
|
||||
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
|
||||
|
||||
it("writes sanctions + users_settings via Drizzle, not prisma facade", () => {
|
||||
expect(src).toContain("@/lib/db");
|
||||
expect(src).toContain("UsersSettings");
|
||||
expect(src).toContain("Sanctions");
|
||||
expect(src).toContain("canTrade");
|
||||
expect(src).toContain("tradeLockedUntil");
|
||||
expect(src).toMatch(/export async function setTradeLock/);
|
||||
const fn = src.slice(src.indexOf("export async function setTradeLock"));
|
||||
expect(fn).not.toContain("prisma.sanctions");
|
||||
});
|
||||
|
||||
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
|
||||
expect(rconSrc).toContain("settradelock");
|
||||
expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)");
|
||||
expect(src).toContain("rcon.setTradeLock");
|
||||
expect(src).toContain("rcon.alertUser");
|
||||
expect(src).toContain("rcon.disconnectUser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin-photos drizzle contract", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
|
||||
it("deletes via Drizzle CameraWeb and attempts local file purge", () => {
|
||||
expect(src).toContain("@/lib/db");
|
||||
expect(src).toContain("CameraWeb");
|
||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(src).not.toContain("@/lib/prisma");
|
||||
expect(src).toContain('revalidatePath("/photos")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryRemoveLocalPhotoFile", () => {
|
||||
it("rejects path traversal and remote CDN urls", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||
});
|
||||
});
|
||||
+1
-2
@@ -2,7 +2,6 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -116,7 +115,7 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
} else {
|
||||
const badgeCodes = parseBadgeCodes(article.badges);
|
||||
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (price > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -81,10 +81,14 @@ export const createUser = adminAction(
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
const target = (err.meta?.target as string[]) ?? [];
|
||||
const target =
|
||||
((err as { meta?: { target?: string[] } }).meta
|
||||
?.target as string[]) ?? [];
|
||||
if (target.includes("username"))
|
||||
throw new ActionError("Username already taken");
|
||||
if (target.includes("mail"))
|
||||
@@ -116,6 +120,7 @@ export const updateUser = adminAction(
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id }, data: userData });
|
||||
invalidateLoginCache(targetUser.username);
|
||||
|
||||
if (diamonds !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
@@ -310,6 +315,7 @@ export const resetPassword = adminAction(
|
||||
where: { id: ctx.data.userId },
|
||||
data: { password: hashed },
|
||||
});
|
||||
invalidateLoginCache(target.username);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
|
||||
@@ -107,6 +107,17 @@ export default async function HelpTicketsPage({
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" name="banAppeal" value="1" />
|
||||
{t("banAppealLabel")}
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("submit")}
|
||||
|
||||
@@ -8,8 +8,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EditAdDeleteButton } from "../ads-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditAd({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -8,8 +8,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AdsTable } from "./ads-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminAds() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type DayRow = {
|
||||
label: string;
|
||||
@@ -192,7 +193,9 @@ export default async function ActivityPage() {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("pages.admin.analytics.activity");
|
||||
const tRoot = await getTranslations("pages.admin.analytics");
|
||||
const data = await getActivityData(locale);
|
||||
const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () =>
|
||||
getActivityData(locale),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type EconomyData = {
|
||||
totalCredits: number;
|
||||
@@ -172,7 +173,11 @@ export default async function EconomyPage() {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("pages.admin.analytics.economy");
|
||||
const tRoot = await getTranslations("pages.admin.analytics");
|
||||
const { data, degraded } = await getEconomyData(locale);
|
||||
const { data, degraded } = await redisCache(
|
||||
`admin:analytics:economy:${locale}`,
|
||||
120,
|
||||
() => getEconomyData(locale),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type AnalyticsData = {
|
||||
totalUsers: number;
|
||||
@@ -202,7 +203,11 @@ export default async function AnalyticsPage() {
|
||||
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("pages.admin.analytics");
|
||||
const { data, degraded } = await getAnalyticsData(locale);
|
||||
const { data, degraded } = await redisCache(
|
||||
`admin:analytics:overview:${locale}`,
|
||||
120,
|
||||
() => getAnalyticsData(locale),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -8,8 +8,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type ApplicationRow = {
|
||||
id: bigint;
|
||||
userId: number;
|
||||
|
||||
@@ -8,8 +8,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditArticle({
|
||||
params,
|
||||
searchParams,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ArticleCard } from "@/components/admin/article-card";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,8 +10,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminArticles({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -56,17 +56,18 @@ export default async function AdminArticles({
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<AdminPageShell
|
||||
icon={Newspaper}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
actions={
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -83,7 +84,7 @@ export default async function AdminArticles({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<section className="mt-2">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
@@ -102,6 +103,6 @@ export default async function AdminArticles({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = { title: "Badges" };
|
||||
|
||||
export default async function AdminBadges({
|
||||
|
||||
+106
-104
@@ -1,6 +1,8 @@
|
||||
import { Ban } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||
@@ -8,8 +10,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminBans() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
|
||||
@@ -33,112 +33,114 @@ export default async function AdminBans() {
|
||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||
{t("newBan")}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
<AdminPageShell icon={Ban} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("hoursPlaceholder")}
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("ban")}
|
||||
</Button>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(new Date(b.banExpire * 1000))}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<Button type="submit" variant="outline">
|
||||
{t("lift")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||
{t("newBan")}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("hoursPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("ban")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(new Date(b.banExpire * 1000))}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<Button type="submit" variant="outline">
|
||||
{t("lift")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Campaign = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -6,8 +6,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Campaign = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -8,8 +8,6 @@ import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CatalogEditPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Store } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma, prisma } from "@/lib/prisma";
|
||||
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
|
||||
import { CatalogActions } from "./catalog-actions";
|
||||
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
|
||||
@@ -23,6 +25,7 @@ export default async function CatalogPagesPage({
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
const t = await getTranslations("pages.admin.catalog");
|
||||
|
||||
const params = await searchParams;
|
||||
const sp = new URLSearchParams(params);
|
||||
@@ -200,50 +203,52 @@ export default async function CatalogPagesPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
||||
{totalBcItems} items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="bc"
|
||||
canEdit={canEdit}
|
||||
totals={{
|
||||
totalPages: totalBcPages,
|
||||
enabledPages: enabledBcPages,
|
||||
totalItems: totalBcItems,
|
||||
}}
|
||||
initialRoots={bcRootPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
iconImage: p.iconImage,
|
||||
enabled: p.enabled,
|
||||
orderNum: 0,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<CatalogActions
|
||||
catalogType="bc"
|
||||
initialParentId={sp.get("newPageParent")}
|
||||
/>
|
||||
)}
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
||||
{totalBcItems} items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="bc"
|
||||
canEdit={canEdit}
|
||||
totals={{
|
||||
totalPages: totalBcPages,
|
||||
enabledPages: enabledBcPages,
|
||||
totalItems: totalBcItems,
|
||||
}}
|
||||
initialRoots={bcRootPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
iconImage: p.iconImage,
|
||||
enabled: p.enabled,
|
||||
orderNum: 0,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<CatalogActions
|
||||
catalogType="bc"
|
||||
initialParentId={sp.get("newPageParent")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "tree" ? (
|
||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
||||
) : (
|
||||
<CatalogTable
|
||||
data={{ rows: bcRows, ...bcPagination }}
|
||||
canEdit={canEdit}
|
||||
catalogType="bc"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "tree" ? (
|
||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
||||
) : (
|
||||
<CatalogTable
|
||||
data={{ rows: bcRows, ...bcPagination }}
|
||||
canEdit={canEdit}
|
||||
catalogType="bc"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,43 +401,45 @@ export default async function CatalogPagesPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
||||
items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="normal"
|
||||
canEdit={canEdit}
|
||||
totals={{
|
||||
totalPages,
|
||||
enabledPages,
|
||||
totalItems,
|
||||
}}
|
||||
initialRoots={rootPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
iconImage: p.iconImage,
|
||||
enabled: p.enabled,
|
||||
orderNum: 0,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
||||
)}
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
||||
items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="normal"
|
||||
canEdit={canEdit}
|
||||
totals={{
|
||||
totalPages,
|
||||
enabledPages,
|
||||
totalItems,
|
||||
}}
|
||||
initialRoots={rootPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
iconImage: p.iconImage,
|
||||
enabled: p.enabled,
|
||||
orderNum: 0,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
||||
) : (
|
||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "tree" ? (
|
||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
||||
) : (
|
||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||
)}
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
import os from "node:os";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import {
|
||||
DiagnosticRow,
|
||||
InfoItem,
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function decodeStacktrace(raw: unknown): string {
|
||||
if (raw == null) return "";
|
||||
try {
|
||||
@@ -45,32 +46,28 @@ export default async function CommandoCentrum() {
|
||||
|
||||
const t = await getTranslations("pages.admin.commandocentrum");
|
||||
|
||||
const [
|
||||
onlineCount,
|
||||
totalUsers,
|
||||
errors,
|
||||
dbOk,
|
||||
emulatorOnline,
|
||||
maintenance,
|
||||
activity,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
||||
prisma.user.count().catch(() => -1),
|
||||
prisma.emulatorErrors
|
||||
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
||||
.catch(() => []),
|
||||
prisma.websiteSetting
|
||||
.count()
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
siteSettings.getBool("maintenance_enabled", false),
|
||||
prisma.staffActivities
|
||||
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
||||
.catch(
|
||||
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
||||
),
|
||||
]);
|
||||
const [totalUsers, errors, health, maintenance, activity, onlineRoster] =
|
||||
await Promise.all([
|
||||
prisma.user.count().catch(() => -1),
|
||||
prisma.emulatorErrors
|
||||
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
||||
.catch(() => []),
|
||||
fetchOpsHealth(),
|
||||
siteSettings.getBool("maintenance_enabled", false),
|
||||
prisma.staffActivities
|
||||
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
||||
.catch(
|
||||
() =>
|
||||
[] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
||||
),
|
||||
fetchOpsOnlineUsers(40),
|
||||
]);
|
||||
|
||||
const dbOk = health.dbOk;
|
||||
const redisOk = health.redisOk;
|
||||
const emulatorOnline = health.emulatorOk;
|
||||
const onlineCount = onlineRoster.count;
|
||||
const onlineUsers = onlineRoster.users;
|
||||
|
||||
const staffIds = Array.from(
|
||||
new Set(activity.map((a) => Number(a.userId))),
|
||||
@@ -91,205 +88,236 @@ export default async function CommandoCentrum() {
|
||||
const rconSet = !!process.env.RCON_HOST;
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* ── Live status ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||
style={{ marginTop: "1.1rem" }}
|
||||
>
|
||||
<StatusCard
|
||||
label={t("playersOnline")}
|
||||
value={onlineCount}
|
||||
hint={
|
||||
totalUsers >= 0
|
||||
? t("totalAccounts", { count: totalUsers })
|
||||
: t("dbUnavailable")
|
||||
}
|
||||
state="neutral"
|
||||
icon="👤"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("emulator")}
|
||||
value={emulatorOnline ? t("online") : t("offline")}
|
||||
hint={emulatorOnline ? t("rconReachable") : t("rconNotResponding")}
|
||||
state={emulatorOnline ? "ok" : "danger"}
|
||||
icon="🎮"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("database")}
|
||||
value={dbOk ? t("connected") : t("down")}
|
||||
hint={dbOk ? t("queriesResponding") : t("cannotReachDb")}
|
||||
state={dbOk ? "ok" : "danger"}
|
||||
icon="🗄️"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("emulatorErrors")}
|
||||
value={errors.length}
|
||||
hint={t("last50Logged")}
|
||||
state={errors.length === 0 ? "ok" : "warn"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
</div>
|
||||
<AdminPageShell icon={Terminal} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
{/* ── Live status ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||
style={{ marginTop: "1.1rem" }}
|
||||
>
|
||||
<StatusCard
|
||||
label={t("playersOnline")}
|
||||
value={onlineCount}
|
||||
hint={
|
||||
totalUsers >= 0
|
||||
? t("totalAccounts", { count: totalUsers })
|
||||
: t("dbUnavailable")
|
||||
}
|
||||
state="neutral"
|
||||
icon="👤"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("emulator")}
|
||||
value={emulatorOnline ? t("online") : t("offline")}
|
||||
hint={emulatorOnline ? t("rconReachable") : t("rconNotResponding")}
|
||||
state={emulatorOnline ? "ok" : "danger"}
|
||||
icon="🎮"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("database")}
|
||||
value={dbOk ? t("connected") : t("down")}
|
||||
hint={dbOk ? t("queriesResponding") : t("cannotReachDb")}
|
||||
state={dbOk ? "ok" : "danger"}
|
||||
icon="🗄️"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("emulatorErrors")}
|
||||
value={errors.length}
|
||||
hint={t("last50Logged")}
|
||||
state={errors.length === 0 ? "ok" : "warn"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Online users widget ───────────────────────────────── */}
|
||||
<section className="admin-card mb-6">
|
||||
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
||||
<OnlineUsersWidget users={[]} isLoading={false} />
|
||||
</section>
|
||||
|
||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="admin-section-title">{t("serverInfo")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
||||
<InfoItem label={t("node")} value={process.version} />
|
||||
<InfoItem
|
||||
label={t("platform")}
|
||||
value={`${process.platform} ${process.arch}`}
|
||||
/>
|
||||
<InfoItem label={t("appUptime")} value={uptime(process.uptime())} />
|
||||
<InfoItem
|
||||
label={t("heapUsed")}
|
||||
value={mb(process.memoryUsage().heapUsed)}
|
||||
/>
|
||||
<InfoItem label={t("rss")} value={mb(process.memoryUsage().rss)} />
|
||||
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
|
||||
<InfoItem
|
||||
label={t("load1m")}
|
||||
value={os.loadavg()[0]?.toFixed(2) ?? "—"}
|
||||
/>
|
||||
<InfoItem label={t("cpus")} value={os.cpus().length} />
|
||||
</div>
|
||||
{/* ── Online users widget ───────────────────────────────── */}
|
||||
<section id="online" className="admin-card mb-6 scroll-mt-4">
|
||||
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
||||
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
|
||||
</section>
|
||||
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="admin-section-title">{t("systemDiagnostics")}</h2>
|
||||
<div className="flex flex-col">
|
||||
<DiagnosticRow
|
||||
label={t("database")}
|
||||
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
|
||||
state={dbOk ? "ok" : "danger"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("emulatorRcon")}
|
||||
detail={emulatorOnline ? t("respondingToPing") : t("noResponse")}
|
||||
state={emulatorOnline ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("rconConfig")}
|
||||
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
|
||||
state={rconSet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("twoFaEncryptionKey")}
|
||||
detail={appKeySet ? t("appKeyConfigured") : t("appKeyMissing")}
|
||||
state={appKeySet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("emailSmtp")}
|
||||
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
|
||||
state={smtpSet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("maintenanceMode")}
|
||||
detail={maintenance ? t("siteIsInMaintenance") : t("siteIsLive")}
|
||||
state={maintenance ? "warn" : "ok"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="admin-section-title">{t("serverInfo")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
||||
<InfoItem label={t("node")} value={process.version} />
|
||||
<InfoItem
|
||||
label={t("platform")}
|
||||
value={`${process.platform} ${process.arch}`}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("appUptime")}
|
||||
value={uptime(process.uptime())}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("heapUsed")}
|
||||
value={mb(process.memoryUsage().heapUsed)}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("rss")}
|
||||
value={mb(process.memoryUsage().rss)}
|
||||
/>
|
||||
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
|
||||
<InfoItem
|
||||
label={t("load1m")}
|
||||
value={os.loadavg()[0]?.toFixed(2) ?? "—"}
|
||||
/>
|
||||
<InfoItem label={t("cpus")} value={os.cpus().length} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CommandocentrumControls />
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="admin-section-title">{t("systemDiagnostics")}</h2>
|
||||
<div className="flex flex-col">
|
||||
<DiagnosticRow
|
||||
label={t("database")}
|
||||
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
|
||||
state={dbOk ? "ok" : "danger"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label="Redis"
|
||||
detail={
|
||||
redisOk === true
|
||||
? "PONG"
|
||||
: redisOk === false
|
||||
? "Unreachable"
|
||||
: "REDIS_URL not configured"
|
||||
}
|
||||
state={
|
||||
redisOk === true
|
||||
? "ok"
|
||||
: redisOk === false
|
||||
? "danger"
|
||||
: "warn"
|
||||
}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("emulatorRcon")}
|
||||
detail={
|
||||
emulatorOnline ? t("respondingToPing") : t("noResponse")
|
||||
}
|
||||
state={emulatorOnline ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("rconConfig")}
|
||||
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
|
||||
state={rconSet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("twoFaEncryptionKey")}
|
||||
detail={appKeySet ? t("appKeyConfigured") : t("appKeyMissing")}
|
||||
state={appKeySet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("emailSmtp")}
|
||||
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
|
||||
state={smtpSet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("maintenanceMode")}
|
||||
detail={
|
||||
maintenance ? t("siteIsInMaintenance") : t("siteIsLive")
|
||||
}
|
||||
state={maintenance ? "warn" : "ok"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
|
||||
{activity.length === 0 ? (
|
||||
<div className="admin-empty">{t("noStaffActivity")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colStaff")}</th>
|
||||
<th>{t("colAction")}</th>
|
||||
<th>{t("colDescription")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activity.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(a.createdAt, "datetime-seconds")}
|
||||
</td>
|
||||
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{a.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{a.description}
|
||||
</td>
|
||||
<CommandocentrumControls />
|
||||
|
||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
|
||||
{activity.length === 0 ? (
|
||||
<div className="admin-empty">{t("noStaffActivity")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colStaff")}</th>
|
||||
<th>{t("colAction")}</th>
|
||||
<th>{t("colDescription")}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Emulator errors ────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
|
||||
{errors.length === 0 ? (
|
||||
<div className="admin-empty">{t("noEmulatorErrors")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colVersion")}</th>
|
||||
<th>{t("colStacktrace")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.map((e) => {
|
||||
const trace = decodeStacktrace(e.stacktrace);
|
||||
const snippet = trace
|
||||
? trace.split("\n").slice(0, 20).join("\n") +
|
||||
(trace.split("\n").length > 20
|
||||
? `\n… ${t("truncated")}`
|
||||
: "")
|
||||
: t("empty");
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activity.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(
|
||||
e.timestamp ? new Date(e.timestamp * 1000) : null,
|
||||
"datetime-seconds",
|
||||
)}
|
||||
</td>
|
||||
<td>{e.type}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{e.version}
|
||||
{formatDate(a.createdAt, "datetime-seconds")}
|
||||
</td>
|
||||
<td>
|
||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||
{snippet}
|
||||
</pre>
|
||||
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
|
||||
</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{a.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{a.description}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Emulator errors ────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
|
||||
{errors.length === 0 ? (
|
||||
<div className="admin-empty">{t("noEmulatorErrors")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colVersion")}</th>
|
||||
<th>{t("colStacktrace")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.map((e) => {
|
||||
const trace = decodeStacktrace(e.stacktrace);
|
||||
const snippet = trace
|
||||
? trace.split("\n").slice(0, 20).join("\n") +
|
||||
(trace.split("\n").length > 20
|
||||
? `\n… ${t("truncated")}`
|
||||
: "")
|
||||
: t("empty");
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(
|
||||
e.timestamp ? new Date(e.timestamp * 1000) : null,
|
||||
"datetime-seconds",
|
||||
)}
|
||||
</td>
|
||||
<td>{e.type}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{e.version}
|
||||
</td>
|
||||
<td>
|
||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||
{snippet}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { HealthCheckClient } from "./health-check-client";
|
||||
@@ -11,42 +13,28 @@ async function getDevOpsData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const dayAgo = now - 86400;
|
||||
|
||||
// DB health — simple count query
|
||||
let dbStatus: "healthy" | "error" = "healthy";
|
||||
let dbLatency = 0;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.user.count({ take: 1 });
|
||||
dbLatency = Date.now() - start;
|
||||
} catch {
|
||||
dbStatus = "error";
|
||||
}
|
||||
|
||||
// Recent emulator errors
|
||||
const recentErrors = await prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const errorsToday = await prisma.emulatorErrors.count({
|
||||
where: { timestamp: { gte: dayAgo } },
|
||||
});
|
||||
|
||||
// Emulator settings (version info)
|
||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
||||
where: { key: "version" },
|
||||
});
|
||||
|
||||
// Online user count as proxy for emulator health
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
const [health, recentErrors, errorsToday, emuVersion] = await Promise.all([
|
||||
fetchOpsHealth(),
|
||||
prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
prisma.emulatorErrors.count({
|
||||
where: { timestamp: { gte: dayAgo } },
|
||||
}),
|
||||
prisma.emulatorSettings.findFirst({
|
||||
where: { key: "version" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
dbStatus,
|
||||
dbLatency,
|
||||
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
|
||||
dbLatency: health.dbLatencyMs,
|
||||
redisOk: health.redisOk,
|
||||
recentErrors,
|
||||
errorsToday,
|
||||
emuVersion: emuVersion?.value ?? "Unknown",
|
||||
onlineUsers,
|
||||
onlineUsers: health.onlineUsers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +50,7 @@ export default async function DevOpsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
||||
@@ -80,6 +68,33 @@ export default async function DevOpsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Redis</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant={
|
||||
data.redisOk === false
|
||||
? "destructive"
|
||||
: data.redisOk === true
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{data.redisOk === true
|
||||
? "Healthy"
|
||||
: data.redisOk === false
|
||||
? "Error"
|
||||
: "Not configured"}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Cache / rate limits / sessions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
||||
@@ -100,8 +115,13 @@ export default async function DevOpsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current active connections
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<Link
|
||||
href="/admin/commandocentrum#online"
|
||||
className="text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
||||
>
|
||||
Live roster in CommandoCentrum
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -9,8 +9,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TEXTS_LIMIT = 300;
|
||||
|
||||
export default async function AdminEmulator() {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { count, desc, eq, like } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { EventsTable } from "./events-table";
|
||||
|
||||
@@ -16,29 +18,44 @@ export default async function EventsPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.EVENTS_VIEW,
|
||||
model: "websiteEvent",
|
||||
searchFields: ["title"],
|
||||
defaultSort: { startsAt: "desc" },
|
||||
include: { type: { select: { name: true, color: true } } },
|
||||
mapRow: (e: {
|
||||
id: number;
|
||||
title: string;
|
||||
type: { name: string; color: string };
|
||||
startsAt: Date;
|
||||
endsAt: Date | null;
|
||||
status: string;
|
||||
hostUserId: number;
|
||||
_count?: { registrations: number };
|
||||
}) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
typeName: e.type.name,
|
||||
typeColor: e.type.color,
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status,
|
||||
hostUserId: e.hostUserId,
|
||||
}),
|
||||
fetch: async ({ search, page, perPage }) => {
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
status: WebsiteEvent.status,
|
||||
hostUserId: WebsiteEvent.hostUserId,
|
||||
typeName: WebsiteEventType.name,
|
||||
typeColor: WebsiteEventType.color,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.leftJoin(
|
||||
WebsiteEventType,
|
||||
eq(WebsiteEvent.typeId, WebsiteEventType.id),
|
||||
)
|
||||
.where(search ? like(WebsiteEvent.title, `%${search}%`) : undefined)
|
||||
.orderBy(desc(WebsiteEvent.startsAt))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(WebsiteEvent),
|
||||
]);
|
||||
return {
|
||||
rows: rows.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
typeName: e.typeName ?? "",
|
||||
typeColor: e.typeColor ?? "",
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status,
|
||||
hostUserId: e.hostUserId,
|
||||
})),
|
||||
total: totals[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
|
||||
@@ -7,8 +7,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminGuildDetailPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
+101
-99
@@ -1,6 +1,8 @@
|
||||
import { Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,8 +11,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminGuildsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -104,103 +104,105 @@ export default async function AdminGuildsPage({
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
{loadError ? (
|
||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||
{t("loadError")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mb-4 flex gap-2" method="get">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
defaultValue={q}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="admin-input flex-1"
|
||||
/>
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
{t("search")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("totalGuilds")} value={total} icon="🛡️" />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="admin-section-title">
|
||||
{t("title")} ({total})
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-empty">{t("empty")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("colBadge")}</th>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colOwner")}</th>
|
||||
<th>{t("colMembers")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td className="muted">{g.id}</td>
|
||||
<td>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/admin/guilds/${g.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{g.name || `#${g.id}`}
|
||||
</Link>
|
||||
{g.description ? (
|
||||
<div className="muted text-xs line-clamp-1 max-w-xs">
|
||||
{g.description}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/show/${g.userId}`}>
|
||||
{ownerById.get(g.userId) ?? `#${g.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(g.dateCreated * 1000), "date")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPageShell icon={Shield} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
{loadError ? (
|
||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||
{t("loadError")}
|
||||
</div>
|
||||
)}
|
||||
<AdminSimplePager
|
||||
page={pagination.page}
|
||||
lastPage={pagination.lastPage}
|
||||
total={total}
|
||||
basePath="/admin/guilds"
|
||||
params={q ? { search: q } : undefined}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
) : null}
|
||||
|
||||
<form className="mb-4 flex gap-2" method="get">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
defaultValue={q}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="admin-input flex-1"
|
||||
/>
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
{t("search")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("totalGuilds")} value={total} icon="🛡️" />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="admin-section-title">
|
||||
{t("listHeading", { count: total })}
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-empty">{t("empty")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("colBadge")}</th>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colOwner")}</th>
|
||||
<th>{t("colMembers")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td className="muted">{g.id}</td>
|
||||
<td>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/admin/guilds/${g.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{g.name || `#${g.id}`}
|
||||
</Link>
|
||||
{g.description ? (
|
||||
<div className="muted text-xs line-clamp-1 max-w-xs">
|
||||
{g.description}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/show/${g.userId}`}>
|
||||
{ownerById.get(g.userId) ?? `#${g.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(g.dateCreated * 1000), "date")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<AdminSimplePager
|
||||
page={pagination.page}
|
||||
lastPage={pagination.lastPage}
|
||||
total={total}
|
||||
basePath="/admin/guilds"
|
||||
params={q ? { search: q } : undefined}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditHelpQuestion({
|
||||
params,
|
||||
searchParams,
|
||||
|
||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminHelpQuestions() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
closeHelpCenterTicket,
|
||||
liftBanFromHelpTicket,
|
||||
reopenHelpCenterTicket,
|
||||
replyHelpCenterTicket,
|
||||
} from "@/actions/admin-help-tickets";
|
||||
@@ -30,6 +31,7 @@ interface HelpTicketInfo {
|
||||
open: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName?: string | null;
|
||||
creator: {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -42,10 +44,20 @@ export function AdminHelpTicketDetail({
|
||||
ticket,
|
||||
messages: initialMessages,
|
||||
canEdit,
|
||||
canLiftBan = false,
|
||||
hasActiveBan = false,
|
||||
listHref = "/admin/help-tickets",
|
||||
hideRequesterContact = false,
|
||||
}: {
|
||||
ticket: HelpTicketInfo;
|
||||
messages: ThreadMessage[];
|
||||
canEdit: boolean;
|
||||
/** Staff may lift requester bans (USERS_BAN). */
|
||||
canLiftBan?: boolean;
|
||||
hasActiveBan?: boolean;
|
||||
listHref?: string;
|
||||
/** Hide email/IP-style contact for mod-lite surfaces. */
|
||||
hideRequesterContact?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.helpTickets");
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
@@ -89,10 +101,17 @@ export function AdminHelpTicketDetail({
|
||||
});
|
||||
}
|
||||
|
||||
function handleLiftBan() {
|
||||
if (!ticket.creator || isPending) return;
|
||||
run(() => liftBanFromHelpTicket({ ticketId: ticket.id }), {
|
||||
successMessage: t("success.banLifted"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/admin/help-tickets">
|
||||
<Link href={listHref}>
|
||||
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -105,6 +124,12 @@ export function AdminHelpTicketDetail({
|
||||
<Badge variant={ticket.open ? "default" : "secondary"}>
|
||||
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
||||
</Badge>
|
||||
{ticket.categoryName ? (
|
||||
<Badge variant="outline">{ticket.categoryName}</Badge>
|
||||
) : null}
|
||||
{hasActiveBan ? (
|
||||
<Badge variant="destructive">{t("requesterBanned")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{ticket.title}</h1>
|
||||
</div>
|
||||
@@ -203,13 +228,24 @@ export function AdminHelpTicketDetail({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{canEdit && (
|
||||
{(canEdit || (canLiftBan && hasActiveBan)) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ticket.open ? (
|
||||
{canLiftBan && hasActiveBan && ticket.creator ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
onClick={handleLiftBan}
|
||||
>
|
||||
{t("liftBanSubmit")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit && ticket.open ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -219,7 +255,8 @@ export function AdminHelpTicketDetail({
|
||||
>
|
||||
{t("closeSubmit")}
|
||||
</Button>
|
||||
) : (
|
||||
) : null}
|
||||
{canEdit && !ticket.open ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -229,7 +266,7 @@ export function AdminHelpTicketDetail({
|
||||
>
|
||||
{t("reopenSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -241,7 +278,11 @@ export function AdminHelpTicketDetail({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link
|
||||
href={`/admin/users/show/${ticket.creator.id}`}
|
||||
href={
|
||||
hideRequesterContact
|
||||
? `/u/${encodeURIComponent(ticket.creator.username)}`
|
||||
: `/admin/users/show/${ticket.creator.id}`
|
||||
}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||
>
|
||||
<User className="h-3.5 w-3.5" />
|
||||
@@ -250,7 +291,7 @@ export function AdminHelpTicketDetail({
|
||||
{t("rankLabel", { rank: ticket.creator.rank })}
|
||||
</Badge>
|
||||
</Link>
|
||||
{ticket.creator.mail ? (
|
||||
{!hideRequesterContact && ticket.creator.mail ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{ticket.creator.mail}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { activeBanWhere } from "@/lib/bans";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STAFF_RANK_THRESHOLD = 4;
|
||||
|
||||
export default async function AdminHelpTicketDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -18,6 +18,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.helpTickets");
|
||||
const { id } = await params;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) notFound();
|
||||
@@ -27,6 +28,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
categoryId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
open: true,
|
||||
@@ -55,9 +57,9 @@ export default async function AdminHelpTicketDetailPage({
|
||||
]),
|
||||
];
|
||||
|
||||
const users =
|
||||
const [users, category, activeBan] = await Promise.all([
|
||||
authorIds.length > 0
|
||||
? await prisma.user.findMany({
|
||||
? prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -66,10 +68,24 @@ export default async function AdminHelpTicketDetailPage({
|
||||
mail: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
: Promise.resolve([]),
|
||||
ticket.categoryId != null
|
||||
? prisma.websiteHelpCenterCategories.findUnique({
|
||||
where: { id: ticket.categoryId },
|
||||
select: { name: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ticket.userId != null
|
||||
? prisma.ban.findFirst({
|
||||
where: { userId: ticket.userId, ...activeBanWhere() },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
const openerId = ticket.userId;
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
|
||||
const messages = [
|
||||
{
|
||||
@@ -86,7 +102,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
...replies.map((r) => {
|
||||
const user = userById.get(r.userId);
|
||||
const isStaff =
|
||||
r.userId !== openerId && (user?.rank ?? 0) >= STAFF_RANK_THRESHOLD;
|
||||
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
|
||||
return {
|
||||
key: String(r.id),
|
||||
userId: r.userId,
|
||||
@@ -101,26 +117,40 @@ export default async function AdminHelpTicketDetailPage({
|
||||
const creator = openerId != null ? userById.get(openerId) : undefined;
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
const canLiftBan = canAccess(permissions, PERMS.USERS_BAN, session.user.rank);
|
||||
const isBanAppeal =
|
||||
/ban\s*appeal/i.test(ticket.title) ||
|
||||
/ban\s*appeal/i.test(category?.name ?? "");
|
||||
|
||||
return (
|
||||
<AdminHelpTicketDetail
|
||||
ticket={{
|
||||
id: String(ticket.id),
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||
creator: creator
|
||||
? {
|
||||
id: creator.id,
|
||||
username: creator.username,
|
||||
rank: creator.rank,
|
||||
mail: creator.mail ?? "",
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
messages={messages}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
/>
|
||||
<AdminHelpTicketDetail
|
||||
ticket={{
|
||||
id: String(ticket.id),
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||
categoryName: category?.name ?? (isBanAppeal ? "Ban appeal" : null),
|
||||
creator: creator
|
||||
? {
|
||||
id: creator.id,
|
||||
username: creator.username,
|
||||
rank: creator.rank,
|
||||
mail: creator.mail ?? "",
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
messages={messages}
|
||||
canEdit={canEdit}
|
||||
canLiftBan={canLiftBan}
|
||||
hasActiveBan={!!activeBan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,10 @@ export interface HelpTicketRow {
|
||||
|
||||
export function HelpTicketsTable({
|
||||
data,
|
||||
basePath = "/admin/help-tickets",
|
||||
}: {
|
||||
data: PaginatedResult<HelpTicketRow>;
|
||||
basePath?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.helpTickets");
|
||||
|
||||
@@ -36,7 +38,7 @@ export function HelpTicketsTable({
|
||||
#{row.id}
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/help-tickets/${row.id}`}
|
||||
href={`${basePath}/${row.id}`}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminHelpTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -25,7 +24,7 @@ export default async function AdminHelpTicketsPage({
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || "open";
|
||||
|
||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (statusFilter === "open") {
|
||||
conditions.push({ open: true });
|
||||
@@ -56,17 +55,14 @@ export default async function AdminHelpTicketsPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
||||
> = {
|
||||
const SORT_MAP: Record<string, any> = {
|
||||
title: { title: "asc" },
|
||||
open: { open: "desc" },
|
||||
user: { userId: "asc" },
|
||||
@@ -79,9 +75,9 @@ export default async function AdminHelpTicketsPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const [total, openCount, closedCount] = await Promise.all([
|
||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: true } })
|
||||
@@ -89,6 +85,7 @@ export default async function AdminHelpTicketsPage({
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: false } })
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
@@ -160,6 +157,15 @@ export default async function AdminHelpTicketsPage({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.helpOpen,
|
||||
other: queues.cmsOpen,
|
||||
})}
|
||||
/>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||
|
||||
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { AuditSection, ExportSection } from "./client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminHousekeeping({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -12,8 +12,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type IpRow = {
|
||||
id: bigint;
|
||||
ipAddress: string;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { updateItemsBase } from "@/actions/items-base";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export type ItemsBaseEditValues = {
|
||||
id: number;
|
||||
spriteId: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
width: number;
|
||||
length: number;
|
||||
stackHeight: string;
|
||||
allowStack: number;
|
||||
allowSit: number;
|
||||
allowLay: number;
|
||||
allowWalk: number;
|
||||
allowGift: number;
|
||||
allowTrade: number;
|
||||
allowRecycle: number;
|
||||
allowMarketplaceSell: number;
|
||||
allowInventoryStack: number;
|
||||
interactionType: string;
|
||||
interactionModesCount: number;
|
||||
vendingIds: string;
|
||||
multiheight: string;
|
||||
customparams: string;
|
||||
};
|
||||
|
||||
const ALLOW_FLAGS: Array<{ key: keyof ItemsBaseEditValues; label: string }> = [
|
||||
{ key: "allowStack", label: "Stack" },
|
||||
{ key: "allowSit", label: "Sit" },
|
||||
{ key: "allowLay", label: "Lay" },
|
||||
{ key: "allowWalk", label: "Walk" },
|
||||
{ key: "allowGift", label: "Gift" },
|
||||
{ key: "allowTrade", label: "Trade" },
|
||||
{ key: "allowRecycle", label: "Recycle" },
|
||||
{ key: "allowMarketplaceSell", label: "Marketplace" },
|
||||
{ key: "allowInventoryStack", label: "Inv. stack" },
|
||||
];
|
||||
|
||||
export function ItemsBaseEditForm({
|
||||
initial,
|
||||
canEdit,
|
||||
}: {
|
||||
initial: ItemsBaseEditValues;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.items");
|
||||
const router = useRouter();
|
||||
const { run, isPending } = useServerAction();
|
||||
const [form, setForm] = useState(initial);
|
||||
|
||||
function setField<K extends keyof ItemsBaseEditValues>(
|
||||
key: K,
|
||||
value: ItemsBaseEditValues[K],
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!canEdit || isPending) return;
|
||||
const { id, ...fields } = form;
|
||||
run(() => updateItemsBase({ id, fields }), {
|
||||
successMessage: t("saved"),
|
||||
onSuccess: () => router.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground m-0 font-mono">
|
||||
#{form.id}
|
||||
</p>
|
||||
<h2 className="text-xl font-bold m-0">
|
||||
{form.publicName || form.itemName || t("untitled")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/items">{t("back")}</Link>
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("sectionIdentity")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="publicName">{t("publicName")}</Label>
|
||||
<Input
|
||||
id="publicName"
|
||||
value={form.publicName}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("publicName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="itemName">{t("itemName")}</Label>
|
||||
<Input
|
||||
id="itemName"
|
||||
value={form.itemName}
|
||||
disabled={!canEdit}
|
||||
className="font-mono"
|
||||
onChange={(e) => setField("itemName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="type">{t("type")}</Label>
|
||||
<Input
|
||||
id="type"
|
||||
value={form.type}
|
||||
disabled={!canEdit}
|
||||
maxLength={3}
|
||||
onChange={(e) => setField("type", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="spriteId">{t("spriteId")}</Label>
|
||||
<Input
|
||||
id="spriteId"
|
||||
type="number"
|
||||
value={form.spriteId}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("spriteId", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("sectionDims")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="width">{t("width")}</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
value={form.width}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("width", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="length">{t("length")}</Label>
|
||||
<Input
|
||||
id="length"
|
||||
type="number"
|
||||
value={form.length}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("length", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="stackHeight">{t("stackHeight")}</Label>
|
||||
<Input
|
||||
id="stackHeight"
|
||||
value={form.stackHeight}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("stackHeight", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="interactionType">{t("interactionType")}</Label>
|
||||
<Input
|
||||
id="interactionType"
|
||||
value={form.interactionType}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("interactionType", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="interactionModesCount">
|
||||
{t("interactionModes")}
|
||||
</Label>
|
||||
<Input
|
||||
id="interactionModesCount"
|
||||
type="number"
|
||||
value={form.interactionModesCount}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) =>
|
||||
setField("interactionModesCount", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("sectionFlags")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{ALLOW_FLAGS.map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-sm rounded-md border px-2 py-1.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(form[key]) === 1}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField(key, e.target.checked ? 1 : 0)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("sectionExtra")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="vendingIds">{t("vendingIds")}</Label>
|
||||
<Input
|
||||
id="vendingIds"
|
||||
value={form.vendingIds}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("vendingIds", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="multiheight">{t("multiheight")}</Label>
|
||||
<Input
|
||||
id="multiheight"
|
||||
value={form.multiheight}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("multiheight", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="customparams">{t("customparams")}</Label>
|
||||
<Input
|
||||
id="customparams"
|
||||
value={form.customparams}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setField("customparams", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ItemsBaseEditForm } from "./items-base-edit-form";
|
||||
|
||||
export default async function ItemsBaseDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.items");
|
||||
const { id: rawId } = await params;
|
||||
const id = Number(rawId);
|
||||
if (!Number.isFinite(id) || id <= 0) notFound();
|
||||
|
||||
const item = await prisma.itemsBase.findUnique({ where: { id } });
|
||||
if (!item) notFound();
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={Package}
|
||||
title={t("editTitle", { name: item.publicName || item.itemName })}
|
||||
subtitle={t("editSubtitle", { id: item.id })}
|
||||
>
|
||||
<ItemsBaseEditForm
|
||||
canEdit={canEdit}
|
||||
initial={{
|
||||
id: item.id,
|
||||
spriteId: item.spriteId,
|
||||
publicName: item.publicName,
|
||||
itemName: item.itemName,
|
||||
type: item.type,
|
||||
width: item.width,
|
||||
length: item.length,
|
||||
stackHeight: String(item.stackHeight),
|
||||
allowStack: item.allowStack,
|
||||
allowSit: item.allowSit,
|
||||
allowLay: item.allowLay,
|
||||
allowWalk: item.allowWalk,
|
||||
allowGift: item.allowGift,
|
||||
allowTrade: item.allowTrade,
|
||||
allowRecycle: item.allowRecycle,
|
||||
allowMarketplaceSell: item.allowMarketplaceSell,
|
||||
allowInventoryStack: item.allowInventoryStack,
|
||||
interactionType: item.interactionType,
|
||||
interactionModesCount: item.interactionModesCount,
|
||||
vendingIds: item.vendingIds,
|
||||
multiheight: item.multiheight,
|
||||
customparams: item.customparams,
|
||||
}}
|
||||
/>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Package, Pencil } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface ItemsBaseRow {
|
||||
id: number;
|
||||
spriteId: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
interactionType: string;
|
||||
width: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function getColumns(canEdit: boolean): DataTableColumn<ItemsBaseRow>[] {
|
||||
return [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "publicName",
|
||||
label: "Name",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link
|
||||
href={`/admin/items/${row.id}`}
|
||||
className="hover:opacity-80 flex items-center gap-2 min-w-0"
|
||||
>
|
||||
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block truncate">
|
||||
{row.publicName || "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate block">
|
||||
{row.itemName}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "spriteId",
|
||||
label: "Sprite",
|
||||
sortable: true,
|
||||
render: (v) => <span className="tabular-nums text-sm">{String(v)}</span>,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: "Type",
|
||||
sortable: true,
|
||||
filterKey: "filter_type",
|
||||
filterOptions: [
|
||||
{ label: "Floor (s)", value: "s" },
|
||||
{ label: "Wall (i)", value: "i" },
|
||||
{ label: "Effect (e)", value: "e" },
|
||||
{ label: "Badge (b)", value: "b" },
|
||||
{ label: "Robot (r)", value: "r" },
|
||||
],
|
||||
render: (v) => <Badge variant="outline">{String(v)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "interactionType",
|
||||
label: "Interaction",
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{String(v || "default")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "width",
|
||||
label: "Size",
|
||||
render: (_, row) => (
|
||||
<span className="tabular-nums text-sm">
|
||||
{row.width}×{row.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: (_, row) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/items/${row.id}`} aria-label="View">
|
||||
{canEdit ? (
|
||||
<Pencil className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ItemsTable({
|
||||
data,
|
||||
canEdit,
|
||||
}: {
|
||||
data: PaginatedResult<ItemsBaseRow>;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={getColumns(canEdit)}
|
||||
data={data}
|
||||
searchPlaceholder="Search name, classname or ID…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { Package } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
||||
|
||||
export default async function ItemsBasePage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const result = await fetchAdminList<ItemsBaseRow>(
|
||||
{
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
fetch: async ({ search, page, perPage, rawParams }) => {
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
const ors = [
|
||||
like(ItemsBase.itemName, `%${search}%`),
|
||||
like(ItemsBase.publicName, `%${search}%`),
|
||||
];
|
||||
const asId = Number(search);
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(ItemsBase.id, asId));
|
||||
}
|
||||
conditions.push(or(...ors));
|
||||
}
|
||||
if (rawParams.filter_type) {
|
||||
conditions.push(eq(ItemsBase.type, rawParams.filter_type));
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
interactionType: ItemsBase.interactionType,
|
||||
width: ItemsBase.width,
|
||||
length: ItemsBase.length,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(where)
|
||||
.orderBy(desc(ItemsBase.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(ItemsBase).where(where),
|
||||
]);
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
spriteId: r.spriteId,
|
||||
publicName: r.publicName || "",
|
||||
itemName: r.itemName || "",
|
||||
type: r.type || "s",
|
||||
interactionType: r.interactionType || "default",
|
||||
width: r.width,
|
||||
length: r.length,
|
||||
})),
|
||||
total: totals[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
|
||||
const t = await getTranslations("pages.admin.items");
|
||||
const canEdit = canAccess(
|
||||
result.permissions,
|
||||
PERMS.CATALOG_EDIT,
|
||||
result.rank,
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageShell icon={Package} title={t("title")} subtitle={t("subtitle")}>
|
||||
<p className="text-muted-foreground m-0">
|
||||
{t("total", { count: result.total })}
|
||||
</p>
|
||||
<ItemsTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -86,6 +90,10 @@ async function Sidebar({
|
||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
||||
});
|
||||
|
||||
const navConfig = parseAdminNavConfig(
|
||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-admin
|
||||
@@ -115,6 +123,7 @@ async function Sidebar({
|
||||
<AdminSidebarNav
|
||||
allowedPermissions={allowedPermissions}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
navConfig={navConfig}
|
||||
/>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
||||
|
||||
+32
-26
@@ -1,5 +1,7 @@
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
@@ -8,8 +10,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { StaffActivitiesTable } from "./staff-activities-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
|
||||
when: "createdAt",
|
||||
staff: "userId",
|
||||
@@ -97,31 +97,37 @@ export default async function AdminStaffActivities({
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
||||
<StatusCard
|
||||
label={t("onThisPage")}
|
||||
value={activities.length}
|
||||
icon="📄"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("recent")}
|
||||
value={recentCount}
|
||||
state={recentCount > 0 ? "ok" : "neutral"}
|
||||
icon="🟢"
|
||||
<AdminPageShell
|
||||
icon={ClipboardList}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
||||
<StatusCard
|
||||
label={t("onThisPage")}
|
||||
value={activities.length}
|
||||
icon="📄"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("recent")}
|
||||
value={recentCount}
|
||||
state={recentCount > 0 ? "ok" : "neutral"}
|
||||
icon="🟢"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaffActivitiesTable
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaffActivitiesTable
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KEYS = [
|
||||
"maintenance_enabled",
|
||||
"maintenance_message",
|
||||
|
||||
+106
-100
@@ -1,7 +1,9 @@
|
||||
import { Store } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { cancelMarketplaceListing } from "@/actions/admin-marketplace";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -10,8 +12,6 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminMarketplacePage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -69,109 +69,115 @@ export default async function AdminMarketplacePage({
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
{loadError ? (
|
||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||
{t("loadError")}
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
{loadError ? (
|
||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||
{t("loadError")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filter === "active" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/marketplace?filter=active">
|
||||
{t("filterActive")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "sold" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/marketplace?filter=sold">{t("filterSold")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filter === "active" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/marketplace?filter=active">
|
||||
{t("filterActive")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "sold" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/marketplace?filter=sold">{t("filterSold")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeListings")}
|
||||
value={activeCount}
|
||||
icon="🏪"
|
||||
/>
|
||||
<StatusCard label={t("soldListings")} value={soldCount} icon="✅" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("activeListings")} value={activeCount} icon="🏪" />
|
||||
<StatusCard label={t("soldListings")} value={soldCount} icon="✅" />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="admin-section-title">
|
||||
{filter === "sold" ? t("soldListings") : t("activeListings")} ({total}
|
||||
)
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-empty">{t("empty")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("colItem")}</th>
|
||||
<th>{t("colSeller")}</th>
|
||||
<th>{t("colPrice")}</th>
|
||||
<th>{t("colListed")}</th>
|
||||
{filter === "sold" ? <th>{t("colSold")}</th> : null}
|
||||
{canEdit && filter === "active" ? (
|
||||
<th>{t("colActions")}</th>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="muted">{row.id}</td>
|
||||
<td>
|
||||
<code>{row.itemId}</code>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/show/${row.userId}`}>
|
||||
{usernameById.get(row.userId) ?? `#${row.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{row.price}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(row.timestamp * 1000))}
|
||||
</td>
|
||||
{filter === "sold" ? (
|
||||
<td className="muted">
|
||||
{formatDate(
|
||||
row.soldTimestamp
|
||||
? new Date(row.soldTimestamp * 1000)
|
||||
: null,
|
||||
)}
|
||||
</td>
|
||||
) : null}
|
||||
<section>
|
||||
<h2 className="admin-section-title">
|
||||
{filter === "sold" ? t("soldListings") : t("activeListings")} (
|
||||
{total})
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-empty">{t("empty")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("colItem")}</th>
|
||||
<th>{t("colSeller")}</th>
|
||||
<th>{t("colPrice")}</th>
|
||||
<th>{t("colListed")}</th>
|
||||
{filter === "sold" ? <th>{t("colSold")}</th> : null}
|
||||
{canEdit && filter === "active" ? (
|
||||
<td>
|
||||
<form action={cancelMarketplaceListing}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
<th>{t("colActions")}</th>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<AdminSimplePager
|
||||
page={pagination.page}
|
||||
lastPage={pagination.lastPage}
|
||||
total={total}
|
||||
basePath="/admin/marketplace"
|
||||
params={filter === "sold" ? { filter: "sold" } : undefined}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="muted">{row.id}</td>
|
||||
<td>
|
||||
<code>{row.itemId}</code>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/show/${row.userId}`}>
|
||||
{usernameById.get(row.userId) ?? `#${row.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{row.price}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(row.timestamp * 1000))}
|
||||
</td>
|
||||
{filter === "sold" ? (
|
||||
<td className="muted">
|
||||
{formatDate(
|
||||
row.soldTimestamp
|
||||
? new Date(row.soldTimestamp * 1000)
|
||||
: null,
|
||||
)}
|
||||
</td>
|
||||
) : null}
|
||||
{canEdit && filter === "active" ? (
|
||||
<td>
|
||||
<form action={cancelMarketplaceListing}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<AdminSimplePager
|
||||
page={pagination.page}
|
||||
lastPage={pagination.lastPage}
|
||||
total={total}
|
||||
basePath="/admin/marketplace"
|
||||
params={filter === "sold" ? { filter: "sold" } : undefined}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { saveAdminNavConfig } from "@/actions/admin-nav-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ADMIN_NAV_PINNED_HREFS,
|
||||
type AdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type EditableNavGroup = {
|
||||
labelKey: string;
|
||||
items: { href: string; labelKey: string }[];
|
||||
};
|
||||
|
||||
function moveIndex<T>(arr: T[], from: number, to: number): T[] {
|
||||
if (to < 0 || to >= arr.length || from === to) return arr;
|
||||
const next = [...arr];
|
||||
const [item] = next.splice(from, 1);
|
||||
if (item === undefined) return arr;
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
groups: EditableNavGroup[],
|
||||
hiddenGroups: Set<string>,
|
||||
hiddenItems: Set<string>,
|
||||
): Required<AdminNavConfig> {
|
||||
return {
|
||||
groupOrder: groups.map((g) => g.labelKey),
|
||||
hiddenGroups: [...hiddenGroups],
|
||||
hiddenItems: [...hiddenItems].filter((h) => !ADMIN_NAV_PINNED_HREFS.has(h)),
|
||||
itemOrder: Object.fromEntries(
|
||||
groups.map((g) => [g.labelKey, g.items.map((i) => i.href)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminMenuEditor({
|
||||
catalog,
|
||||
initialConfig,
|
||||
canEdit,
|
||||
}: {
|
||||
catalog: EditableNavGroup[];
|
||||
initialConfig: AdminNavConfig;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const tNav = useTranslations("pages.admin.nav");
|
||||
const t = useTranslations("pages.admin.menu");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const initialGroups = useMemo(() => {
|
||||
const order = initialConfig.groupOrder ?? [];
|
||||
const byKey = new Map(catalog.map((g) => [g.labelKey, g]));
|
||||
const ordered: EditableNavGroup[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of order) {
|
||||
const g = byKey.get(key);
|
||||
if (!g || seen.has(key)) continue;
|
||||
const itemOrder = initialConfig.itemOrder?.[key] ?? [];
|
||||
const itemsByHref = new Map(g.items.map((i) => [i.href, i]));
|
||||
const items = [
|
||||
...itemOrder
|
||||
.map((href) => itemsByHref.get(href))
|
||||
.filter((i): i is NonNullable<typeof i> => Boolean(i)),
|
||||
...g.items.filter((i) => !itemOrder.includes(i.href)),
|
||||
];
|
||||
ordered.push({ labelKey: g.labelKey, items });
|
||||
seen.add(key);
|
||||
}
|
||||
for (const g of catalog) {
|
||||
if (seen.has(g.labelKey)) continue;
|
||||
ordered.push({ ...g, items: [...g.items] });
|
||||
}
|
||||
return ordered;
|
||||
}, [catalog, initialConfig]);
|
||||
|
||||
const [groups, setGroups] = useState(initialGroups);
|
||||
const [hiddenGroups, setHiddenGroups] = useState(
|
||||
() => new Set(initialConfig.hiddenGroups ?? []),
|
||||
);
|
||||
const [hiddenItems, setHiddenItems] = useState(
|
||||
() => new Set(initialConfig.hiddenItems ?? []),
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setGroups(catalog.map((g) => ({ ...g, items: [...g.items] })));
|
||||
setHiddenGroups(new Set());
|
||||
setHiddenItems(new Set());
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!canEdit) return;
|
||||
const config = buildConfig(groups, hiddenGroups, hiddenItems);
|
||||
startTransition(async () => {
|
||||
const res = await saveAdminNavConfig(config);
|
||||
if (res.ok) {
|
||||
toast.success(t("saved"));
|
||||
} else {
|
||||
toast.error(res.error ?? t("saveFailed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Button type="button" onClick={save} disabled={pending}>
|
||||
<Save className="h-4 w-4" />
|
||||
{pending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
disabled={pending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground w-full sm:w-auto sm:ml-auto">
|
||||
{t("hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{groups.map((group, gi) => {
|
||||
const groupHidden = hiddenGroups.has(group.labelKey);
|
||||
return (
|
||||
<section
|
||||
key={group.labelKey}
|
||||
className={cn(
|
||||
"rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3",
|
||||
groupHidden && "opacity-55",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-sm uppercase tracking-wide flex-1 min-w-0">
|
||||
{tNav(group.labelKey)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={gi === 0}
|
||||
aria-label={t("moveUp")}
|
||||
onClick={() =>
|
||||
setGroups((prev) => moveIndex(prev, gi, gi - 1))
|
||||
}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={gi === groups.length - 1}
|
||||
aria-label={t("moveDown")}
|
||||
onClick={() =>
|
||||
setGroups((prev) => moveIndex(prev, gi, gi + 1))
|
||||
}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={groupHidden ? t("showGroup") : t("hideGroup")}
|
||||
onClick={() =>
|
||||
setHiddenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.labelKey))
|
||||
next.delete(group.labelKey);
|
||||
else next.add(group.labelKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{groupHidden ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((item, ii) => {
|
||||
const pinned = ADMIN_NAV_PINNED_HREFS.has(item.href);
|
||||
const itemHidden = !pinned && hiddenItems.has(item.href);
|
||||
return (
|
||||
<li
|
||||
key={item.href}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"bg-[var(--admin-canvas)]/60",
|
||||
itemHidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate">
|
||||
{tNav(item.labelKey)}
|
||||
<span className="ml-2 text-xs text-muted-foreground font-mono">
|
||||
{item.href}
|
||||
</span>
|
||||
{pinned && (
|
||||
<span className="ml-2 text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
||||
{t("pinned")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={ii === 0}
|
||||
aria-label={t("moveUp")}
|
||||
onClick={() =>
|
||||
setGroups((prev) =>
|
||||
prev.map((g, idx) =>
|
||||
idx === gi
|
||||
? {
|
||||
...g,
|
||||
items: moveIndex(g.items, ii, ii - 1),
|
||||
}
|
||||
: g,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={ii === group.items.length - 1}
|
||||
aria-label={t("moveDown")}
|
||||
onClick={() =>
|
||||
setGroups((prev) =>
|
||||
prev.map((g, idx) =>
|
||||
idx === gi
|
||||
? {
|
||||
...g,
|
||||
items: moveIndex(g.items, ii, ii + 1),
|
||||
}
|
||||
: g,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={pinned}
|
||||
aria-label={
|
||||
itemHidden ? t("showItem") : t("hideItem")
|
||||
}
|
||||
onClick={() => {
|
||||
if (pinned) return;
|
||||
setHiddenItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.href)) next.delete(item.href);
|
||||
else next.add(item.href);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{itemHidden || pinned ? (
|
||||
itemHidden ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 opacity-40" />
|
||||
)
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ListOrdered } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { AdminMenuEditor } from "./admin-menu-editor";
|
||||
|
||||
export const metadata = { title: "Admin menu" };
|
||||
|
||||
export default async function AdminMenuPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const canEdit = canAccess(
|
||||
permissions,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.menu");
|
||||
|
||||
const catalog = ADMIN_NAV_GROUPS.map((g) => ({
|
||||
labelKey: g.labelKey,
|
||||
items: g.items.map((i) => ({ href: i.href, labelKey: i.labelKey })),
|
||||
}));
|
||||
const initialConfig = parseAdminNavConfig(
|
||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={ListOrdered}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
>
|
||||
<AdminMenuEditor
|
||||
catalog={catalog}
|
||||
initialConfig={initialConfig}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { type CfhRow, CfhTable } from "./cfh-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CfhListPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -26,7 +23,7 @@ export default async function CfhListPage({
|
||||
const stateFilter =
|
||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||
|
||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
||||
const conditions: any[] = [];
|
||||
if (stateFilter >= 0) {
|
||||
conditions.push({ state: stateFilter });
|
||||
}
|
||||
@@ -61,17 +58,14 @@ export default async function CfhListPage({
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.SupportTicketsWhereInput =
|
||||
const where: any =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.SupportTicketsOrderByWithRelationInput
|
||||
> = {
|
||||
const SORT_MAP: Record<string, any> = {
|
||||
id: { id: "desc" },
|
||||
issue: { issue: "asc" },
|
||||
sender: { senderId: "asc" },
|
||||
@@ -84,7 +78,7 @@ export default async function CfhListPage({
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
||||
} as any;
|
||||
|
||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { and, count, desc, eq, like, or, type SQL } from "drizzle-orm";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { OnlineTable } from "./online-table";
|
||||
|
||||
@@ -12,25 +17,46 @@ export default async function OnlineUsersPage({
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "motto"],
|
||||
baseWhere: { online: "1" },
|
||||
defaultPerPage: 50,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
fetch: async ({ search, page, perPage }) => {
|
||||
const conditions: (SQL | undefined)[] = [eq(User.online, "1")];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(User.username, `%${search}%`),
|
||||
like(User.motto, `%${search}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
const where = and(...conditions);
|
||||
const [rows, totals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
motto: User.motto,
|
||||
look: User.look,
|
||||
})
|
||||
.from(User)
|
||||
.where(where)
|
||||
.orderBy(desc(User.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage),
|
||||
db.select({ total: count() }).from(User).where(where),
|
||||
]);
|
||||
return { rows, total: totals[0]?.total ?? 0 };
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
|
||||
const t = await getTranslations("pages.admin.online");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-muted-foreground">
|
||||
Currently online users ({result.total} connected)
|
||||
<AdminPageShell icon={Wifi} title={t("title")} subtitle={t("subtitle")}>
|
||||
<p className="text-muted-foreground m-0">
|
||||
{t("connected", { count: result.total })}
|
||||
</p>
|
||||
|
||||
<OnlineTable
|
||||
@@ -42,6 +68,6 @@ export default async function OnlineUsersPage({
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
+16
-9
@@ -13,8 +13,6 @@ import { activeBanWhere } from "@/lib/bans";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RankCount = { rank: number; count: number };
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
@@ -83,13 +81,22 @@ export default async function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatusCard
|
||||
label={t("playersOnline")}
|
||||
value={online}
|
||||
hint={dbOk ? t("totalAccounts", { count: users }) : t("databaseDown")}
|
||||
state={dbOk ? "neutral" : "danger"}
|
||||
icon={<Users size={18} />}
|
||||
/>
|
||||
<Link
|
||||
href="/admin/commandocentrum#online"
|
||||
className="no-underline text-inherit"
|
||||
>
|
||||
<StatusCard
|
||||
label={t("playersOnline")}
|
||||
value={online}
|
||||
hint={
|
||||
dbOk
|
||||
? `${t("totalAccounts", { count: users })} · live roster →`
|
||||
: t("databaseDown")
|
||||
}
|
||||
state={dbOk ? "neutral" : "danger"}
|
||||
icon={<Users size={18} />}
|
||||
/>
|
||||
</Link>
|
||||
<StatusCard
|
||||
label={t("articles")}
|
||||
value={dbOk ? articles : "—"}
|
||||
|
||||
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user