Compare commits
99
Commits
v1.0.2
...
5ff75144dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff75144dd | ||
|
|
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,
|
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||||
# it does NOT own or migrate the emulator tables. Format:
|
# ==============================================================================
|
||||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
|
||||||
|
|
||||||
# Optional pool tuning (defaults shown)
|
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||||
DATABASE_POOL_SIZE=10
|
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
DATABASE_POOL_SIZE=150
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
|
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||||
# REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||||
|
REDIS_CACHE_TTL_DEFAULT=7200
|
||||||
|
|
||||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
HOTEL_NAME=Atom
|
NODE_ENV=production
|
||||||
APP_URL=http://localhost:3000
|
PORT=3002
|
||||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
AUTH_URL=http://localhost:3000
|
UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
# --- HOTEL & URLS ---
|
||||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
HOTEL_NAME=EPIC WEB CONTROL
|
||||||
AUTH_SECRET=
|
APP_URL=http://localhost:3002
|
||||||
APP_KEY=
|
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||||
CONVERT_PASSWORDS=false
|
AUTH_URL=http://localhost:3002
|
||||||
|
|
||||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
# --- IMAGER ---
|
||||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||||
# column). Existing accounts in either format still verify on login.
|
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||||
PASSWORD_HASH=bcrypt
|
|
||||||
|
|
||||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
# --- SECURITY & HASHING ---
|
||||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
# Leave unset to disable badge uploads.
|
APP_KEY=base64:your-app-key-here=
|
||||||
BADGE_UPLOAD_DIR=
|
CONVERT_PASSWORDS=true
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
# --- PATHS ---
|
||||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
EMULATOR_BACKUP_DIR=
|
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||||
EMULATOR_BACKUP_KEEP=7
|
EMULATOR_BACKUP_KEEP=7
|
||||||
|
|
||||||
# RCON link to the Arcturus emulator
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
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.
|
# --- EMAIL & NOTIFICATIONS ---
|
||||||
# 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)
|
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=[email protected]
|
||||||
|
|
||||||
# Optional alerting (jobs worker / alert service)
|
# --- ALERTING & MONITORING ---
|
||||||
DISCORD_WEBHOOK_URL=
|
DISCORD_WEBHOOK_URL=
|
||||||
ALERT_EMAIL=
|
ALERT_EMAIL=
|
||||||
|
|
||||||
# Optional AI content moderation (user comments / guestbook).
|
# --- MODERATION & PAYMENTS ---
|
||||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
|
||||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
# Optional PayPal top-up (sandbox by default)
|
|
||||||
PAYPAL_CLIENT_ID=
|
PAYPAL_CLIENT_ID=
|
||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||||
|
|
||||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
|
||||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
LOG_LEVEL=error
|
||||||
# in-process memory: limits reset on restart and do not work across instances.
|
SENTRY_DSN=https://[email protected]/0
|
||||||
# Deploy logs a loud warning when this is unset in production.
|
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
|
||||||
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=
|
|
||||||
+504
-2
@@ -1,11 +1,18 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
if: startsWith(gitea.ref_name, 'v') == false
|
||||||
runs-on: shell
|
runs-on: shell
|
||||||
steps:
|
steps:
|
||||||
- name: Typecheck, lint, and test
|
- name: Typecheck, lint, and test
|
||||||
@@ -30,12 +37,507 @@ jobs:
|
|||||||
git checkout -f "${REF}"
|
git checkout -f "${REF}"
|
||||||
|
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
export ARGON2_MEMORY_SIZE=1024
|
export NODE_ENV=test
|
||||||
export ARGON2_ITERATIONS=1
|
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
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
# Generate Prisma type stubs for facade type-checking (dev only).
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
# Run lint + typecheck + tests on the Drizzle-backed codebase.
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
echo "--- CI checks passed ---"
|
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 ---"
|
|
||||||
@@ -12,8 +12,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
# Ensure cache dir exists before Docker starts
|
||||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
|
||||||
mkdir -p /var/tmp/renovate-cache
|
mkdir -p /var/tmp/renovate-cache
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
|
|||||||
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
|
# 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;
|
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
|
### 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.).
|
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
|
```bash
|
||||||
pnpm prisma:generate
|
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
|
### 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 test` | Run all tests (Vitest) |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `pnpm db:migrate:status` | Show migration status |
|
||||||
|
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from Prisma + live DB (needs `DATABASE_URL`) |
|
||||||
|
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
||||||
| `pnpm analyze` | Build + open bundle analyzer |
|
| `pnpm analyze` | Build + open bundle analyzer |
|
||||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||||
| `pnpm biome:check` | Lint and format code |
|
| `pnpm biome:check` | Lint and format code |
|
||||||
|
|
||||||
|
**Drizzle CLI (dev only, run with `npx`):**
|
||||||
|
| 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
|
## Performance Features
|
||||||
@@ -161,20 +204,26 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
│ ├── schema.prisma # ~190 models (emulator + CMS) — used for type generation only (legacy)
|
||||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
│ └── migrations/ # 19 SQL migrations for CMS tables (idempotent, never re-run)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── apply-migrations.ts # Custom migration runner
|
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||||
│ ├── jobs-worker.ts # Background task scheduler
|
│ ├── 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/
|
├── 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)
|
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||||
│ ├── actions/ # Server Actions
|
│ ├── actions/ # Server Actions
|
||||||
│ ├── components/ # UI components
|
│ ├── components/ # UI components
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||||
│ │ ├── prisma.ts # 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.ts # Redis client (ioredis)
|
||||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||||
│ │ ├── cache.ts # In-memory cache fallback
|
│ │ ├── cache.ts # In-memory cache fallback
|
||||||
@@ -195,9 +244,19 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| Component | Type | Migrations |
|
| Component | Type | Migrations |
|
||||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
||||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (19 files) |
|
||||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
| 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
|
## Optional Integrations
|
||||||
@@ -261,6 +320,8 @@ pnpm biome:check # Lint and format
|
|||||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
||||||
4. SQL migrations in `prisma/migrations/` must be idempotent
|
4. SQL migrations in `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 = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {},
|
turbopack: {},
|
||||||
serverExternalPackages: [
|
serverExternalPackages: ["mariadb", "lzma", "sharp", "pino", "pino-pretty"],
|
||||||
"@prisma/adapter-mariadb",
|
|
||||||
"mariadb",
|
|
||||||
"@prisma/client",
|
|
||||||
"lzma",
|
|
||||||
"sharp",
|
|
||||||
"pino",
|
|
||||||
"pino-pretty",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Enable React Compiler for automatic memoization
|
// Enable React Compiler for automatic memoization
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
// Compress responses with gzip
|
// Compress responses with gzip/brotli
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|
||||||
// Disable Next.js telemetry
|
// Disable Next.js telemetry and browser sourcemaps in production
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|
||||||
|
// Standalone output for smaller, faster Docker deployments and cold starts
|
||||||
|
output: "standalone",
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
useTypeScriptCli: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+19
-38
@@ -11,28 +11,22 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"typecheck": "tsc6 --noEmit --incremental false",
|
"typecheck": "tsc --noEmit --incremental",
|
||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
"biome:format": "biome format --write .",
|
"biome:format": "biome format --write .",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"analyze": "ANALYZE=true pnpm build",
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"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": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
|
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write",
|
"biome check --write"
|
||||||
"eslint --fix --max-warnings=50"
|
|
||||||
],
|
],
|
||||||
"*.{json,md,css,scss,html}": [
|
"*.{json,md,css,scss,html}": [
|
||||||
"biome format --write"
|
"biome format --write"
|
||||||
@@ -44,38 +38,34 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@prisma/adapter-mariadb": "^7.9.1",
|
"@sentry/nextjs": "^10.69.0",
|
||||||
"@prisma/client": "^7.9.1",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"@sentry/nextjs": "^10.68.0",
|
|
||||||
"@tanstack/react-virtual": "^3.14.8",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"framer-motion": "^12.42.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"isomorphic-dompurify": "^3.21.0",
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lenis": "^1.3.25",
|
"lenis": "^1.3.25",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.28.0",
|
||||||
"lzma": "^2.3.2",
|
"lzma": "^2.3.2",
|
||||||
|
"motion": "^12.43.0",
|
||||||
"music-metadata": "^11.14.0",
|
"music-metadata": "^11.14.0",
|
||||||
"mysql2": "^3.23.2",
|
"mysql2": "^3.23.2",
|
||||||
"next": "^16.2.12",
|
"next": "^16.2.12",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "^4.13.4",
|
"next-intl": "^4.13.4",
|
||||||
"next-view-transitions": "^0.3.5",
|
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.83.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"resend": "^6.18.1",
|
"resend": "^6.18.1",
|
||||||
"sanitize-html": "^2.17.6",
|
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -85,37 +75,28 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@eslint/js": "10.0.1",
|
|
||||||
"@lhci/cli": "^0.15.1",
|
|
||||||
"@next/bundle-analyzer": "^16.2.12",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
"@next/eslint-plugin-next": "^16.2.12",
|
"@prisma/client": "^7.9.1",
|
||||||
"@playwright/test": "1.62.0",
|
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@types/sanitize-html": "^2.16.1",
|
|
||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "10.8.0",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-security": "^4.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "4.4.1",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.29.0",
|
"knip": "^6.31.0",
|
||||||
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.24",
|
"postcss": "^8.5.25",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"vite": "8.2.0",
|
||||||
"vite": "8.1.5",
|
|
||||||
"vitest": "4.1.10"
|
"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
+1665
-3518
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -20,7 +20,7 @@ overrides:
|
|||||||
uuid: "^9.0.1"
|
uuid: "^9.0.1"
|
||||||
fast-uri: "^3.1.3"
|
fast-uri: "^3.1.3"
|
||||||
"@hono/node-server": "^1.19.13"
|
"@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
|
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
||||||
tmp: "^0.2.6"
|
tmp: "^0.2.6"
|
||||||
# NIEUWE SECURITY PATCHES:
|
# 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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { $Enums } from "@/generated/prisma/client";
|
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
type BanType = $Enums.bans_type;
|
|
||||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||||
"account",
|
"account",
|
||||||
"ip",
|
"ip",
|
||||||
@@ -45,7 +43,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
banExpire,
|
banExpire,
|
||||||
banReason: reason,
|
banReason: reason,
|
||||||
type: type as BanType,
|
type: type as any,
|
||||||
cfhTopic: -1,
|
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);
|
const id = String(ticketId);
|
||||||
revalidatePath("/admin/help-tickets");
|
revalidatePath("/admin/help-tickets");
|
||||||
revalidatePath(`/admin/help-tickets/${id}`);
|
revalidatePath(`/admin/help-tickets/${id}`);
|
||||||
|
revalidatePath("/mod/help-tickets");
|
||||||
|
revalidatePath(`/mod/help-tickets/${id}`);
|
||||||
revalidatePath("/help/tickets");
|
revalidatePath("/help/tickets");
|
||||||
revalidatePath(`/help/tickets/${id}`);
|
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(
|
export const replyHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
@@ -72,7 +123,7 @@ export const replyHelpCenterTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeHelpCenterTicket = adminAction(
|
export const closeHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
@@ -104,7 +155,7 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const reopenHelpCenterTicket = adminAction(
|
export const reopenHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const ticket = await 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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
import { CameraWeb, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
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"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
try {
|
const [row] = await db
|
||||||
await prisma.cameraWeb.delete({ where: { id } });
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||||
} catch {
|
.from(CameraWeb)
|
||||||
// Record may have already been removed; ignore.
|
.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("/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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, Sanctions, User, UsersSettings } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
@@ -181,3 +183,177 @@ export async function bulkGiveBadge({
|
|||||||
data: { given, total: userIds.length, failedIds },
|
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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -58,7 +57,7 @@ export async function updateBcPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPagesBc.update({
|
await prisma.catalogPagesBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.CatalogPagesBcUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -104,7 +103,7 @@ export async function updateBcItem({
|
|||||||
}
|
}
|
||||||
await prisma.catalogItemsBc.update({
|
await prisma.catalogItemsBc.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safe as Prisma.CatalogItemsBcUpdateInput,
|
data: safe as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -252,7 +252,7 @@ export async function moveCatalogItems({
|
|||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
UPDATE catalog_items
|
UPDATE catalog_items
|
||||||
SET page_id = ${pageIdStr}
|
SET page_id = ${pageIdStr}
|
||||||
WHERE id IN (${Prisma.join(ids)})
|
WHERE id IN (${sql.join(ids, sql`, `)})
|
||||||
`;
|
`;
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
@@ -301,7 +301,7 @@ export async function updateCatalogItem({
|
|||||||
if (Object.keys(safeCatalog).length > 0) {
|
if (Object.keys(safeCatalog).length > 0) {
|
||||||
await prisma.catalogItems.update({
|
await prisma.catalogItems.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: safeCatalog as Prisma.CatalogItemsUpdateInput,
|
data: safeCatalog as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (baseItem) {
|
if (baseItem) {
|
||||||
@@ -309,7 +309,7 @@ export async function updateCatalogItem({
|
|||||||
if (Object.keys(safeBase).length > 0) {
|
if (Object.keys(safeBase).length > 0) {
|
||||||
await prisma.itemsBase.update({
|
await prisma.itemsBase.update({
|
||||||
where: { id: baseItem.id },
|
where: { id: baseItem.id },
|
||||||
data: safeBase as Prisma.ItemsBaseUpdateInput,
|
data: safeBase as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -57,7 +56,7 @@ export async function updateCatalogPage({
|
|||||||
}
|
}
|
||||||
await prisma.catalogPages.update({
|
await prisma.catalogPages.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.CatalogPagesUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -89,8 +88,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
// Atomically deduct credits and persist the badge so a failure
|
// Atomically deduct credits and persist the badge so a failure
|
||||||
// between the two operations cannot orphan the user.
|
// between the two operations cannot orphan the user.
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(async (tx) => {
|
||||||
async (tx: Prisma.TransactionClient) => {
|
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { credits: { decrement: price } },
|
data: { credits: { decrement: price } },
|
||||||
@@ -110,8 +108,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
data: { userId, slotId, badgeCode: code },
|
data: { userId, slotId, badgeCode: code },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant the badge live so it appears immediately for online users.
|
// 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) {
|
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();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.create({
|
await prisma.websiteHelpCenterTickets.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
title,
|
title: ticketTitle,
|
||||||
content,
|
content,
|
||||||
|
categoryId,
|
||||||
open: true,
|
open: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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";
|
"use server";
|
||||||
|
|
||||||
|
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AclModelPermission,
|
||||||
|
AclModelRole,
|
||||||
|
AclPermission,
|
||||||
|
AclRole,
|
||||||
|
db,
|
||||||
|
User,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import {
|
import {
|
||||||
@@ -22,16 +31,15 @@ const createRankSchema = z.object({
|
|||||||
export const createRank = adminAction(
|
export const createRank = adminAction(
|
||||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = await createEmulatorRank(prisma, ctx.data);
|
const id = await createEmulatorRank(db, ctx.data);
|
||||||
await prisma.aclRole.upsert({
|
await db
|
||||||
where: { slug: `rank_${id}` },
|
.insert(AclRole)
|
||||||
create: {
|
.values({
|
||||||
slug: `rank_${id}`,
|
slug: `rank_${id}`,
|
||||||
title: ctx.data.rank_name,
|
title: ctx.data.rank_name,
|
||||||
description: "CMS role synchronized from permission_ranks",
|
description: "CMS role synchronized from permission_ranks",
|
||||||
},
|
})
|
||||||
update: { title: ctx.data.rank_name },
|
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
||||||
});
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
action: "rank_create",
|
action: "rank_create",
|
||||||
@@ -50,21 +58,32 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteRank = adminAction(
|
export const deleteRank = adminAction(
|
||||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
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)
|
if (users > 0)
|
||||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||||
const role = await prisma.aclRole.findFirst({
|
const [role] = await db
|
||||||
where: { slug: `rank_${ctx.data.id}` },
|
.select({ id: AclRole.id })
|
||||||
});
|
.from(AclRole)
|
||||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||||
|
.limit(1);
|
||||||
|
await deleteEmulatorRank(db, ctx.data.id);
|
||||||
if (role) {
|
if (role) {
|
||||||
await prisma.$transaction([
|
await db.transaction(async (tx) => {
|
||||||
prisma.aclModelPermission.deleteMany({
|
await tx
|
||||||
where: { modelId: role.id, modelType: "Role" },
|
.delete(AclModelPermission)
|
||||||
}),
|
.where(
|
||||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
and(
|
||||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
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({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
@@ -87,12 +106,12 @@ const saveRankSchema = z.object({
|
|||||||
export const saveRank = adminAction(
|
export const saveRank = adminAction(
|
||||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
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") {
|
if (typeof ctx.data.fields.rank_name === "string") {
|
||||||
await prisma.aclRole.updateMany({
|
await db
|
||||||
where: { slug: `rank_${ctx.data.id}` },
|
.update(AclRole)
|
||||||
data: { title: ctx.data.fields.rank_name },
|
.set({ title: ctx.data.fields.rank_name })
|
||||||
});
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
||||||
}
|
}
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: ctx.session.user.id,
|
staffId: ctx.session.user.id,
|
||||||
@@ -115,27 +134,33 @@ const setCmsPermsSchema = z.object({
|
|||||||
export const setCmsPermissions = adminAction(
|
export const setCmsPermissions = adminAction(
|
||||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const role = await prisma.aclRole.findUnique({
|
const [role] = await db
|
||||||
where: { id: ctx.data.roleId },
|
.select({ id: AclRole.id, slug: AclRole.slug })
|
||||||
select: { id: true, slug: true },
|
.from(AclRole)
|
||||||
});
|
.where(eq(AclRole.id, ctx.data.roleId))
|
||||||
|
.limit(1);
|
||||||
if (!role) throw new ActionError("Role not found");
|
if (!role) throw new ActionError("Role not found");
|
||||||
const permissions = await prisma.aclPermission.findMany({
|
const permissions = await db
|
||||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
.select({ id: AclPermission.id })
|
||||||
select: { id: true },
|
.from(AclPermission)
|
||||||
});
|
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
||||||
await prisma.$transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.aclModelPermission.deleteMany({
|
await tx
|
||||||
where: { modelId: role.id, modelType: "Role" },
|
.delete(AclModelPermission)
|
||||||
});
|
.where(
|
||||||
|
and(
|
||||||
|
eq(AclModelPermission.modelId, role.id),
|
||||||
|
eq(AclModelPermission.modelType, "Role"),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (permissions.length) {
|
if (permissions.length) {
|
||||||
await tx.aclModelPermission.createMany({
|
await tx.insert(AclModelPermission).values(
|
||||||
data: permissions.map((permission) => ({
|
permissions.map((permission) => ({
|
||||||
modelId: role.id,
|
modelId: role.id,
|
||||||
modelType: "Role",
|
modelType: "Role",
|
||||||
permissionId: permission.id,
|
permissionId: permission.id,
|
||||||
})),
|
})),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
@@ -149,3 +174,98 @@ export const setCmsPermissions = adminAction(
|
|||||||
return actionOk();
|
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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -17,7 +16,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
await prisma.items.update({
|
await prisma.items.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
data: data as Prisma.ItemsUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -111,7 +110,7 @@ export async function updateRoom({
|
|||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await prisma.rooms.update({
|
await prisma.rooms.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: data as Prisma.RoomsUpdateInput,
|
data: data as any,
|
||||||
});
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
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 { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -116,7 +115,7 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
const badgeCodes = parseBadgeCodes(article.badges);
|
const badgeCodes = parseBadgeCodes(article.badges);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { invalidateLoginCache } from "@/lib/auth";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -81,10 +81,14 @@ export const createUser = adminAction(
|
|||||||
return actionOk({ id: user.id, username: user.username });
|
return actionOk({ id: user.id, username: user.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"code" in err &&
|
||||||
err.code === "P2002"
|
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"))
|
if (target.includes("username"))
|
||||||
throw new ActionError("Username already taken");
|
throw new ActionError("Username already taken");
|
||||||
if (target.includes("mail"))
|
if (target.includes("mail"))
|
||||||
@@ -116,6 +120,7 @@ export const updateUser = adminAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({ where: { id }, data: userData });
|
await prisma.user.update({ where: { id }, data: userData });
|
||||||
|
invalidateLoginCache(targetUser.username);
|
||||||
|
|
||||||
if (diamonds !== undefined) {
|
if (diamonds !== undefined) {
|
||||||
await prisma.usersCurrency.upsert({
|
await prisma.usersCurrency.upsert({
|
||||||
@@ -310,6 +315,7 @@ export const resetPassword = adminAction(
|
|||||||
where: { id: ctx.data.userId },
|
where: { id: ctx.data.userId },
|
||||||
data: { password: hashed },
|
data: { password: hashed },
|
||||||
});
|
});
|
||||||
|
invalidateLoginCache(target.username);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
|
|||||||
@@ -107,6 +107,17 @@ export default async function HelpTicketsPage({
|
|||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="checkbox" name="banAppeal" value="1" />
|
||||||
|
{t("banAppealLabel")}
|
||||||
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
{t("submit")}
|
{t("submit")}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { EditAdDeleteButton } from "../ads-table";
|
import { EditAdDeleteButton } from "../ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function EditAd({
|
export default async function EditAd({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { AdsTable } from "./ads-table";
|
import { AdsTable } from "./ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminAds() {
|
export default async function AdminAds() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
type DayRow = {
|
type DayRow = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -192,7 +193,9 @@ export default async function ActivityPage() {
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = await getTranslations("pages.admin.analytics.activity");
|
const t = await getTranslations("pages.admin.analytics.activity");
|
||||||
const tRoot = await getTranslations("pages.admin.analytics");
|
const tRoot = await getTranslations("pages.admin.analytics");
|
||||||
const data = await getActivityData(locale);
|
const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () =>
|
||||||
|
getActivityData(locale),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
type EconomyData = {
|
type EconomyData = {
|
||||||
totalCredits: number;
|
totalCredits: number;
|
||||||
@@ -172,7 +173,11 @@ export default async function EconomyPage() {
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = await getTranslations("pages.admin.analytics.economy");
|
const t = await getTranslations("pages.admin.analytics.economy");
|
||||||
const tRoot = await getTranslations("pages.admin.analytics");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
type AnalyticsData = {
|
type AnalyticsData = {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
@@ -202,7 +203,11 @@ export default async function AnalyticsPage() {
|
|||||||
|
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = await getTranslations("pages.admin.analytics");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type ApplicationRow = {
|
type ApplicationRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function EditArticle({
|
export default async function EditArticle({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { Newspaper } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { ArticleCard } from "@/components/admin/article-card";
|
import { ArticleCard } from "@/components/admin/article-card";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,8 +10,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminArticles({
|
export default async function AdminArticles({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -56,17 +56,18 @@ export default async function AdminArticles({
|
|||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell
|
||||||
<div className="flex justify-end mb-6">
|
icon={Newspaper}
|
||||||
|
title={t("title")}
|
||||||
|
subtitle={t("subtitle")}
|
||||||
|
actions={
|
||||||
<Button variant="default" asChild>
|
<Button variant="default" asChild>
|
||||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
||||||
Error: {error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<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>
|
</div>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-2">
|
||||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noArticles")}</div>
|
<div className="admin-empty">{t("noArticles")}</div>
|
||||||
@@ -102,6 +103,6 @@ export default async function AdminArticles({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Badges" };
|
export const metadata = { title: "Badges" };
|
||||||
|
|
||||||
export default async function AdminBadges({
|
export default async function AdminBadges({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { Ban } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { activeBanWhere, unixNow } from "@/lib/bans";
|
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||||
@@ -8,8 +10,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminBans() {
|
export default async function AdminBans() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
|
||||||
@@ -33,6 +33,7 @@ export default async function AdminBans() {
|
|||||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell icon={Ban} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<main>
|
<main>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -140,5 +141,6 @@ export default async function AdminBans() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Campaign = {
|
type Campaign = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Campaign = {
|
type Campaign = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
|||||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||||
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function CatalogEditPage({
|
export default async function CatalogEditPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { Store } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
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 { CatalogTree } from "@/components/admin/catalog-tree";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
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 { getCatalogItemCounts } from "@/lib/services/catalog-tree";
|
||||||
import { CatalogActions } from "./catalog-actions";
|
import { CatalogActions } from "./catalog-actions";
|
||||||
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
|
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 canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
|
const t = await getTranslations("pages.admin.catalog");
|
||||||
|
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const sp = new URLSearchParams(params);
|
const sp = new URLSearchParams(params);
|
||||||
@@ -200,6 +203,7 @@ export default async function CatalogPagesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -244,6 +248,7 @@ export default async function CatalogPagesPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +401,7 @@ export default async function CatalogPagesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -434,5 +440,6 @@ export default async function CatalogPagesPage({
|
|||||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { Terminal } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import {
|
import {
|
||||||
DiagnosticRow,
|
DiagnosticRow,
|
||||||
InfoItem,
|
InfoItem,
|
||||||
OnlineUsersWidget,
|
OnlineUsersWidget,
|
||||||
StatusCard,
|
StatusCard,
|
||||||
} from "@/components/admin/dashboard";
|
} 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 { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function decodeStacktrace(raw: unknown): string {
|
function decodeStacktrace(raw: unknown): string {
|
||||||
if (raw == null) return "";
|
if (raw == null) return "";
|
||||||
try {
|
try {
|
||||||
@@ -45,33 +46,29 @@ export default async function CommandoCentrum() {
|
|||||||
|
|
||||||
const t = await getTranslations("pages.admin.commandocentrum");
|
const t = await getTranslations("pages.admin.commandocentrum");
|
||||||
|
|
||||||
const [
|
const [totalUsers, errors, health, maintenance, activity, onlineRoster] =
|
||||||
onlineCount,
|
await Promise.all([
|
||||||
totalUsers,
|
|
||||||
errors,
|
|
||||||
dbOk,
|
|
||||||
emulatorOnline,
|
|
||||||
maintenance,
|
|
||||||
activity,
|
|
||||||
] = await Promise.all([
|
|
||||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
|
||||||
prisma.user.count().catch(() => -1),
|
prisma.user.count().catch(() => -1),
|
||||||
prisma.emulatorErrors
|
prisma.emulatorErrors
|
||||||
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
prisma.websiteSetting
|
fetchOpsHealth(),
|
||||||
.count()
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false),
|
|
||||||
rcon.send("ping", null).catch(() => false),
|
|
||||||
siteSettings.getBool("maintenance_enabled", false),
|
siteSettings.getBool("maintenance_enabled", false),
|
||||||
prisma.staffActivities
|
prisma.staffActivities
|
||||||
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
||||||
.catch(
|
.catch(
|
||||||
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
() =>
|
||||||
|
[] 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(
|
const staffIds = Array.from(
|
||||||
new Set(activity.map((a) => Number(a.userId))),
|
new Set(activity.map((a) => Number(a.userId))),
|
||||||
).filter((n) => n > 0);
|
).filter((n) => n > 0);
|
||||||
@@ -91,6 +88,7 @@ export default async function CommandoCentrum() {
|
|||||||
const rconSet = !!process.env.RCON_HOST;
|
const rconSet = !!process.env.RCON_HOST;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell icon={Terminal} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<main>
|
<main>
|
||||||
{/* ── Live status ────────────────────────────────────────── */}
|
{/* ── Live status ────────────────────────────────────────── */}
|
||||||
<div
|
<div
|
||||||
@@ -132,9 +130,9 @@ export default async function CommandoCentrum() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Online users widget ───────────────────────────────── */}
|
{/* ── Online users widget ───────────────────────────────── */}
|
||||||
<section className="admin-card mb-6">
|
<section id="online" className="admin-card mb-6 scroll-mt-4">
|
||||||
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
||||||
<OnlineUsersWidget users={[]} isLoading={false} />
|
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||||
@@ -147,12 +145,18 @@ export default async function CommandoCentrum() {
|
|||||||
label={t("platform")}
|
label={t("platform")}
|
||||||
value={`${process.platform} ${process.arch}`}
|
value={`${process.platform} ${process.arch}`}
|
||||||
/>
|
/>
|
||||||
<InfoItem label={t("appUptime")} value={uptime(process.uptime())} />
|
<InfoItem
|
||||||
|
label={t("appUptime")}
|
||||||
|
value={uptime(process.uptime())}
|
||||||
|
/>
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label={t("heapUsed")}
|
label={t("heapUsed")}
|
||||||
value={mb(process.memoryUsage().heapUsed)}
|
value={mb(process.memoryUsage().heapUsed)}
|
||||||
/>
|
/>
|
||||||
<InfoItem label={t("rss")} value={mb(process.memoryUsage().rss)} />
|
<InfoItem
|
||||||
|
label={t("rss")}
|
||||||
|
value={mb(process.memoryUsage().rss)}
|
||||||
|
/>
|
||||||
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
|
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label={t("load1m")}
|
label={t("load1m")}
|
||||||
@@ -170,9 +174,28 @@ export default async function CommandoCentrum() {
|
|||||||
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
|
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
|
||||||
state={dbOk ? "ok" : "danger"}
|
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
|
<DiagnosticRow
|
||||||
label={t("emulatorRcon")}
|
label={t("emulatorRcon")}
|
||||||
detail={emulatorOnline ? t("respondingToPing") : t("noResponse")}
|
detail={
|
||||||
|
emulatorOnline ? t("respondingToPing") : t("noResponse")
|
||||||
|
}
|
||||||
state={emulatorOnline ? "ok" : "warn"}
|
state={emulatorOnline ? "ok" : "warn"}
|
||||||
/>
|
/>
|
||||||
<DiagnosticRow
|
<DiagnosticRow
|
||||||
@@ -192,7 +215,9 @@ export default async function CommandoCentrum() {
|
|||||||
/>
|
/>
|
||||||
<DiagnosticRow
|
<DiagnosticRow
|
||||||
label={t("maintenanceMode")}
|
label={t("maintenanceMode")}
|
||||||
detail={maintenance ? t("siteIsInMaintenance") : t("siteIsLive")}
|
detail={
|
||||||
|
maintenance ? t("siteIsInMaintenance") : t("siteIsLive")
|
||||||
|
}
|
||||||
state={maintenance ? "warn" : "ok"}
|
state={maintenance ? "warn" : "ok"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +248,9 @@ export default async function CommandoCentrum() {
|
|||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
{formatDate(a.createdAt, "datetime-seconds")}
|
{formatDate(a.createdAt, "datetime-seconds")}
|
||||||
</td>
|
</td>
|
||||||
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
<td>
|
||||||
|
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
|
||||||
|
</td>
|
||||||
<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)]">
|
<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}
|
{a.action}
|
||||||
@@ -291,5 +318,6 @@ export default async function CommandoCentrum() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { HealthCheckClient } from "./health-check-client";
|
import { HealthCheckClient } from "./health-check-client";
|
||||||
@@ -11,42 +13,28 @@ async function getDevOpsData() {
|
|||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const dayAgo = now - 86400;
|
const dayAgo = now - 86400;
|
||||||
|
|
||||||
// DB health — simple count query
|
const [health, recentErrors, errorsToday, emuVersion] = await Promise.all([
|
||||||
let dbStatus: "healthy" | "error" = "healthy";
|
fetchOpsHealth(),
|
||||||
let dbLatency = 0;
|
prisma.emulatorErrors.findMany({
|
||||||
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" },
|
orderBy: { id: "desc" },
|
||||||
take: 20,
|
take: 20,
|
||||||
});
|
}),
|
||||||
|
prisma.emulatorErrors.count({
|
||||||
const errorsToday = await prisma.emulatorErrors.count({
|
|
||||||
where: { timestamp: { gte: dayAgo } },
|
where: { timestamp: { gte: dayAgo } },
|
||||||
});
|
}),
|
||||||
|
prisma.emulatorSettings.findFirst({
|
||||||
// Emulator settings (version info)
|
|
||||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
|
||||||
where: { key: "version" },
|
where: { key: "version" },
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
// Online user count as proxy for emulator health
|
|
||||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dbStatus,
|
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
|
||||||
dbLatency,
|
dbLatency: health.dbLatencyMs,
|
||||||
|
redisOk: health.redisOk,
|
||||||
recentErrors,
|
recentErrors,
|
||||||
errorsToday,
|
errorsToday,
|
||||||
emuVersion: emuVersion?.value ?? "Unknown",
|
emuVersion: emuVersion?.value ?? "Unknown",
|
||||||
onlineUsers,
|
onlineUsers: health.onlineUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +50,7 @@ export default async function DevOpsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Status cards */}
|
{/* 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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
||||||
@@ -80,6 +68,33 @@ export default async function DevOpsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
||||||
@@ -100,8 +115,13 @@ export default async function DevOpsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Current active connections
|
<Link
|
||||||
|
href="/admin/commandocentrum#online"
|
||||||
|
className="text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Live roster in CommandoCentrum
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const TEXTS_LIMIT = 300;
|
const TEXTS_LIMIT = 300;
|
||||||
|
|
||||||
export default async function AdminEmulator() {
|
export default async function AdminEmulator() {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { count, desc, eq, like } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { EventsTable } from "./events-table";
|
import { EventsTable } from "./events-table";
|
||||||
|
|
||||||
@@ -16,29 +18,44 @@ export default async function EventsPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.EVENTS_VIEW,
|
permission: PERMS.EVENTS_VIEW,
|
||||||
model: "websiteEvent",
|
fetch: async ({ search, page, perPage }) => {
|
||||||
searchFields: ["title"],
|
const [rows, totals] = await Promise.all([
|
||||||
defaultSort: { startsAt: "desc" },
|
db
|
||||||
include: { type: { select: { name: true, color: true } } },
|
.select({
|
||||||
mapRow: (e: {
|
id: WebsiteEvent.id,
|
||||||
id: number;
|
title: WebsiteEvent.title,
|
||||||
title: string;
|
startsAt: WebsiteEvent.startsAt,
|
||||||
type: { name: string; color: string };
|
endsAt: WebsiteEvent.endsAt,
|
||||||
startsAt: Date;
|
status: WebsiteEvent.status,
|
||||||
endsAt: Date | null;
|
hostUserId: WebsiteEvent.hostUserId,
|
||||||
status: string;
|
typeName: WebsiteEventType.name,
|
||||||
hostUserId: number;
|
typeColor: WebsiteEventType.color,
|
||||||
_count?: { registrations: number };
|
})
|
||||||
}) => ({
|
.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,
|
id: e.id,
|
||||||
title: e.title,
|
title: e.title,
|
||||||
typeName: e.type.name,
|
typeName: e.typeName ?? "",
|
||||||
typeColor: e.type.color,
|
typeColor: e.typeColor ?? "",
|
||||||
startsAt: e.startsAt,
|
startsAt: e.startsAt,
|
||||||
endsAt: e.endsAt,
|
endsAt: e.endsAt,
|
||||||
status: e.status,
|
status: e.status,
|
||||||
hostUserId: e.hostUserId,
|
hostUserId: e.hostUserId,
|
||||||
}),
|
})),
|
||||||
|
total: totals[0]?.total ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminGuildDetailPage({
|
export default async function AdminGuildDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { Shield } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,8 +11,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminGuildsPage({
|
export default async function AdminGuildsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -104,6 +104,7 @@ export default async function AdminGuildsPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell icon={Shield} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<main>
|
<main>
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||||
@@ -130,7 +131,7 @@ export default async function AdminGuildsPage({
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="admin-section-title">
|
<h2 className="admin-section-title">
|
||||||
{t("title")} ({total})
|
{t("listHeading", { count: total })}
|
||||||
</h2>
|
</h2>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="admin-empty">{t("empty")}</div>
|
<div className="admin-empty">{t("empty")}</div>
|
||||||
@@ -202,5 +203,6 @@ export default async function AdminGuildsPage({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function EditHelpQuestion({
|
export default async function EditHelpQuestion({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminHelpQuestions() {
|
export default async function AdminHelpQuestions() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
closeHelpCenterTicket,
|
closeHelpCenterTicket,
|
||||||
|
liftBanFromHelpTicket,
|
||||||
reopenHelpCenterTicket,
|
reopenHelpCenterTicket,
|
||||||
replyHelpCenterTicket,
|
replyHelpCenterTicket,
|
||||||
} from "@/actions/admin-help-tickets";
|
} from "@/actions/admin-help-tickets";
|
||||||
@@ -30,6 +31,7 @@ interface HelpTicketInfo {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
categoryName?: string | null;
|
||||||
creator: {
|
creator: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -42,10 +44,20 @@ export function AdminHelpTicketDetail({
|
|||||||
ticket,
|
ticket,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
canLiftBan = false,
|
||||||
|
hasActiveBan = false,
|
||||||
|
listHref = "/admin/help-tickets",
|
||||||
|
hideRequesterContact = false,
|
||||||
}: {
|
}: {
|
||||||
ticket: HelpTicketInfo;
|
ticket: HelpTicketInfo;
|
||||||
messages: ThreadMessage[];
|
messages: ThreadMessage[];
|
||||||
canEdit: boolean;
|
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 t = useTranslations("pages.admin.helpTickets");
|
||||||
const [messages, setMessages] = useState(initialMessages);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Link href="/admin/help-tickets">
|
<Link href={listHref}>
|
||||||
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
|
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -105,6 +124,12 @@ export function AdminHelpTicketDetail({
|
|||||||
<Badge variant={ticket.open ? "default" : "secondary"}>
|
<Badge variant={ticket.open ? "default" : "secondary"}>
|
||||||
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{ticket.categoryName ? (
|
||||||
|
<Badge variant="outline">{ticket.categoryName}</Badge>
|
||||||
|
) : null}
|
||||||
|
{hasActiveBan ? (
|
||||||
|
<Badge variant="destructive">{t("requesterBanned")}</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">{ticket.title}</h1>
|
<h1 className="text-2xl font-bold">{ticket.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,13 +228,24 @@ export function AdminHelpTicketDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{canEdit && (
|
{(canEdit || (canLiftBan && hasActiveBan)) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
|
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -219,7 +255,8 @@ export function AdminHelpTicketDetail({
|
|||||||
>
|
>
|
||||||
{t("closeSubmit")}
|
{t("closeSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : null}
|
||||||
|
{canEdit && !ticket.open ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -229,7 +266,7 @@ export function AdminHelpTicketDetail({
|
|||||||
>
|
>
|
||||||
{t("reopenSubmit")}
|
{t("reopenSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -241,7 +278,11 @@ export function AdminHelpTicketDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<Link
|
<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"
|
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||||
>
|
>
|
||||||
<User className="h-3.5 w-3.5" />
|
<User className="h-3.5 w-3.5" />
|
||||||
@@ -250,7 +291,7 @@ export function AdminHelpTicketDetail({
|
|||||||
{t("rankLabel", { rank: ticket.creator.rank })}
|
{t("rankLabel", { rank: ticket.creator.rank })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
{ticket.creator.mail ? (
|
{!hideRequesterContact && ticket.creator.mail ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Mail className="h-3 w-3" />
|
<Mail className="h-3 w-3" />
|
||||||
{ticket.creator.mail}
|
{ticket.creator.mail}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
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 { positiveBigInt } from "@/lib/api";
|
||||||
|
import { activeBanWhere } from "@/lib/bans";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const STAFF_RANK_THRESHOLD = 4;
|
|
||||||
|
|
||||||
export default async function AdminHelpTicketDetailPage({
|
export default async function AdminHelpTicketDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -18,6 +18,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.helpTickets");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const ticketId = positiveBigInt(id);
|
const ticketId = positiveBigInt(id);
|
||||||
if (!ticketId) notFound();
|
if (!ticketId) notFound();
|
||||||
@@ -27,6 +28,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
categoryId: true,
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
open: true,
|
open: true,
|
||||||
@@ -55,9 +57,9 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const users =
|
const [users, category, activeBan] = await Promise.all([
|
||||||
authorIds.length > 0
|
authorIds.length > 0
|
||||||
? await prisma.user.findMany({
|
? prisma.user.findMany({
|
||||||
where: { id: { in: authorIds } },
|
where: { id: { in: authorIds } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -66,10 +68,24 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
mail: true,
|
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 userById = new Map(users.map((u) => [u.id, u]));
|
||||||
const openerId = ticket.userId;
|
const openerId = ticket.userId;
|
||||||
|
const minStaffRank = await getMinStaffRank();
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
@@ -86,7 +102,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
...replies.map((r) => {
|
...replies.map((r) => {
|
||||||
const user = userById.get(r.userId);
|
const user = userById.get(r.userId);
|
||||||
const isStaff =
|
const isStaff =
|
||||||
r.userId !== openerId && (user?.rank ?? 0) >= STAFF_RANK_THRESHOLD;
|
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
|
||||||
return {
|
return {
|
||||||
key: String(r.id),
|
key: String(r.id),
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
@@ -101,8 +117,18 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
const creator = openerId != null ? userById.get(openerId) : undefined;
|
const creator = openerId != null ? userById.get(openerId) : undefined;
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
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 (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TicketQueueBanner
|
||||||
|
hint={t("queueHint")}
|
||||||
|
otherHref="/admin/tickets"
|
||||||
|
otherLabel={t("queueOtherCta")}
|
||||||
|
/>
|
||||||
<AdminHelpTicketDetail
|
<AdminHelpTicketDetail
|
||||||
ticket={{
|
ticket={{
|
||||||
id: String(ticket.id),
|
id: String(ticket.id),
|
||||||
@@ -110,6 +136,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
open: ticket.open,
|
open: ticket.open,
|
||||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||||
|
categoryName: category?.name ?? (isBanAppeal ? "Ban appeal" : null),
|
||||||
creator: creator
|
creator: creator
|
||||||
? {
|
? {
|
||||||
id: creator.id,
|
id: creator.id,
|
||||||
@@ -121,6 +148,9 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
}}
|
}}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
canLiftBan={canLiftBan}
|
||||||
|
hasActiveBan={!!activeBan}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,10 @@ export interface HelpTicketRow {
|
|||||||
|
|
||||||
export function HelpTicketsTable({
|
export function HelpTicketsTable({
|
||||||
data,
|
data,
|
||||||
|
basePath = "/admin/help-tickets",
|
||||||
}: {
|
}: {
|
||||||
data: PaginatedResult<HelpTicketRow>;
|
data: PaginatedResult<HelpTicketRow>;
|
||||||
|
basePath?: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.helpTickets");
|
const t = useTranslations("pages.admin.helpTickets");
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ export function HelpTicketsTable({
|
|||||||
#{row.id}
|
#{row.id}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/help-tickets/${row.id}`}
|
href={`${basePath}/${row.id}`}
|
||||||
className="font-medium truncate hover:underline"
|
className="font-medium truncate hover:underline"
|
||||||
>
|
>
|
||||||
{row.title}
|
{row.title}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
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 { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
|
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminHelpTicketsPage({
|
export default async function AdminHelpTicketsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -25,7 +24,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
const statusFilter = raw.filter_status || "open";
|
const statusFilter = raw.filter_status || "open";
|
||||||
|
|
||||||
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (statusFilter === "open") {
|
if (statusFilter === "open") {
|
||||||
conditions.push({ open: true });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
: { AND: conditions };
|
: { AND: conditions };
|
||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<string, any> = {
|
||||||
string,
|
|
||||||
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
|
||||||
> = {
|
|
||||||
title: { title: "asc" },
|
title: { title: "asc" },
|
||||||
open: { open: "desc" },
|
open: { open: "desc" },
|
||||||
user: { userId: "asc" },
|
user: { userId: "asc" },
|
||||||
@@ -79,9 +75,9 @@ export default async function AdminHelpTicketsPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[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 }).catch(() => 0),
|
||||||
prisma.websiteHelpCenterTickets
|
prisma.websiteHelpCenterTickets
|
||||||
.count({ where: { open: true } })
|
.count({ where: { open: true } })
|
||||||
@@ -89,6 +85,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
prisma.websiteHelpCenterTickets
|
prisma.websiteHelpCenterTickets
|
||||||
.count({ where: { open: false } })
|
.count({ where: { open: false } })
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
|
fetchTicketQueueOpenCounts(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
@@ -160,6 +157,15 @@ export default async function AdminHelpTicketsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { getAuditLogs } from "@/lib/services/audit";
|
import { getAuditLogs } from "@/lib/services/audit";
|
||||||
import { AuditSection, ExportSection } from "./client";
|
import { AuditSection, ExportSection } from "./client";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminHousekeeping({
|
export default async function AdminHousekeeping({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type IpRow = {
|
type IpRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
ipAddress: string;
|
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 { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
parseAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -86,6 +90,10 @@ async function Sidebar({
|
|||||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navConfig = parseAdminNavConfig(
|
||||||
|
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-admin
|
data-admin
|
||||||
@@ -115,6 +123,7 @@ async function Sidebar({
|
|||||||
<AdminSidebarNav
|
<AdminSidebarNav
|
||||||
allowedPermissions={allowedPermissions}
|
allowedPermissions={allowedPermissions}
|
||||||
isSuperAdmin={isSuperAdmin}
|
isSuperAdmin={isSuperAdmin}
|
||||||
|
navConfig={navConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { ClipboardList } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -8,8 +10,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { StaffActivitiesTable } from "./staff-activities-table";
|
import { StaffActivitiesTable } from "./staff-activities-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
|
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
|
||||||
when: "createdAt",
|
when: "createdAt",
|
||||||
staff: "userId",
|
staff: "userId",
|
||||||
@@ -97,6 +97,11 @@ export default async function AdminStaffActivities({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell
|
||||||
|
icon={ClipboardList}
|
||||||
|
title={t("title")}
|
||||||
|
subtitle={t("subtitle")}
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||||
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
||||||
@@ -123,5 +128,6 @@ export default async function AdminStaffActivities({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
"maintenance_enabled",
|
"maintenance_enabled",
|
||||||
"maintenance_message",
|
"maintenance_message",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { Store } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { cancelMarketplaceListing } from "@/actions/admin-marketplace";
|
import { cancelMarketplaceListing } from "@/actions/admin-marketplace";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -10,8 +12,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminMarketplacePage({
|
export default async function AdminMarketplacePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -69,6 +69,7 @@ export default async function AdminMarketplacePage({
|
|||||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<main>
|
<main>
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||||
@@ -96,14 +97,18 @@ export default async function AdminMarketplacePage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<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("activeListings")}
|
||||||
|
value={activeCount}
|
||||||
|
icon="🏪"
|
||||||
|
/>
|
||||||
<StatusCard label={t("soldListings")} value={soldCount} icon="✅" />
|
<StatusCard label={t("soldListings")} value={soldCount} icon="✅" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="admin-section-title">
|
<h2 className="admin-section-title">
|
||||||
{filter === "sold" ? t("soldListings") : t("activeListings")} ({total}
|
{filter === "sold" ? t("soldListings") : t("activeListings")} (
|
||||||
)
|
{total})
|
||||||
</h2>
|
</h2>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="admin-empty">{t("empty")}</div>
|
<div className="admin-empty">{t("empty")}</div>
|
||||||
@@ -173,5 +178,6 @@ export default async function AdminMarketplacePage({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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 { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { type CfhRow, CfhTable } from "./cfh-table";
|
import { type CfhRow, CfhTable } from "./cfh-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function CfhListPage({
|
export default async function CfhListPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -26,7 +23,7 @@ export default async function CfhListPage({
|
|||||||
const stateFilter =
|
const stateFilter =
|
||||||
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
|
||||||
|
|
||||||
const conditions: Prisma.SupportTicketsWhereInput[] = [];
|
const conditions: any[] = [];
|
||||||
if (stateFilter >= 0) {
|
if (stateFilter >= 0) {
|
||||||
conditions.push({ state: stateFilter });
|
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 === 0
|
||||||
? {}
|
? {}
|
||||||
: conditions.length === 1
|
: conditions.length === 1
|
||||||
? conditions[0]
|
? conditions[0]
|
||||||
: { AND: conditions };
|
: { AND: conditions };
|
||||||
|
|
||||||
const SORT_MAP: Record<
|
const SORT_MAP: Record<string, any> = {
|
||||||
string,
|
|
||||||
Prisma.SupportTicketsOrderByWithRelationInput
|
|
||||||
> = {
|
|
||||||
id: { id: "desc" },
|
id: { id: "desc" },
|
||||||
issue: { issue: "asc" },
|
issue: { issue: "asc" },
|
||||||
sender: { senderId: "asc" },
|
sender: { senderId: "asc" },
|
||||||
@@ -84,7 +78,7 @@ export default async function CfhListPage({
|
|||||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.SupportTicketsOrderByWithRelationInput;
|
} as any;
|
||||||
|
|
||||||
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
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 { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { OnlineTable } from "./online-table";
|
import { OnlineTable } from "./online-table";
|
||||||
|
|
||||||
@@ -12,25 +17,46 @@ export default async function OnlineUsersPage({
|
|||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_VIEW,
|
permission: PERMS.USERS_VIEW,
|
||||||
model: "user",
|
|
||||||
searchFields: ["username", "motto"],
|
|
||||||
baseWhere: { online: "1" },
|
|
||||||
defaultPerPage: 50,
|
defaultPerPage: 50,
|
||||||
select: {
|
fetch: async ({ search, page, perPage }) => {
|
||||||
id: true,
|
const conditions: (SQL | undefined)[] = [eq(User.online, "1")];
|
||||||
username: true,
|
if (search) {
|
||||||
motto: true,
|
conditions.push(
|
||||||
look: true,
|
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,
|
paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.online");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<AdminPageShell icon={Wifi} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground m-0">
|
||||||
Currently online users ({result.total} connected)
|
{t("connected", { count: result.total })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<OnlineTable
|
<OnlineTable
|
||||||
@@ -42,6 +68,6 @@ export default async function OnlineUsersPage({
|
|||||||
lastPage: result.lastPage,
|
lastPage: result.lastPage,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+10
-3
@@ -13,8 +13,6 @@ import { activeBanWhere } from "@/lib/bans";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type RankCount = { rank: number; count: number };
|
type RankCount = { rank: number; count: number };
|
||||||
|
|
||||||
export default async function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
@@ -83,13 +81,22 @@ export default async function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin/commandocentrum#online"
|
||||||
|
className="no-underline text-inherit"
|
||||||
|
>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("playersOnline")}
|
label={t("playersOnline")}
|
||||||
value={online}
|
value={online}
|
||||||
hint={dbOk ? t("totalAccounts", { count: users }) : t("databaseDown")}
|
hint={
|
||||||
|
dbOk
|
||||||
|
? `${t("totalAccounts", { count: users })} · live roster →`
|
||||||
|
: t("databaseDown")
|
||||||
|
}
|
||||||
state={dbOk ? "neutral" : "danger"}
|
state={dbOk ? "neutral" : "danger"}
|
||||||
icon={<Users size={18} />}
|
icon={<Users size={18} />}
|
||||||
/>
|
/>
|
||||||
|
</Link>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("articles")}
|
label={t("articles")}
|
||||||
value={dbOk ? 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