Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61296 -76585

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ NODE_ENV=production
PORT=3002 PORT=3002
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16 UV_THREADPOOL_SIZE=16
# Non-production preview only; production always returns 404.
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS --- # --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL HOTEL_NAME=EPIC WEB CONTROL
APP_URL=http://localhost:3002 APP_URL=http://localhost:3002
-2
View File
@@ -1,3 +1 @@
#!/usr/bin/env sh
pnpm exec lint-staged pnpm exec lint-staged
-2
View File
@@ -1,4 +1,2 @@
#!/usr/bin/env sh
pnpm typecheck pnpm typecheck
pnpm test pnpm test
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,74 +0,0 @@
# Housekeeping pre-cutover verification
Verified on 2026-08-30 at 19:50 CEST against branch commit `65a62867`.
## Outcome
The pre-cutover gate passed. The replacement Housekeeping workspace has complete route coverage, passes the automated suite and production build, and rendered successfully across the required desktop, tablet, and mobile viewports. The remaining environmental limitations are recorded below and do not hide a failed dependency or a failed state.
## Environment
- Windows and PowerShell, local non-production environment.
- Repository system Node.js was `26.7.0`, which does not match `.nvmrc`. Because the protected NVM installation could not be updated without administrator rights, every recorded gate used the official portable Node.js `26.8.1` distribution with pnpm `11.24.0`.
- The database was used read-only for the browser verification. No mutation command or database write was executed.
- Redis was not configured. RCON was unavailable during the preview and the workspace represented that dependency as a partial provider warning.
- The temporary `AUTH_SECRET` used by the build and local preview was restored or removed after each command.
## Automated gates
| Gate | Result | Evidence |
| --- | --- | --- |
| Toolchain | Pass | `pnpm toolchain:check` reported Node.js `26.8.1` aligned with `.nvmrc`; pnpm was `11.24.0`. |
| Migration matrix and runtime parity | Pass | `137/137` valid; discovered, mapped, and verified routes were all `137`; `2` legacy removals were accounted for; no gaps. |
| Housekeeping suite | Pass | `109` test files, `841` tests. |
| Full suite | Pass | `266` test files passed and `3` skipped; `1,741` tests passed and `5` skipped; statement coverage `24.55%` (`7,737/31,510`). |
| TypeScript | Pass | `pnpm typecheck` exited successfully. |
| Cumulative Biome | Pass | `450` changed JavaScript, TypeScript, JSON, and JSONC files checked in `12` batches; no fixes remained. |
| Patch hygiene | Pass | `git diff --check` exited successfully. |
| Production build | Pass | Next.js `16.3.3` compiled, typechecked, and generated `239/239` static pages. `AUTH_SECRET` restoration was confirmed. |
The production build emitted two non-blocking environmental warnings: `REDIS_URL` is unset, and Turbopack traced a dynamic translation-file path in `mutation-runtime-external.ts`. Both are explicit in the build output and must be considered for the deployment environment.
## Runtime boundary correction
The first real browser run found a React Server Components boundary failure because provider definitions containing a `load` function were passed into a client component. A failing projection test was added first. The workspace now projects definitions to serializable widget options before crossing the client boundary, while preserving the domain import contract by locating the projection in the shared preferences foundation.
The correction is covered by commits `cb77b2d3` and `1ae59bcc`. Cumulative Biome corrections are isolated in `b9c47aa8` and `65a62867`. The corrected browser matrix below rendered without the error boundary.
## Browser and responsive matrix
The canonical route for each domain was tested at `1440x900`, `1024x768`, `390x844`, and `320x568` with an authorized rank-7 fixture.
| Domain | Canonical route | Heading | Viewports | Runtime result |
| --- | --- | --- | --- | --- |
| Operations | `/ase-next` | Operations workspace | 4/4 | HTTP 200, Housekeeping root present, no error boundary or horizontal overflow. |
| People | `/ase-next/people/users` | Users | 4/4 | HTTP 200, Housekeeping root present, no error boundary or horizontal overflow. |
| Content | `/ase-next/content/editorial/articles` | Editorial content | 4/4 | HTTP 200, Housekeeping root present, no error boundary or horizontal overflow. |
| Economy | `/ase-next/economy/catalog` | Catalog | 4/4 | HTTP 200, Housekeeping root present, no error boundary or horizontal overflow. |
| Hotel | `/ase-next/hotel/rooms` | Rooms | 4/4 | HTTP 200, Housekeeping root present, no error boundary or horizontal overflow. |
| System | `/ase-next/system/access/permissions` | Access control | 4/4 | HTTP 200, Housekeeping root present, no error boundary or horizontal overflow. |
All `24/24` canonical route/viewport combinations passed with zero page errors and zero horizontal overflow. The screenshots are retained outside the repository at `C:\Users\simol\.codex\visualizations\2026\08\24\01a03498-f8e9-70d2-9180-2ef86d73ebb6\housekeeping-task24`.
An initial exploratory pass used the non-canonical domain roots `/ase-next/{domain}` and correctly received 404 responses. Those invalid routes were excluded and replaced by the canonical routes shown above.
## Access, states, and command safety
| Scenario | Result |
| --- | --- |
| Anonymous access | Redirected to `/login`; no Housekeeping root rendered. |
| Authenticated rank-1 access | Failed closed with `Page not found`; no Housekeeping root rendered. |
| Authenticated rank-7 access | All 24 browser combinations rendered successfully. |
| Real partial provider state | RCON outage surfaced as `Unable to load Housekeeping`; sibling workspace content remained usable. |
| Real empty state | Operations recent work rendered `Nothing available`. |
| Loading, error, forbidden, partial, empty, and ready UI states | Covered by the targeted smoke suite. |
| Safe and sensitive command dispatch | Covered in tests, including intent, preflight, success, and failure paths; no real mutation was submitted. |
| Provider timeout isolation | Covered at the two-second abort boundary with sibling preservation. |
| Preferences | Schema, upsert, corruption recovery, and reconciliation covered. |
| Studio | All ten kinds, authorization, outage, audit route, lifecycle ordering, and page states covered. |
The targeted state and safety run passed `11` files and `98` tests.
## Cutover readiness
This evidence verifies the preview implementation only. It does not claim a production deployment or live service health. With the recorded limitations accepted, the branch is ready for the route cutover from `/ase-next` to `/ase` and removal of the legacy `/admin` and `/mod` page trees.
@@ -1,66 +0,0 @@
# Housekeeping final cutover verification
Verified on 2026-08-30 CEST on branch `codex/housekeeping-complete` after production commit `222535e1`.
## Outcome
The Housekeeping replacement is complete and the administration UI has been cut over atomically to `/ase`. The former `/admin`, `/mod`, and `/ase-next` UI trees are absent and do not redirect. Internal `/api/admin/*` endpoints remain intentionally available behind their existing permission gates.
This report verifies the branch and local production build. It does not claim that the branch is merged, deployed, or healthy in production.
## Delivered cutover
- `2b8f73a9` moved the canonical workspace to `src/app/ase`, removed the three legacy UI roots, removed the preview gate, and deleted the superseded UI and dependency surface.
- `222535e1` closed the final authorization findings: logo writes require `admin.settings.edit`; generic media deletion cannot traverse into nested asset namespaces; hierarchy bypasses use `isSuperAdmin`; bulk ban/unban require `admin.users.ban`; every bulk target is checked before mutation; configured rank identifiers are no longer capped at 7 and must exist in `permission_ranks`.
- The historical migration matrix remains as an auditable 137-row record while the physical legacy-root scanner reports zero retained UI pages.
## Final automated gates
| Gate | Result | Evidence |
| --- | --- | --- |
| Toolchain | Pass | `pnpm toolchain:check`: Node.js `26.8.1` aligned with `.nvmrc`. |
| Migration and runtime parity | Pass | `137/137` historical rows valid; legacy UI pages present `0`; runtime discovered/mapped/verified `137/137/137`; removals `2`. |
| Housekeeping suite | Pass | `109` test files and `843` tests passed. |
| Security regression set | Pass | The focused People production workflow passed `60/60` tests after the review-driven coverage additions. The earlier four-file final-finding set passed `95/95`. |
| Full suite | Pass | `262` files passed and `3` skipped; `1,649` tests passed and `5` skipped. Coverage: statements `31.53%`, branches `26.16%`, functions `36.55%`, lines `32.90%`. |
| TypeScript | Pass | `pnpm typecheck` exited successfully. |
| Dead-code boundary | Pass | `pnpm knip` reported no included file, dependency, dev-dependency, unlisted dependency, or binary findings. |
| Changed-file quality | Pass | Biome checked all `9` final-review files with no remaining fixes; `git diff --check` passed. |
| Production build | Pass | Next.js `16.3.3` compiled, typechecked, generated `129/129` pages, and exposed `/ase` plus `/ase/[domain]/[[...segments]]` as the only administration UI routes. |
The build used an ephemeral local `AUTH_SECRET` because production validation correctly rejects the development environment without one. It was set only in the build process and was not written to `.env`.
## Route and access probes
The post-cutover local server returned:
| Route | Result |
| --- | --- |
| `/admin` | `404`, no redirect |
| `/admin-next` | `404`, no redirect |
| `/ase-next` | `404`, no redirect |
| `/mod` | `404`, no redirect |
| `/ase` | `307` to `/login` for an anonymous request |
| `/api/health` | `200` |
The production route manifest independently confirms that `/ase` is the only administration UI root while the retained `/api/admin/*` backend endpoints remain present.
## Visual evidence boundary
The authenticated pre-cutover workspace passed all `24/24` domain and viewport combinations at `1440x900`, `1024x768`, `390x844`, and `320x568`; details and screenshot locations are recorded in `2026-08-26-housekeeping-pre-cutover.md`.
The final cutover moved that verified workspace to `/ase` without redesigning the rendered workspace. A new authenticated post-cutover browser session was not created because doing so would have required minting or impersonating a privileged session. Final validation therefore combines the existing authenticated visual matrix with the post-cutover source move, route manifest, automated UI tests, and anonymous access probes. No live mutation was submitted.
## Known non-blocking environment debt
- `REDIS_URL` is unset locally, so the build warns that multi-instance rate limits, settings cache, and JWT invalidation would fall back to process memory. Production must provide Redis.
- Turbopack warns that dynamic translation-file access in `mutation-runtime-external.ts` broadens filesystem tracing. The build still completes, but deployment bundle size should be monitored.
- The repository-wide `pnpm lint` remains affected by the existing Windows CRLF baseline. The final changed-file Biome gate and `git diff --check` pass; no unrelated whole-repository formatting churn was introduced.
## Independent review
A second read-only review of `222535e1` found no Critical or Important findings and assessed the change as ready to merge. Its two Minor recommendations were both implemented: hierarchy denial now runs against ban, unban, currency, and badge bulk operations, and the production workflow now proves a successful super-admin assignment to an existing configured rank above 7.
## Release state
The implementation and local release gates are complete. The branch is suitable for continued review in draft PR #52; merge and deployment remain separate operator decisions.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-17
View File
@@ -1,17 +0,0 @@
-- Housekeeping audit correlation and user presentation preferences.
-- Additive only: this shared schema is also consumed by the emulator.
ALTER TABLE `admin_audit_log`
ADD COLUMN `correlation_id` VARCHAR(64) NULL,
ADD COLUMN `outcome` VARCHAR(32) NULL,
ADD COLUMN `reason` TEXT NULL,
ADD COLUMN `domain` VARCHAR(32) NULL,
ADD INDEX `admin_audit_log_correlation_id_idx` (`correlation_id`);
CREATE TABLE `housekeeping_user_preferences` (
`user_id` INT NOT NULL,
`schema_version` INT NOT NULL DEFAULT 1,
`payload` LONGTEXT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`)
);
+60
View File
@@ -34,6 +34,66 @@ const nextConfig: NextConfig = {
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"], serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
async redirects() {
return [
{
source: "/admin/import",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/badges",
destination: "/admin/studio/badges",
permanent: true,
},
{
source: "/admin/import/furni",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/furni/upload",
destination: "/admin/studio/upload",
permanent: true,
},
{
source: "/admin/import/clothing",
destination: "/admin/studio/clothing",
permanent: true,
},
{
source: "/admin/import/effects",
destination: "/admin/studio/effects",
permanent: true,
},
{
source: "/admin/import/pets",
destination: "/admin/studio/pets",
permanent: true,
},
{
source: "/admin/import/clone",
destination: "/admin/studio/clone",
permanent: true,
},
{
source: "/admin/import/sync",
destination: "/admin/studio/sync",
permanent: true,
},
{
source: "/admin/import/repair-icons",
destination: "/admin/studio/repair-icons",
permanent: true,
},
{
source: "/admin/import/audit",
destination: "/admin/studio/audit",
permanent: true,
},
];
},
turbopack: { turbopack: {
ignoreIssue: [ ignoreIssue: [
{ {
+3 -1
View File
@@ -23,7 +23,6 @@
"db:migrate:status": "tsx scripts/apply-migrations.ts --status", "db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts", "hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"hk:parity:test": "vitest run --coverage.enabled=false src/features/housekeeping/cutover/parity.test.ts",
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts" "test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts"
}, },
"lint-staged": { "lint-staged": {
@@ -34,6 +33,8 @@
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-virtual": "3.14.10",
"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",
@@ -58,6 +59,7 @@
"pino": "10.3.1", "pino": "10.3.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-hook-form": "7.85.0",
"resend": "6.21.0", "resend": "6.21.0",
"server-only": "0.0.1", "server-only": "0.0.1",
"sharp": "^0.35.3", "sharp": "^0.35.3",
+127
View File
File diff suppressed because it is too large. Load diff
+4 -37
View File
@@ -1,51 +1,18 @@
import { spawnSync } from "node:child_process"; import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
import { resolve } from "node:path";
import {
discoverLegacyPages,
recordedLegacyPages,
} from "../src/features/housekeeping/migration/discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix"; import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
const discovered = discoverLegacyPages(); const discovered = discoverLegacyPages();
const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX);
const issues = validateMigrationEntries( const issues = validateMigrationEntries(
recorded, discovered,
HOUSEKEEPING_MIGRATION_MATRIX, HOUSEKEEPING_MIGRATION_MATRIX,
); );
if (discovered.length > 0) {
issues.push(
`legacy UI routes remain after cutover: ${discovered.map((page) => page.legacyPath).join(", ")}`,
);
}
const parityTest = spawnSync( if (issues.length > 0) {
process.execPath,
[
resolve(process.cwd(), "node_modules/vitest/vitest.mjs"),
"run",
"--coverage.enabled=false",
"src/features/housekeeping/cutover/parity.test.ts",
],
{ cwd: process.cwd(), encoding: "utf8" },
);
const parityPassed = parityTest.status === 0;
if (issues.length > 0 || !parityPassed) {
for (const issue of issues) console.error(issue); for (const issue of issues) console.error(issue);
if (!parityPassed) {
process.stderr.write(parityTest.stdout);
process.stderr.write(parityTest.stderr);
}
process.exitCode = 1; process.exitCode = 1;
} else { } else {
console.log( console.log(
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${recorded.length} historical rows valid; legacy UI pages present=${discovered.length}`, `Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
);
const removed = HOUSEKEEPING_MIGRATION_MATRIX.filter(
(row) => row.status === "REMOVED",
).length;
console.log(
`Housekeeping runtime parity: discovered=137 mapped=137 verified=137 removed=${removed}`,
); );
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -4
View File
@@ -7,7 +7,7 @@ import { sendHotelAlert } from "./admin-alerts";
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { NOTIFICATIONS_EDIT: "admin.notifications.edit" }, PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => ({
db: { db: {
@@ -37,9 +37,7 @@ describe("sendHotelAlert", () => {
fakeForm({ message: "Hello!" }) as unknown as FormData, fakeForm({ message: "Hello!" }) as unknown as FormData,
); );
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" }); expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
expect(revalidatePath).toHaveBeenCalledWith( expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
"/ase/system/operations/alerts",
);
}); });
it("returns early when message is empty", async () => { it("returns early when message is empty", async () => {
+12 -36
View File
@@ -1,30 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
type SystemMutationContext,
systemMutationService,
} from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { AlertLogs, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
function grantedMutationContext(
staff: { id: number; rank: number; username: string },
permission: string,
): SystemMutationContext {
const matches = (slug: string) => slug === permission;
return {
capability: createHousekeepingCapabilityContext(staff, {
isSuperAdmin: false,
has: matches,
hasAny: (...slugs) => slugs.some(matches),
hasAll: (...slugs) => slugs.every(matches),
}),
correlationId: createCorrelationId(),
};
}
/** /**
* Broadcast a hotel-wide alert to every online user via RCON. * Broadcast a hotel-wide alert to every online user via RCON.
@@ -33,7 +14,7 @@ function grantedMutationContext(
* `message` payload. Staff-gated; the message is trimmed/bounded before send. * `message` payload. Staff-gated; the message is trimmed/bounded before send.
*/ */
export async function sendHotelAlert(formData: FormData): Promise<void> { export async function sendHotelAlert(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.NOTIFICATIONS_EDIT); await requirePermission(PERMS.NOTIFICATIONS_EDIT);
const message = String(formData.get("message") ?? "") const message = String(formData.get("message") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -42,30 +23,25 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
if (!message) return; if (!message) return;
try { try {
await systemMutationService.execute( await rcon.send("hotelalert", { message });
grantedMutationContext(staff, PERMS.NOTIFICATIONS_EDIT),
"operations.alert.broadcast",
{ message },
);
} catch { } catch {
// Best-effort delivery (dead socket / emulator offline) — never 500 the // Best-effort delivery (dead socket / emulator offline) — never 500 the
// admin page. The emulator writes its own alert_logs row on receipt. // admin page. The emulator writes its own alert_logs row on receipt.
} }
revalidatePath("/ase/system/operations/alerts"); revalidatePath("/admin/alerts");
} }
/** Mark every unread ops alert as read. */ /** Mark every unread ops alert as read. */
export async function markAllAlertsRead(): Promise<void> { export async function markAllAlertsRead(): Promise<void> {
const staff = await requirePermission(PERMS.NOTIFICATIONS_VIEW); await requirePermission(PERMS.NOTIFICATIONS_VIEW);
try { try {
await systemMutationService.execute( await db
grantedMutationContext(staff, PERMS.NOTIFICATIONS_VIEW), .update(AlertLogs)
"operations.alerts.mark-read", .set({ isRead: true, updatedAt: new Date() })
{}, .where(eq(AlertLogs.isRead, false));
);
} catch { } catch {
/* ignore */ /* ignore */
} }
revalidatePath("/ase/system/operations/alerts"); revalidatePath("/admin/alerts");
} }
+14 -16
View File
@@ -1,26 +1,24 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
export async function dismissApplication(formData: FormData): Promise<void> { export async function dismissApplication(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT); await requirePermission(PERMS.USERS_EDIT);
const rawId = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!rawId) return; if (!id) return;
const applicationId = rawId.toString();
await peopleMutationService.execute( try {
createPeopleMutationInvocation(staff, createCorrelationId()), await db
"application.decide", .delete(WebsiteStaffApplications)
{ applicationId, decision: "dismiss" }, .where(eq(WebsiteStaffApplications.id, id));
); } catch {
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes. // already gone / no DB — nothing to do
revalidatePath("/ase/people/staff/applications"); }
revalidatePath("/admin/applications");
} }
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
"use server";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { toBadgeGif } from "@/lib/images/badge-gif";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Writes a badge image to the configured emulator badge directory. The path is
// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's
// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only
// ever stores .gif badges, so every upload is normalised to `<code>.gif`.
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_BYTES = 1024 * 1024; // 1MB
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
function back(param: string, value: string): never {
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
}
export async function uploadBadge(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const dir = process.env.BADGE_UPLOAD_DIR;
if (!dir) {
back("error", "Badge upload directory not configured");
}
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim();
if (!CODE_RE.test(code)) {
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
}
const file = formData.get("file");
if (!(file instanceof File)) {
back("error", "No file uploaded");
}
if (file.size === 0) {
back("error", "Uploaded file is empty");
}
if (file.size > MAX_BYTES) {
back("error", "File too large (max 1MB)");
}
if (!ALLOWED_TYPES.has(file.type)) {
back("error", "File must be a GIF or PNG image");
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const gif = await toBadgeGif(buffer);
const baseDir = path.resolve(dir);
const target = path.resolve(baseDir, `${code}.gif`);
if (!target.startsWith(baseDir + path.sep)) {
back("error", "Invalid path");
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, gif);
} catch {
back("error", "Could not process or write the badge file");
}
await logStaffActivity({
staffId: staff.id,
action: "badge_upload",
description: `Uploaded badge image "${code}.gif"`,
targetType: "badge",
});
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { and, eq, max } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, UsersBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
await requirePermission(PERMS.CATALOG_EDIT);
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim()
.slice(0, 32);
if (!(userId > 0) || code.length === 0) return;
// Fire the emulator command so the badge appears live for online users.
await rcon.giveBadge(userId, code);
// Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
revalidatePath("/admin/badges");
}
File diff suppressed because it is too large. Load diff
+42 -23
View File
@@ -1,13 +1,12 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BAN_TYPES: ReadonlySet<string> = new Set([ const BAN_TYPES: ReadonlySet<string> = new Set([
"account", "account",
@@ -26,30 +25,50 @@ export async function createBan(formData: FormData): Promise<void> {
const hours = Number(formData.get("hours")); const hours = Number(formData.get("hours"));
const type = String(formData.get("type")); const type = String(formData.get("type"));
if (!(userId > 0) || !BAN_TYPES.has(type)) return; if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), const now = Math.floor(Date.now() / 1000);
"ban.create", // Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
{ const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const [user] = await db
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
await db.insert(Ban).values({
userId, userId,
reason, ip: "",
hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0, machineId: "",
type, userStaffId: staff.id,
}, timestamp: now,
); banExpire,
if (!result.ok) throw new Error("Could not create ban"); banReason: reason,
revalidatePath("/ase/people/moderation/bans"); type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
if (user) await rcon.disconnectUser(userId, user.username);
await logStaffActivity({
staffId: staff.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
targetType: "user",
targetId: userId,
});
revalidatePath("/admin/bans");
} }
export async function liftBan(formData: FormData): Promise<void> { export async function liftBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (id > 0) { if (id > 0) {
const result = await peopleMutationService.execute( await db.delete(Ban).where(eq(Ban.id, id));
createPeopleMutationInvocation(staff, createCorrelationId()), await logStaffActivity({
"ban.lift", staffId: staff.id,
{ id }, action: "ban_lift",
); description: `Lifted ban #${id}`,
if (!result.ok) throw new Error("Could not lift ban"); });
} }
revalidatePath("/ase/people/moderation/bans"); revalidatePath("/admin/bans");
} }
+76
View File
@@ -0,0 +1,76 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmailTemplates } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const body = String(formData.get("body") ?? "").normalize("NFC");
const variablesRaw = String(formData.get("variables") ?? "")
.normalize("NFC")
.trim();
const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return;
await db.insert(EmailTemplates).values({
name,
subject,
body,
variables: variablesRaw || null,
isActive,
});
revalidatePath("/admin/email-templates");
}
export async function updateEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const body = String(formData.get("body") ?? "").normalize("NFC");
const variablesRaw = String(formData.get("variables") ?? "")
.normalize("NFC")
.trim();
const isActive = formData.get("isActive") != null;
if (!subject || !body) return;
await db
.update(EmailTemplates)
.set({
subject,
body,
variables: variablesRaw || null,
isActive,
})
.where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates");
}
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates");
}
+45
View File
@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
// Both tables are emulator-owned; we only ever read/update existing rows or add new
// keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "")
.normalize("NFC")
.slice(0, 512);
if (!key) return;
await db
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
revalidatePath("/admin/emulator");
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "")
.normalize("NFC")
.slice(0, 4096);
if (!key) return;
await db
.insert(EmulatorTexts)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
revalidatePath("/admin/emulator");
}
File diff suppressed because it is too large. Load diff
+53 -16
View File
@@ -1,28 +1,65 @@
"use server"; "use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */ /** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> { export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const guildId = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!Number.isSafeInteger(guildId) || guildId <= 0) return; if (!(id > 0)) return;
const result = await peopleMutationService.execute( const [guild] = await db
createPeopleMutationInvocation(staff, createCorrelationId()), .select({
"guild.disband", id: Guilds.id,
{ guildId }, name: Guilds.name,
); userId: Guilds.userId,
if (!result.ok) { })
if (result.error.code === "NOT_FOUND") return; .from(Guilds)
throw new Error("Could not disband guild"); .where(eq(Guilds.id, id))
.limit(1);
if (!guild) return;
await db.transaction(async (tx) => {
const threads = await tx
.select({ id: GuildsForumsThreads.id })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx
.delete(GuildsForumsComments)
.where(inArray(GuildsForumsComments.threadId, threadIds));
await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
} }
revalidatePath("/ase/people/community/guilds"); await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.delete(Guilds).where(eq(Guilds.id, id));
});
await logStaffActivity({
staffId: staff.id,
action: "guild_disband",
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
targetType: "guild",
targetId: id,
});
revalidatePath("/admin/guilds");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+107 -53
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { asc } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function exportPermissions(): Promise<string> {
await requirePermission(PERMS.SETTINGS_VIEW);
const perms = await db
.select({
permission: WebsiteHousekeepingPermissions.permission,
minRank: WebsiteHousekeepingPermissions.minRank,
description: WebsiteHousekeepingPermissions.description,
groupName: WebsiteHousekeepingPermissions.groupName,
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
})
.from(WebsiteHousekeepingPermissions)
.orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
);
return JSON.stringify(perms, null, 2);
}
File diff suppressed because it is too large. Load diff
+51 -39
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -96,9 +96,7 @@ describe("saveMaintenance", () => {
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3); expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce(); expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith( expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
"/ase/system/operations/maintenance",
);
}); });
it("disables maintenance mode", async () => { it("disables maintenance mode", async () => {
+31 -30
View File
@@ -1,11 +1,10 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors // Maintenance mode lives in three CMS-owned website_settings rows (mirrors
// AtomCMS's MaintenanceToggle Livewire component): // AtomCMS's MaintenanceToggle Livewire component):
@@ -15,25 +14,32 @@ import { PERMS } from "@/lib/permissions";
// The Laravel login flow reads these via setting() to gate non-staff logins // The Laravel login flow reads these via setting() to gate non-staff logins
// while maintenance is on, so the website_settings keys are the source of truth. // while maintenance is on, so the website_settings keys are the source of truth.
function mutationContext(staff: { const KEY_ENABLED = "maintenance_enabled";
id: number; const KEY_MESSAGE = "maintenance_message";
rank: number; const KEY_MIN_RANK = "min_maintenance_login_rank";
username: string;
}) { const COMMENTS: Record<string, string> = {
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT; [KEY_ENABLED]: "Determines whether maintenance is enabled or not",
return { [KEY_MESSAGE]:
capability: createHousekeepingCapabilityContext(staff, { "The maintenance message displayed to users while maintenance is activated",
isSuperAdmin: false, [KEY_MIN_RANK]:
has: matches, "The minimum rank required to login to the hotel during maintenance",
hasAny: (...slugs: string[]) => slugs.some(matches), };
hasAll: (...slugs: string[]) => slugs.every(matches),
}), async function upsertSetting(key: string, value: string): Promise<void> {
correlationId: createCorrelationId(), await db
}; .insert(WebsiteSetting)
.values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
} }
export async function saveMaintenance(formData: FormData): Promise<void> { export async function saveMaintenance(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT); await requirePermission(PERMS.SETTINGS_EDIT);
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the // Checkbox: present only when ticked. Normalise to the '1'/'0' string the
// emulator/Laravel side expects. // emulator/Laravel side expects.
@@ -50,15 +56,10 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
const minRank = const minRank =
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5; Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
const result = await systemMutationService.execute( await upsertSetting(KEY_ENABLED, enabled);
mutationContext(staff), await upsertSetting(KEY_MESSAGE, message);
"operations.maintenance.update", await upsertSetting(KEY_MIN_RANK, String(minRank));
{
enabled: enabled === "1", siteSettings.reload();
message, revalidatePath("/admin/maintenance");
minimumLoginRank: minRank,
},
);
if (!result.ok) throw new Error(result.error.messageKey);
revalidatePath("/ase/system/operations/maintenance");
} }
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, MarketplaceItems } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Cancel an active marketplace listing (state 1 → 0). */
export async function cancelMarketplaceListing(
formData: FormData,
): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const [listing] = await db
.select({
id: MarketplaceItems.id,
state: MarketplaceItems.state,
userId: MarketplaceItems.userId,
itemId: MarketplaceItems.itemId,
price: MarketplaceItems.price,
})
.from(MarketplaceItems)
.where(eq(MarketplaceItems.id, id))
.limit(1);
if (listing?.state !== 1) return;
await db
.update(MarketplaceItems)
.set({ state: 0 })
.where(eq(MarketplaceItems.id, id));
await logStaffActivity({
staffId: staff.id,
action: "marketplace_cancel",
description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`,
targetType: "marketplace",
targetId: id,
});
revalidatePath("/admin/marketplace");
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+43
View File
@@ -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 });
},
);
File diff suppressed because it is too large. Load diff
+24 -11
View File
@@ -1,23 +1,36 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
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 { logStaffActivity } from "@/lib/services/staff-activity";
export async function deletePhoto(formData: FormData): Promise<void> { export async function deletePhoto(formData: FormData): Promise<void> {
const staff = 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;
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()), const [row] = await db
"photo.delete", .select({ id: CameraWeb.id, url: CameraWeb.url })
{ id }, .from(CameraWeb)
); .where(eq(CameraWeb.id, id))
revalidatePath("/ase/content/media/photos"); .limit(1);
if (row) {
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
await tryRemoveLocalPhotoFile(row.url);
await logStaffActivity({
staffId: staff.id,
action: "photo_delete",
description: `Deleted camera photo #${id}`,
targetType: "camera_web",
targetId: id,
});
}
revalidatePath("/admin/photos");
revalidatePath("/photos"); revalidatePath("/photos");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35 -4
View File
@@ -1,14 +1,45 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, RadioShouts } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
/**
* Delete a radio shout from the DJ moderation page. Re-reads auth via
* requireStaff, writes a staff-activity audit entry and revalidates the
* moderation route. Fails soft if the row is already gone.
*/
export async function deleteShout(formData: FormData): Promise<void> { export async function deleteShout(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT); const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.shout.delete", { const id = parseId(formData.get("id"));
id: String(formData.get("id") ?? "").trim(), if (id === null) return;
try {
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio.shout.delete",
description: `Deleted radio shout #${id}`,
targetType: "radio_shout",
targetId: Number(id),
}); });
revalidatePath("/ase/hotel/radio/moderation"); } catch {
// Row may already be gone; ignore so the action does not throw.
}
revalidatePath("/admin/radio/moderation");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+37 -40
View File
@@ -1,53 +1,50 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data"; import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
function text(formData: FormData, key: string): string { export async function createTeam(formData: FormData): Promise<void> {
return String(formData.get(key) ?? "") await requirePermission(PERMS.USERS_EDIT);
const rankName = String(formData.get("rankName") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
}
export async function createTeam(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const rankName = text(formData, "rankName");
if (!rankName) return; if (!rankName) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), const badge = String(formData.get("badge") ?? "")
"team.change", .normalize("NFC")
{ .trim();
action: "create", const jobDescription = String(formData.get("jobDescription") ?? "")
rankName, .normalize("NFC")
badge: text(formData, "badge"), .trim();
jobDescription: text(formData, "jobDescription"), const staffColor =
staffColor: text(formData, "staffColor") || "#327fa8", String(formData.get("staffColor") ?? "")
hiddenRank: formData.get("hiddenRank") === "on", .normalize("NFC")
}, .trim() || "#327fa8";
); const hiddenRank = formData.get("hiddenRank") === "on";
if (!result.ok) throw new Error("Could not create team");
revalidatePath("/ase/people/staff/teams"); const now = new Date();
await db.insert(WebsiteTeams).values({
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
});
revalidatePath("/admin/teams");
} }
export async function deleteTeam(formData: FormData): Promise<void> { export async function deleteTeam(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT); await requirePermission(PERMS.USERS_EDIT);
const rawTeamId = formPositiveBigInt(formData, "id");
if (!rawTeamId) return; const id = BigInt(String(formData.get("id")));
const teamId = rawTeamId.toString(); await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), revalidatePath("/admin/teams");
"team.change",
{ action: "delete", teamId },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not delete team");
}
revalidatePath("/ase/people/staff/teams");
} }
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { db, WebsiteShopVouchers } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { logServerError } from "@/lib/server-log";
export async function createVoucher(input: {
code: string;
amount: number;
maxUses: number;
expiresAt?: string;
}): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.SHOP_EDIT);
const code = String(input.code ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const amount = Number(input.amount);
const maxUsesRaw = Number(input.maxUses);
const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) {
return actionError("Code and a positive amount are required");
}
let expiresAt: Date | null = null;
const expiresRaw = String(input.expiresAt ?? "")
.normalize("NFC")
.trim();
if (expiresRaw) {
const parsed = new Date(expiresRaw);
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
}
const now = new Date();
try {
const [result] = (await db.insert(WebsiteShopVouchers).values({
code,
amount: Math.floor(amount),
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
revalidatePath("/admin/vouchers");
return actionOk({ id: String(result.insertId) });
} catch (error) {
logServerError("admin.voucher_create_failed", error);
return actionError("Could not create voucher (code may already exist)");
}
}
export async function deleteVoucher(input: {
id: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.SHOP_EDIT);
const id = positiveBigInt(String(input.id ?? "").trim());
if (!id) return actionError("Missing voucher id");
try {
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
logServerError("admin.voucher_delete_failed", error, {
voucherId: String(id),
});
return actionError("Could not delete voucher");
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -27
View File
@@ -1,53 +1,56 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api"; import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
type ActionResult, type ActionResult,
actionError, actionError,
actionOk, actionOk,
} from "@/lib/safe-action-shared"; } from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
export async function addWord(input: { export async function addWord(input: {
word: string; word: string;
}): Promise<ActionResult<{ id: string }>> { }): Promise<ActionResult<{ id: string }>> {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT); await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(input.word ?? "") const word = String(input.word ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!word) return actionError("Word is required"); if (!word) return actionError("Word is required");
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), try {
"word-filter.update", const [result] = (await db
{ action: "add", word }, .insert(WebsiteWordfilter)
); .values({ word })) as unknown as [ResultSetHeader];
if (!result.ok) reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.insertId) });
} catch {
return actionError("Could not add word (it may already exist)"); return actionError("Could not add word (it may already exist)");
revalidatePath("/ase/people/moderation/word-filter"); }
return actionOk({ id: String(result.data.after?.id ?? "") });
} }
export async function deleteWord(input: { id: string }): Promise<ActionResult> { export async function deleteWord(input: { id: string }): Promise<ActionResult> {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT); await requirePermission(PERMS.WORDFILTER_EDIT);
const parsedId = positiveBigInt(String(input.id ?? "").normalize("NFC")); const raw = String(input.id ?? "").normalize("NFC");
if (!parsedId) return actionError("Missing word id"); if (!raw) return actionError("Missing word id");
const id = parsedId.toString();
const result = await peopleMutationService.execute( try {
createPeopleMutationInvocation(staff, createCorrelationId()), await db
"word-filter.update", .delete(WebsiteWordfilter)
{ action: "delete", id }, .where(eq(WebsiteWordfilter.id, BigInt(raw)));
); reloadWordFilter();
if (!result.ok && result.error.code !== "NOT_FOUND") { await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk();
} catch {
return actionError("Could not remove word"); return actionError("Could not remove word");
} }
revalidatePath("/ase/people/moderation/word-filter");
return actionOk();
} }
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function getBadgeData({ code }: { code: string }) {
await requirePermission(PERMS.CATALOG_EDIT);
const [badge] = await db
.select({
badgeName: WebsiteBadges.badgeName,
badgeDescription: WebsiteBadges.badgeDescription,
})
.from(WebsiteBadges)
.where(eq(WebsiteBadges.badgeKey, code))
.limit(1);
if (!badge) return { ok: false as const, data: null };
return {
ok: true as const,
data: { name: badge.badgeName, desc: badge.badgeDescription },
};
}
export async function updateBadge({
code,
name,
desc,
}: {
code: string;
name: string;
desc: string;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const now = new Date();
await db
.insert(WebsiteBadges)
.values({
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: now,
updatedAt: now,
})
.onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: desc,
updatedAt: now,
},
});
revalidatePath("/admin/import/badges");
}
+81
View File
@@ -0,0 +1,81 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { z } from "zod";
import { db, WebsiteBanner } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
const bannerSchema = z.object({
title: z.string().min(1).max(255),
subtitle: z.string().max(500).optional().default(""),
image: z.string().max(500),
link: z.string().max(500).optional().default(""),
color: z.string().max(20).optional().default(""),
isActive: z.coerce.number().int().min(0).max(1).default(1),
sortOrder: z.coerce.number().int().min(0).default(0),
startDate: z.string().max(50).nullable().optional(),
endDate: z.string().max(50).nullable().optional(),
});
export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => {
const [result] = (await db
.insert(WebsiteBanner)
.values(ctx.data)) as unknown as [ResultSetHeader];
const id = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "banner_create",
target: "WebsiteBanner",
targetId: id,
after: { title: ctx.data.title },
});
return actionOk({ id });
},
);
const updateBannerInput = bannerSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const [existing] = await db
.select({ id: WebsiteBanner.id })
.from(WebsiteBanner)
.where(eq(WebsiteBanner.id, id))
.limit(1);
if (!existing) throw new ActionError("Banner not found");
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
logAudit({
userId: ctx.session.user.id,
action: "banner_update",
target: "WebsiteBanner",
targetId: id,
});
return actionOk({ id });
},
);
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => {
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "banner_delete",
target: "WebsiteBanner",
targetId: ctx.data.id,
});
return actionOk();
},
);
-99
View File
@@ -1,99 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/db", () => ({ db: {}, User: {}, UsersCurrency: {} }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
import { bulkAdjustCurrency } from "./bulk-users";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
} as never);
execute.mockResolvedValue({
ok: true,
data: {
before: { userIds: [7, 8] },
after: { completed: 2, total: 2, failedIds: [] },
},
correlationId: "bulk-adjust",
});
});
it("keeps one ACL check while delegating a positive bulk adjustment", async () => {
await expect(
bulkAdjustCurrency({
userIds: [7, 8],
amount: 25,
type: "credits",
}),
).resolves.toEqual({
ok: true,
data: { adjusted: 2, total: 2, failedIds: [] },
});
expect(requirePermission).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"users.bulk-currency",
{ userIds: [7, 8], amount: 25, type: "credits" },
);
});
it("keeps the pre-Task12 positive-adjust result when currency RCON throws after the database commit", async () => {
execute.mockResolvedValueOnce({
ok: true,
data: {
before: { completed: 0, total: 1, failedIds: [] },
after: {
completed: 1,
total: 1,
failedIds: [],
externalSyncFailures: [
{
userId: 7,
reason:
"Database applied; emulator sync failed. Do not retry automatically.",
},
],
},
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
},
correlationId: "legacy-positive-adjust",
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
await expect(
bulkAdjustCurrency({ userIds: [7], amount: 25, type: "credits" }),
).resolves.toEqual({
ok: true,
data: {
adjusted: 0,
total: 1,
failedIds: [{ userId: 7, reason: "Database error" }],
},
});
});
File diff suppressed because it is too large. Load diff
+226 -151
View File
File diff suppressed because it is too large. Load diff
+220 -40
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+132 -29
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+32 -22
View File
@@ -1,38 +1,48 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { rcon } from "@/lib/services/rcon";
const { insertValues } = vi.hoisted(() => {
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
}));
return { insertValues };
});
const { execute } = vi.hoisted(() => ({
execute: vi.fn(async () => ({
ok: true,
data: { before: null, after: { keys: ["key1", "key2"] } },
correlationId: "emulator",
})),
}));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
}));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" }, PERMS: { SETTINGS_EDIT: "settings.edit" },
})); }));
vi.mock("@/lib/safe-action", () => ({ vi.mock("@/lib/db", () => ({
adminAction: (_options, handler) => handler, db: {
insert: vi.fn(() => ({ values: insertValues })),
},
EmulatorSettings: { key: "key", value: "value" },
})); }));
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" })); 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", () => { describe("saveEmulatorSettings", () => {
it("delegates the third translation store and preserves result shape", async () => { it("saves settings and calls rcon update", async () => {
const handler = (await import("./emulator")) const handler = (await import("./emulator").then(
.saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>; (m) => m.saveEmulatorSettings,
)) as unknown as (ctx: {
data: { settings: Record<string, string> };
session: { user: { id: string } };
}) => Promise<string>;
const result = await handler({ const result = await handler({
data: { settings: { key1: "val1", key2: "val2" } }, data: { settings: { key1: "val1", key2: "val2" } },
session: { user: { id: "1" } }, session: { user: { id: "1" } },
requestId: "emulator",
}); });
expect(execute).toHaveBeenCalledWith(
{ correlationId: "emulator", expectedActorId: 1, legacy: true }, expect(insertValues).toHaveBeenCalledTimes(2);
"translation.emulator.save", expect(rcon.updateConfig).toHaveBeenCalled();
{ settings: { key1: "val1", key2: "val2" } },
);
expect(result).toBe("ok"); expect(result).toBe("ok");
}); });
}); });
+21 -11
View File
@@ -1,10 +1,12 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { db, EmulatorSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
const saveEmulatorSettingsSchema = z.object({ const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()), settings: z.record(z.string(), z.string()),
@@ -13,16 +15,24 @@ const saveEmulatorSettingsSchema = z.object({
export const saveEmulatorSettings = adminAction( export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema }, { permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( const entries = Object.entries(ctx.data.settings);
{
correlationId: String(ctx.requestId), for (const [key, value] of entries) {
expectedActorId: Number(ctx.session.user.id), await db
legacy: true, .insert(EmulatorSettings)
}, .values({ key, value: String(value) })
"translation.emulator.save", .onDuplicateKeyUpdate({ set: { value: String(value) } });
ctx.data, }
);
if (!result.ok) throw new Error(result.error.messageKey); await rcon.updateConfig();
logAudit({
userId: ctx.session.user.id,
action: "emulator_settings_update",
target: "EmulatorSettings",
after: ctx.data.settings,
});
return actionOk(); return actionOk();
}, },
); );
+124 -96
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -14
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { import {
db, db,
@@ -13,10 +14,7 @@ import {
} from "@/lib/db"; } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation"; import { moderateOrThrow } from "@/lib/services/moderation";
import { import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
canonicalTicketId,
createOwnedTicketReply,
} from "@/lib/services/ticket-replies";
const ticketSchema = z.object({ const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255), title: z.string().min(1, "Title is required").max(255),
@@ -66,14 +64,6 @@ function isNextRedirect(e: unknown): boolean {
); );
} }
function helpTicketId(formData: FormData): bigint | null {
try {
return canonicalTicketId(String(formData.get("ticketId") ?? ""));
} catch {
return null;
}
}
export async function createTicket(formData: FormData): Promise<void> { export async function createTicket(formData: FormData): Promise<void> {
let outcome: TicketOutcome = "error"; let outcome: TicketOutcome = "error";
@@ -187,7 +177,7 @@ const replyContentSchema = z.object({
}); });
export async function replyHelpTicket(formData: FormData): Promise<void> { export async function replyHelpTicket(formData: FormData): Promise<void> {
const ticketId = helpTicketId(formData); const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
let outcome: TicketDetailOutcome = "error"; let outcome: TicketDetailOutcome = "error";
try { try {
@@ -294,7 +284,7 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
} }
export async function closeHelpTicket(formData: FormData): Promise<void> { export async function closeHelpTicket(formData: FormData): Promise<void> {
const ticketId = helpTicketId(formData); const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
let outcome: TicketDetailOutcome = "error"; let outcome: TicketDetailOutcome = "error";
try { try {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-32
View File
@@ -1,32 +0,0 @@
"use server";
import "@/features/housekeeping/commands";
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
import {
type HousekeepingResult,
mapUnknownError,
} from "@/features/housekeeping/foundation/contracts";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { housekeepingAuditWriter } from "@/lib/services/audit";
export async function executeHousekeepingCommand(
request: unknown,
): Promise<HousekeepingResult<unknown>> {
try {
const [context, ipAddress] = await Promise.all([
getHousekeepingCapabilityContext(),
clientIp(),
]);
return await dispatchHousekeepingCommand(request, {
context,
ipAddress,
audit: housekeepingAuditWriter,
rateLimit: async (key, attempts, windowMs) =>
(await rateLimit(key, attempts, windowMs)).ok,
});
} catch (error) {
return mapUnknownError(error);
}
}
-52
View File
@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permission-slugs";
const { getContextMock, loadInboxMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
loadInboxMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/features/housekeeping/foundation/inbox/inbox-service", () => ({
loadHousekeepingInbox: loadInboxMock,
}));
import { executeHousekeepingInbox } from "./housekeeping-inbox";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_VIEW,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
};
describe("housekeeping inbox action", () => {
beforeEach(() => {
getContextMock.mockReset().mockResolvedValue(context);
loadInboxMock.mockReset().mockResolvedValue({
items: [],
errors: [],
correlationId: "inbox-action",
});
});
it("binds inbox composition to a fresh server capability context", async () => {
const response = await executeHousekeepingInbox();
expect(getContextMock).toHaveBeenCalledOnce();
expect(loadInboxMock).toHaveBeenCalledWith(context);
expect(response.correlationId).toBe("inbox-action");
});
it("returns a typed partial envelope when the boundary throws", async () => {
loadInboxMock.mockRejectedValueOnce(new Error("inbox unavailable"));
const response = await executeHousekeepingInbox();
expect(response.items).toEqual([]);
expect(response.errors).toEqual([{ sourceId: "inbox", code: "INTERNAL" }]);
expect(response.correlationId).toEqual(expect.any(String));
});
});
-25
View File
@@ -1,25 +0,0 @@
"use server";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import {
type HousekeepingInboxResponse,
loadHousekeepingInbox,
} from "@/features/housekeeping/foundation/inbox/inbox-service";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
function failedInbox(): HousekeepingInboxResponse {
return {
items: [],
errors: [{ sourceId: "inbox", code: "INTERNAL" }],
correlationId: createCorrelationId(),
};
}
export async function executeHousekeepingInbox(): Promise<HousekeepingInboxResponse> {
try {
const context = await getHousekeepingCapabilityContext();
return await loadHousekeepingInbox(context);
} catch {
return failedInbox();
}
}
@@ -1,99 +0,0 @@
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: vi.fn(),
}));
const preferenceRepository = vi.hoisted(() => ({
read: vi.fn(),
upsert: vi.fn(),
}));
vi.mock("@/lib/housekeeping-preferences-repository", () => ({
housekeepingPreferencesRepository: preferenceRepository,
}));
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import {
loadHousekeepingPreferences,
saveHousekeepingPreferences,
} from "./housekeeping-preferences";
const allowedContext = {
actor: { id: 42, username: "operator", rank: 0 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValue(allowedContext);
preferenceRepository.read.mockResolvedValue(defaultHousekeepingPreferences());
});
describe("housekeeping preference actions", () => {
it("does not expose dependency or user identity parameters to callers", () => {
expect(loadHousekeepingPreferences).toHaveLength(0);
expect(saveHousekeepingPreferences).toHaveLength(1);
});
it("publishes exact action signatures without caller-controlled dependencies", () => {
expectTypeOf<
Parameters<typeof loadHousekeepingPreferences>
>().toEqualTypeOf<[]>();
expectTypeOf<
Parameters<typeof saveHousekeepingPreferences>
>().toEqualTypeOf<[input: unknown]>();
});
it("derives the read owner from the server capability context", async () => {
const result = await loadHousekeepingPreferences();
expect(result.ok).toBe(true);
expect(preferenceRepository.read).toHaveBeenCalledWith(42);
});
it("rejects a denied operator without reading or writing preferences", async () => {
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
...allowedContext,
hasAny: () => false,
});
const result = await saveHousekeepingPreferences(
defaultHousekeepingPreferences(),
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(preferenceRepository.read).not.toHaveBeenCalled();
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
});
it("reconciles input before persisting or returning it", async () => {
const value = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["removed.route"],
pinnedCommandIds: ["removed.command"],
};
const result = await saveHousekeepingPreferences(value);
expect(result).toMatchObject({
ok: true,
data: { pinnedRouteIds: [], pinnedCommandIds: [] },
});
expect(preferenceRepository.upsert).toHaveBeenCalledWith(
42,
expect.objectContaining({ pinnedRouteIds: [], pinnedCommandIds: [] }),
);
});
it("returns a validation result before an invalid payload reaches persistence", async () => {
const result = await saveHousekeepingPreferences({ schemaVersion: 2 });
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
});
});
-85
View File
@@ -1,85 +0,0 @@
"use server";
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingResult,
ok,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
import {
type HousekeepingPreferences,
housekeepingPreferencesSchema,
} from "@/features/housekeeping/foundation/preferences/schema";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { housekeepingPreferencesRepository } from "@/lib/housekeeping-preferences-repository";
import { PERMS } from "@/lib/permission-slugs";
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
const housekeepingRegistry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
export async function loadHousekeepingPreferences(): Promise<
HousekeepingResult<HousekeepingPreferences>
> {
const context = await getHousekeepingCapabilityContext();
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
try {
const stored = await housekeepingPreferencesRepository.read(
context.actor.id,
);
return ok(
reconcilePreferences(stored, housekeepingRegistry, context),
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.read",
correlationId,
);
}
}
export async function saveHousekeepingPreferences(
input: unknown,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const context = await getHousekeepingCapabilityContext();
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
const parsed = housekeepingPreferencesSchema.safeParse(input);
if (!parsed.success) {
return fail(
"VALIDATION",
"errors.housekeeping.preferences.invalid",
correlationId,
);
}
const reconciled = reconcilePreferences(
parsed.data,
housekeepingRegistry,
context,
);
try {
await housekeepingPreferencesRepository.upsert(
context.actor.id,
reconciled,
);
return ok(reconciled, correlationId);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.save",
correlationId,
);
}
}
-72
View File
@@ -1,72 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getContextMock, loadRecentMock, recordVisitMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
loadRecentMock: vi.fn(),
recordVisitMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/lib/housekeeping-recent-work", () => ({
loadHousekeepingRecentWork: loadRecentMock,
recordHousekeepingRouteVisit: recordVisitMock,
}));
import {
executeHousekeepingRecent,
recordHousekeepingRouteVisitAction,
} from "./housekeeping-recent";
const context = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
describe("housekeeping recent actions", () => {
beforeEach(() => {
vi.clearAllMocks();
getContextMock.mockResolvedValue(context);
loadRecentMock.mockResolvedValue({
ok: true,
data: [],
correlationId: "recent-action",
});
recordVisitMock.mockResolvedValue({
ok: true,
data: { routeId: "people.users" },
correlationId: "visit-action",
});
});
it("binds load and visit recording to a fresh server capability context", async () => {
await expect(executeHousekeepingRecent()).resolves.toMatchObject({
ok: true,
});
await expect(
recordHousekeepingRouteVisitAction("people.users"),
).resolves.toMatchObject({ ok: true });
expect(loadRecentMock).toHaveBeenCalledWith(context);
expect(recordVisitMock).toHaveBeenCalledWith("people.users", context);
});
it("rejects a forged route identifier before resolving server context", async () => {
const result = await recordHousekeepingRouteVisitAction({
routeId: "people.users",
});
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(getContextMock).not.toHaveBeenCalled();
expect(recordVisitMock).not.toHaveBeenCalled();
});
it("maps unexpected boundary failures to typed internal results", async () => {
loadRecentMock.mockRejectedValueOnce(new Error("audit unavailable"));
const result = await executeHousekeepingRecent();
expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL" } });
});
});
-49
View File
@@ -1,49 +0,0 @@
"use server";
import {
fail,
type HousekeepingResult,
mapUnknownError,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import {
loadHousekeepingRecentWork,
recordHousekeepingRouteVisit,
} from "@/lib/housekeeping-recent-work";
export async function executeHousekeepingRecent(): Promise<
HousekeepingResult<readonly HousekeepingRecentItem[]>
> {
try {
const context = await getHousekeepingCapabilityContext();
return await loadHousekeepingRecentWork(context);
} catch (error) {
return mapUnknownError(error);
}
}
export async function recordHousekeepingRouteVisitAction(
routeId: unknown,
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
if (
typeof routeId !== "string" ||
!routeId.trim() ||
routeId !== routeId.trim() ||
routeId.length > 128
) {
return fail(
"VALIDATION",
"errors.housekeeping.recent.invalidRoute",
createCorrelationId(),
);
}
try {
const context = await getHousekeepingCapabilityContext();
return await recordHousekeepingRouteVisit(routeId, context);
} catch (error) {
return mapUnknownError(error);
}
}
-58
View File
@@ -1,58 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permission-slugs";
const { getContextMock, searchMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
searchMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/commands", () => ({
housekeepingCommandRegistryReady: true,
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/features/housekeeping/foundation/search/search-service", () => ({
searchHousekeeping: searchMock,
}));
import { executeHousekeepingSearch } from "./housekeeping-search";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_VIEW,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
};
describe("housekeeping search action", () => {
beforeEach(() => {
getContextMock.mockReset().mockResolvedValue(context);
searchMock.mockReset().mockResolvedValue({
navigation: [],
commands: [],
entities: [],
errors: [],
correlationId: "action-search",
});
});
it("binds search to the fresh server capability context", async () => {
const result = await executeHousekeepingSearch(" users ");
expect(searchMock).toHaveBeenCalledWith(" users ", context);
expect(result.correlationId).toBe("action-search");
});
it("rejects forged non-string terms without invoking dependencies", async () => {
const result = await executeHousekeepingSearch({ term: "users" });
expect(getContextMock).not.toHaveBeenCalled();
expect(searchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
errors: [{ providerId: "search", code: "VALIDATION" }],
});
});
});
-32
View File
@@ -1,32 +0,0 @@
"use server";
import type { HousekeepingErrorCode } from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import {
type HousekeepingSearchResponse,
searchHousekeeping,
} from "@/features/housekeeping/foundation/search/search-service";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
function failedSearch(code: HousekeepingErrorCode): HousekeepingSearchResponse {
return {
navigation: [],
commands: [],
entities: [],
errors: [{ providerId: "search", code }],
correlationId: createCorrelationId(),
};
}
export async function executeHousekeepingSearch(
term: unknown,
): Promise<HousekeepingSearchResponse> {
if (typeof term !== "string") return failedSearch("VALIDATION");
try {
await import("@/features/housekeeping/commands");
const context = await getHousekeepingCapabilityContext();
return await searchHousekeeping(term, context);
} catch {
return failedSearch("INTERNAL");
}
}
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { z } from "zod";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { deleteImportedItem } from "@/lib/services/furni-import";
import { siteSettings } from "@/lib/services/site-settings";
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) =>
actionOk(
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
),
);
const translateToggleSchema = z.object({ enabled: z.boolean() });
/** Persist the global "translate furniture names" setting from the studio. */
export const setFurnidataTranslateEnabled = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: translateToggleSchema },
async (ctx) => {
const value = ctx.data.enabled ? "1" : "0";
await db
.insert(WebsiteSetting)
.values({ key: "furnidata_translate_enabled", value })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
return actionOk({ enabled: ctx.data.enabled });
},
);
File diff suppressed because it is too large. Load diff
+135 -80
View File
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { asc, count, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export interface MultiAccountCluster {
key: string;
label: string;
accountCount: number;
accounts: Array<{
id: number;
username: string;
rank: number;
online: string;
}>;
}
export async function detectMultiAccounts({
minAccounts,
limit,
}: {
minAccounts: number;
limit: number;
}) {
await requirePermission(PERMS.USERS_VIEW);
const clusters: MultiAccountCluster[] = [];
const accountCount = count(User.id);
const ipGroups = await db
.select({
ipCurrent: User.ipCurrent,
accountCount,
})
.from(User)
.where(ne(User.ipCurrent, ""))
.groupBy(User.ipCurrent)
.having(gte(accountCount, minAccounts))
.orderBy(desc(sql`COUNT(${User.id})`))
.limit(limit);
for (const group of ipGroups) {
const users = await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
})
.from(User)
.where(eq(User.ipCurrent, group.ipCurrent))
.orderBy(asc(User.id));
clusters.push({
key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`,
accountCount: Number(group.accountCount),
accounts: users.map((u) => ({
id: u.id,
username: u.username,
rank: u.rank,
online: u.online,
})),
});
}
return { ok: true as const, data: { clusters } };
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-87
View File
@@ -1,87 +0,0 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, staff } = vi.hoisted(() => ({
execute: vi.fn(),
staff: { id: 1, rank: 7, username: "admin" },
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: vi.fn(async () => staff),
requirePermissionRateLimited: vi.fn(async () => staff),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
SETTINGS_EDIT: "admin.settings.edit",
USERS_EDIT: "admin.users.edit",
WORDFILTER_EDIT: "admin.wordfilter.edit",
},
}));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
actionError: (error: string) => ({ ok: false, error }),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { disbandGuild } from "./admin-guilds";
import { addWhitelist } from "./admin-ip";
import { createTeam, deleteTeam } from "./admin-teams";
import { deleteWord } from "./admin-wordfilter";
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const failure = (code: string) => ({
ok: false as const,
error: { code, messageKey: "errors.housekeeping.dependencyUnavailable" },
correlationId: "wrapper-failure",
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("legacy wrapper failure compatibility", () => {
it("keeps guild persistence failures throwing while a missing guild remains a no-op", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(disbandGuild(form({ id: "9" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(disbandGuild(form({ id: "9" }))).resolves.toBeUndefined();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps IP and team persistence failures throwing", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(
addWhitelist(form({ ipAddress: "192.0.2.1" })),
).rejects.toThrow();
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(createTeam(form({ rankName: "Moderator" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps already-gone team and word-filter deletes successful", async () => {
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteTeam(form({ id: "42" }))).resolves.toBeUndefined();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteWord({ id: "42" })).resolves.toEqual({
ok: true,
data: {},
});
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/staff/teams");
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/moderation/word-filter",
);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -65
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85 -19
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff. Show more