Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61296
-76585
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
pnpm exec lint-staged
|
pnpm exec lint-staged
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Task 10 report: System vertical
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
Delivered the real System access, configuration, observability, and operations vertical from base `0117b45d74450510187f8f860ee927a193c45a3c` on `codex/housekeeping-complete`.
|
|
||||||
|
|
||||||
- Registered the exact 17 System route IDs, canonical `/ase/system/*` hrefs, labels, and read capabilities from `migration/system.ts`.
|
|
||||||
- Added injected access, configuration, observability, and operations queries with forbidden, partial, and dependency-unavailable results.
|
|
||||||
- Extracted redirect-free, server-only, capability-guarded mutation services from the six legacy action modules while retaining their existing permission checks and `/admin` revalidation behavior.
|
|
||||||
- Registered 29 sensitive System commands through deterministic bootstrap, dispatcher authorization, confirmation, rate limiting, and audit. Reasons are mandatory for ACL/permission changes, global settings, alert broadcast, every RCON operation, and maintenance/global availability.
|
|
||||||
- Added four query-backed server workflow page modules with loading, empty, partial, error, forbidden, and ready states.
|
|
||||||
- Added the exact 17 System handlers to the global aggregate. The manifest contains real routes and deliberately keeps providers, search, inbox, and widgets empty for Task 19.
|
|
||||||
|
|
||||||
No database operation, deployment, push, pull request update, Task 11 work, `/admin` or `/mod` cutover, rank-threshold authorization, or placeholder workflow was performed. `.remember/remember.md` remained untracked and untouched.
|
|
||||||
|
|
||||||
## TDD evidence
|
|
||||||
|
|
||||||
All Vitest commands used `--coverage.enabled=false` so each RED/GREEN cycle exercised only the named boundary.
|
|
||||||
|
|
||||||
| Phase | Exact command | RED | GREEN |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Routes | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/routes.test.ts` | 1 file failed before tests: missing `./routes`. | 1 file, 3 tests passed. |
|
|
||||||
| Injected queries | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/queries/system-queries.test.ts` | 1 file failed before tests: missing `./access`. | 1 file, 6 tests passed. |
|
|
||||||
| Commands and guarded service | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/commands/system-commands.test.ts` | 1 file failed before tests: missing `../services/mutations`. | 1 file, 6 tests passed at the first command boundary. |
|
|
||||||
| Legacy alert and maintenance wrappers | `pnpm exec vitest run --coverage.enabled=false src/actions/admin-alerts.test.ts src/actions/admin-maintenance.test.ts` | 1 of 7 tests failed because the existing alert mock granted `notifications.edit` instead of the canonical `admin.notifications.edit`. | 2 files, 7 tests passed after correcting only the stale mock permission. |
|
|
||||||
| ACL wrapper extraction | `pnpm exec vitest run --coverage.enabled=false src/lib/admin/acl-management-contract.test.ts` | 1 of 2 tests failed before `access.permissions.update` was present. | 1 file, 2 tests passed after the wrapper delegated to the guarded service. |
|
|
||||||
| Workflow pages | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/pages/system-pages.test.tsx` | 1 file failed before tests: missing `./access`. | 1 file, 8 tests passed. |
|
|
||||||
| Handler/bootstrap integration | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/commands/bootstrap.test.ts` | 2 tests failed: System had 0 handlers instead of 17 and no 29-command bootstrap registration. | 2 files, 3 tests passed. |
|
|
||||||
| Review regressions | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/commands/system-commands.test.ts src/features/housekeeping/domains/system/services/mutations-production.test.ts src/lib/admin/acl-management-contract.test.ts` | 3 files failed; 11 tests failed and 6 passed. Failures proved missing alert reason, six whitespace-only inputs accepted, three alert dependency failures swallowed, and the public/non-strict production boundary. | 3 files, 17 tests passed after the minimal corrections. |
|
|
||||||
|
|
||||||
The first integrated target run passed 17 files and 179 tests. `pnpm typecheck` then exposed four integration-only type errors (a heterogeneous test tuple, a bigint alert identifier, and result-union narrowing); the corrected run passed. The first `pnpm test:housekeeping` exposed exactly three obsolete foundation assertions (40 files/372 tests otherwise passed). The three directly obsolete contracts were updated with strict positive System assertions without relaxing another domain; the focused rerun passed 2 files/38 tests and the then-current full suite passed 42 files/376 tests.
|
|
||||||
|
|
||||||
## Final verification
|
|
||||||
|
|
||||||
- `pnpm exec vitest run --coverage.enabled=false src/actions/admin-alerts.test.ts src/actions/admin-maintenance.test.ts src/lib/admin/acl-management-contract.test.ts src/features/housekeeping/domains/system/routes.test.ts src/features/housekeeping/domains/system/queries/system-queries.test.ts src/features/housekeeping/domains/system/commands/system-commands.test.ts src/features/housekeeping/domains/system/services/mutations-production.test.ts src/features/housekeeping/domains/system/pages/system-pages.test.tsx src/features/housekeeping/migration/system.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/commands/bootstrap.test.ts src/features/housekeeping/foundation/commands/registry.test.ts src/features/housekeeping/foundation/commands/dispatcher.test.ts src/features/housekeeping/foundation/commands/confirmation.test.ts src/features/housekeeping/foundation/commands/audit-envelope.test.ts src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/registry.test.ts` 17 files, 185 tests passed.
|
|
||||||
- `pnpm exec vitest run --coverage.enabled=false src/lib/admin-operations-contract.test.ts src/lib/staff-smoke-contract.test.ts src/lib/admin/authorization-contract.test.ts` 3 files, 97 tests passed.
|
|
||||||
- `pnpm test:housekeeping` 43 files, 385 tests passed.
|
|
||||||
- `pnpm typecheck` passed (`tsc --noEmit`).
|
|
||||||
- `pnpm exec biome check --formatter-enabled=false src/actions/admin-alerts.test.ts src/actions/admin-alerts.ts src/actions/admin-emulator.ts src/actions/admin-maintenance.ts src/actions/admin-settings.ts src/actions/commandocentrum.ts src/actions/permissions.ts src/features/housekeeping/domains/system src/features/housekeeping/foundation/commands/bootstrap.test.ts src/features/housekeeping/foundation/commands/bootstrap.ts src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/route-handlers.ts src/lib/admin/acl-management-contract.test.ts` checked 32 files; no fixes applied.
|
|
||||||
- `git diff --check` exit 0; only expected Git autocrlf warnings.
|
|
||||||
- `git diff --cached --check` exit 0 before staging and rerun after exact staging.
|
|
||||||
- Independent read-only re-review 0 Critical, 0 Important, 0 Minor; ready verdict.
|
|
||||||
|
|
||||||
Node/pnpm emitted this non-blocking warning during pnpm gates:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[WARN] Unsupported engine: wanted: {"node":">=26.8.1 <27"} (current: {"node":"v26.7.0","pnpm":"11.24.0"})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architectural decisions
|
|
||||||
|
|
||||||
- The migration matrix remains the single source of route truth. The System route array is materialized from its exact identifiers and values, and tests assert ordered route/handler equality rather than set-only coverage.
|
|
||||||
- Query factories accept narrow adapters; production adapters reuse existing ACL, settings, emulator, health, online-user, analytics, log, alert, and maintenance services. The fix round added only a strict online-roster helper beside the unchanged tolerant legacy API in `ops-online-users.ts`, so System can report a database outage truthfully.
|
|
||||||
- `systemMutationService` is the only public production mutation boundary. It is server-only and repeats capability enforcement even when called by an already-guarded legacy action or an authorized dispatcher. The unguarded production adapter is module-private.
|
|
||||||
- Adapter exceptions and unsuccessful RCON sends become typed `DEPENDENCY_UNAVAILABLE` failures. Rank-delete conflict metadata travels in the standard `fieldErrors` shape; the legacy wrapper reconstructs the prior human-readable `ActionError`, keeping the dispatcher result schema strict.
|
|
||||||
- Command string schemas use a non-transforming `\S` check to reject whitespace-only values; normalization and trimming remain at the guarded service boundary. This preserves the foundation registry rule that command schemas contain no executable transforms.
|
|
||||||
- System navigation labels use stable `pages.housekeeping.routes.system.*` keys. Complete source strings are present in the tested English and Italian catalogs; repository fallback remains responsible for other locales.
|
|
||||||
- Foundation source-boundary changes are a narrow source-to-import allowlist for the new System integration edges. Existing forbidden directions for every other domain remain asserted.
|
|
||||||
|
|
||||||
## Changed files
|
|
||||||
|
|
||||||
- `.superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md`
|
|
||||||
- `src/actions/admin-alerts.test.ts`
|
|
||||||
- `src/actions/admin-alerts.ts`
|
|
||||||
- `src/actions/admin-emulator.ts`
|
|
||||||
- `src/actions/admin-maintenance.ts`
|
|
||||||
- `src/actions/admin-settings.ts`
|
|
||||||
- `src/actions/commandocentrum.ts`
|
|
||||||
- `src/actions/permissions.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/commands/system-commands.test.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/commands/system-commands.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/manifest.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/access.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/configuration.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/observability.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/operations.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/system-pages.test.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/access.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/configuration.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/observability.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/operations.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/system-queries.test.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/route-handlers.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/routes.test.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/routes.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/services/mutations-production.test.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/services/mutations.ts`
|
|
||||||
- `src/features/housekeeping/foundation/commands/bootstrap.test.ts`
|
|
||||||
- `src/features/housekeeping/foundation/commands/bootstrap.ts`
|
|
||||||
- `src/features/housekeeping/foundation/foundation-source-contract.test.ts`
|
|
||||||
- `src/features/housekeeping/foundation/registry.test.ts`
|
|
||||||
- `src/features/housekeeping/route-handlers.test.ts`
|
|
||||||
- `src/features/housekeeping/route-handlers.ts`
|
|
||||||
- `src/lib/admin/acl-management-contract.test.ts`
|
|
||||||
|
|
||||||
## Official review fix round 1
|
|
||||||
|
|
||||||
The official review was addressed on exact base `3788ecd9f1a4e32e68abac5ee2dae3418cdebfb2`. The three parked Minor findings were deliberately left unchanged.
|
|
||||||
|
|
||||||
### Findings resolved
|
|
||||||
|
|
||||||
1. **Housekeeping label namespace:** all 17 System routes used legacy `pages.admin.*` keys, which the Task 9 preview layout correctly rejected. Routes now use 17 stable `pages.housekeeping.routes.system.*` keys, EN/IT provide non-empty source strings, and a preview-contract test builds and translates the real System navigation without broadening layout validation.
|
|
||||||
2. **Truthful partial/outage states:** access, configuration, and observability previously rendered empty before considering failed dependencies. Partial now takes precedence whenever any dependency failed. System online-user queries use a strict helper that exposes database failure; the existing tolerant `fetchOpsOnlineUsers` API and legacy behavior remain intact.
|
|
||||||
3. **Rank synchronization failures:** create, delete, and update no longer report success when `updatepermissions` returns false, and set-rank no longer reports success when RCON committed but database persistence failed. Both paths return the existing strict `DEPENDENCY_UNAVAILABLE` envelope with stable message keys and explicit `fieldErrors` describing `operation`, `completed`, and `pending` effects. Dispatcher audit records `intent` then `failure`, never `success`.
|
|
||||||
4. **Legacy permission error parity:** known rank-in-use and role-not-found conflicts retain their established `ActionError` text. Unknown infrastructure failures now cross the real `adminAction` boundary as ordinary errors and are sanitized to `Internal server error`; internal Housekeeping message keys are not exposed to the legacy UI.
|
|
||||||
|
|
||||||
### Fix-round TDD evidence
|
|
||||||
|
|
||||||
| Cycle | Exact command | RED | GREEN |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Labels and runtime navigation | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/routes.test.ts src/features/housekeeping/foundation/localization-contract.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts` | 3 files failed; 4 tests failed and 66 passed. The route labels mismatched, EN/IT lacked the routes subtree, and preview layout rejected `pages.admin.hubs.tabs.permissions`. | 3 files, 70 tests passed. |
|
|
||||||
| Partial precedence and online-user outage | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/pages/system-pages.test.tsx src/features/housekeeping/domains/system/queries/operations-production.test.ts` | 2 files failed; 4 tests failed and 9 passed. Three pages rendered empty, and the production adapter resolved a false ready zero-user state on database failure. | 2 files, 13 tests passed. |
|
|
||||||
| Rank synchronization | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts` | 1 file failed; 4 tests failed. Create/delete/update returned success after failed permission synchronization, and set-rank lacked explicit partial-completion metadata. | 1 file, 4 tests passed. The first post-production run had 3 passed and 1 test-only audit expectation failure; aligning it with the established `intent` then `failure` envelope produced the final GREEN without a further production change. |
|
|
||||||
| Legacy permission parity | `pnpm exec vitest run --coverage.enabled=false src/actions/permissions.test.ts` | 1 file failed; 1 test failed and 2 passed. The generic infrastructure case leaked `errors.housekeeping.dependencyUnavailable`; both known business conflicts already retained their prior text. | 1 file, 3 tests passed. |
|
|
||||||
|
|
||||||
The combined focused rerun passed 7 files and 90 tests. The first fix-round `pnpm typecheck` found two test-only narrowing errors in the new rank test; after the minimal annotations, its focused test remained green and `tsc --noEmit` passed.
|
|
||||||
|
|
||||||
### Fix-round verification
|
|
||||||
|
|
||||||
- Full affected Task 10 System, action, audit, authorization, bootstrap, localization, and preview suite: 22 files, 264 tests passed.
|
|
||||||
- Legacy operations, staff smoke, and authorization suite: 3 files, 97 tests passed.
|
|
||||||
- `pnpm test:housekeeping`: 45 files, 395 tests passed.
|
|
||||||
- `pnpm typecheck`: passed (`tsc --noEmit`).
|
|
||||||
- Exact changed-file `pnpm exec biome check --formatter-enabled=false ...`: checked 17 code, test, and locale files; no fixes applied after the one mechanical import-order correction.
|
|
||||||
- UTF-8 source verification confirmed the Italian `Analisi attività` label contains U+00E0, followed by a 3-file/70-test route-localization-preview GREEN rerun.
|
|
||||||
- `git diff --check` and the pre-stage `git diff --cached --check`: exit 0; only expected Git autocrlf warnings.
|
|
||||||
- Independent read-only re-review: 0 Critical, 0 Important, 0 new Minor; all four official Important findings resolved, all three parked Minors unchanged, ready-to-merge verdict.
|
|
||||||
|
|
||||||
### Fix-round changed files
|
|
||||||
|
|
||||||
- `.superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md`
|
|
||||||
- `src/actions/permissions.test.ts`
|
|
||||||
- `src/actions/permissions.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/access.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/configuration.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/observability.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/pages/system-pages.test.tsx`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/operations-production.test.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/queries/operations.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/routes.test.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/routes.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/services/mutations.ts`
|
|
||||||
- `src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts`
|
|
||||||
- `src/features/housekeeping/foundation/localization-contract.test.ts`
|
|
||||||
- `src/features/housekeeping/foundation/preview-route-contract.test.ts`
|
|
||||||
- `src/lib/admin/ops-online-users.ts`
|
|
||||||
- `src/messages/en.json`
|
|
||||||
- `src/messages/it.json`
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
# Task 11 — People workflow read models
|
|
||||||
|
|
||||||
Status: DONE — fix round 2
|
|
||||||
|
|
||||||
## Delivered scope
|
|
||||||
|
|
||||||
- Added the exact 24-route People catalog covering the 39 migration-matrix entries across users, multi-account review, online/community, guilds, staff applications/teams, support tickets/help tickets, CFH, moderation overview, bans, IP rules, VPN settings, and word filter workflows.
|
|
||||||
- Added canonical, JSON-serializable People DTOs, `/ase/people` link builders, bounded list normalization, and stable sorting with numeric-ID tie breaking.
|
|
||||||
- Added injected query factories and narrow server-only production adapters for user/detail, community/guild, staff/applications/teams, support queues/tickets/help/CFH, and moderation/bans/sanctions sources.
|
|
||||||
- Reused foundation `HousekeepingResult`, error codes, capability context, authorization, and canonical href contracts. People-local `ListInput` and `Page` were added because no shared foundation equivalents exist in this checkout.
|
|
||||||
- Kept the People manifest, global handlers, pages, mutations, providers, widgets, search, and inbox unchanged for Task 12.
|
|
||||||
|
|
||||||
## Security and behavior decisions
|
|
||||||
|
|
||||||
- User mail and current IP remain independently nullable fields. Each is projected only when the capability context contains the existing `PERMS.USERS_VIEW`; `PERMS.MOD_USERS_VIEW` alone receives the safe base projection with both values set to `null`, and a context with neither permission is forbidden.
|
|
||||||
- No new ACL slug or rank threshold was introduced. Staff filtering reuses the existing `getMinStaffRank()` source.
|
|
||||||
- The production user selection is explicit and excludes passwords, authentication tickets, secrets, and two-factor material. VPN settings intentionally exclude `vpn_api_key`.
|
|
||||||
- Adapters fail closed. Malformed driver envelopes, invalid identifiers, corrupt links, non-serializable DTO values, and count failures map to `DEPENDENCY_UNAVAILABLE`. Primary/entity identifiers remain positive safe integers; zero is accepted only for the schema-declared guild `userId`/`roomId` and CFH `senderId`/`reportedId`/`roomId`/`moderatorId` sentinels. Missing valid detail entities map to `NOT_FOUND`; invalid request identifiers map to `VALIDATION`.
|
|
||||||
- Pagination clamps page size to 100 and offset to 10,000. Deterministic primary sorting, numeric-ID tie breaking, and `LIMIT`/`OFFSET` now execute in the database; no list query fetches a prefix for locale re-sorting or second slicing.
|
|
||||||
- Raw production adapters and `buildPeopleUserSelection` are module-private. Runtime exports expose only context-authorized query factories and singleton query surfaces.
|
|
||||||
- Multi-account clusters use one bounded CTE/window page query plus one independent matching-cluster count query, cap accounts per cluster at 100, and never issue one query per IP cluster.
|
|
||||||
- User detail/edit now includes the operator's watched state and canonical permission-rank data. Support ticket reads use the existing unified inbox through a strict, fail-closed, database-paged mode that includes CMS and help-center rows while leaving the legacy tolerant mode unchanged.
|
|
||||||
- The unified `/support/tickets` inbox still merges CMS and help-center rows. The ticket desk now has its own strict CMS-only page loader preserving priority, category, assignee, and message count; help summaries include reply count. Support desk/detail DTOs explicitly include queue counts, bounded staff, and the relevant active ban.
|
|
||||||
- Active bans are filtered before sorting. Expiry `0` remains the permanent-active sentinel; expired rows cannot hide permanent or future-active bans in lists or details.
|
|
||||||
|
|
||||||
## Official fix round 1 findings
|
|
||||||
|
|
||||||
1. **Authorization boundary:** fixed by making all raw production adapters and the user selection builder module-private and testing only guarded public query surfaces plus source/runtime export contracts.
|
|
||||||
2. **Pagination and sorting:** fixed by moving declared sort fields, deterministic tie ordering, and bounded `LIMIT`/`OFFSET` to production adapters. The exact `user10`/`user2` and support `status`/`updatedAt` page regressions are covered.
|
|
||||||
3. **Resource bounds:** fixed by replacing multi-account prefix loading plus per-row `Promise.all` with one bounded batched CTE/window query. No million-row prefix and no N+1 cluster query remain.
|
|
||||||
4. **Fail-closed database validation:** fixed across People models and community/support/moderation query boundaries. Invalid driver shapes and invalid identifiers cannot become empty lists, `NOT_FOUND`, ID `0`, or corrupt canonical links.
|
|
||||||
5. **Canonical dependencies:** fixed watched and permission-rank data for user detail/edit; `/support/tickets` now calls strict `fetchUnifiedTicketInbox`; support queue/staff/active-ban data is explicit in canonical query DTOs.
|
|
||||||
6. **Moderation capability equality:** fixed after direct user authorization. `moderationQuery.capability` now exactly equals the existing eleven-slug overview union already used by the route and `run`; no route, mutation, rank threshold, or new slug changed.
|
|
||||||
7. **Active bans:** fixed list/detail selection so permanent `ban_expire = 0` and future-active bans are deterministic and expired rows cannot hide them.
|
|
||||||
|
|
||||||
The two official Minor findings remain parked and unchanged as instructed.
|
|
||||||
|
|
||||||
## Official fix round 2 findings
|
|
||||||
|
|
||||||
1. **Entity-aware sentinels:** canonical guild DTOs now use the real schema names `userId` and `roomId`. Serialization permits zero only on those two guild fields and the four named CFH fields when the containing DTO has the matching canonical entity href. Generic `*Id` zero values, negatives, unsafe integers, and primary ID zero remain unavailable failures.
|
|
||||||
2. **Support source fidelity:** `people.support.tickets` remains on strict `fetchUnifiedTicketInbox`. `people.support.ticket-desk` now dispatches to a distinct strict `website_tickets` loader with message aggregation and no help-center source. Real priority/category/assignee/message count and help reply count are present in canonical DTOs; malformed driver rows fail closed.
|
|
||||||
3. **Multi-account total:** the page CTE and matching-cluster count run as two bounded parallel queries. Empty pages retain the correct total without prefix loading or N+1 queries.
|
|
||||||
|
|
||||||
## Strict TDD evidence
|
|
||||||
|
|
||||||
### Cycle 1 — exact route catalog
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts
|
|
||||||
Test Files 1 failed
|
|
||||||
Error: Cannot find module './routes'
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 3 passed (3)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cycle 2 — canonical models and normalizers
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts
|
|
||||||
Test Files 1 failed
|
|
||||||
Error: Cannot find module './models'
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 5 passed (5)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cycle 3 — injected-adapter read queries
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts
|
|
||||||
Test Files 1 failed
|
|
||||||
Error: Cannot find module './community'
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 10 passed (10)
|
|
||||||
```
|
|
||||||
|
|
||||||
Production-source contract RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 2 failed (2)
|
|
||||||
Reason: raw production adapters and buildPeopleUserSelection were exported as bypassable runtime internals.
|
|
||||||
```
|
|
||||||
|
|
||||||
Pagination regression RED after adding the production contract fixture:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed | 2 passed (3)
|
|
||||||
Expected ["203.0.113.1", "203.0.113.2"], received ["203.0.113.2"].
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN for the original baseline implementation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 3 passed (3)
|
|
||||||
```
|
|
||||||
|
|
||||||
The query tests cover adversarial page size/offset/search, stable tie sorting, empty/missing entities, adapter and count failures, PII capability combinations, serializable DTOs, explicit source projection, and production pagination.
|
|
||||||
|
|
||||||
## Fix round 1 strict behavioral TDD evidence
|
|
||||||
|
|
||||||
### Authorization boundary
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 2 failed (2)
|
|
||||||
Observed runtime exports: buildPeopleUserSelection and peopleUsersAdapters.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 3 passed (3)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database pagination and declared sorting
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts
|
|
||||||
Test Files 2 failed (2)
|
|
||||||
Tests 4 failed | 14 passed (18)
|
|
||||||
Failures: offset 999999 was not capped; DB pages were sliced a second time for user10/user2 and support status/updatedAt.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts
|
|
||||||
Test Files 2 passed (2)
|
|
||||||
Tests 18 passed (18)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-account resource bounds
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed | 2 passed (3)
|
|
||||||
Observed prefix result plus one query per IP cluster.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 3 passed (3)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fail-closed validation
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 3 failed (3)
|
|
||||||
Tests 5 failed | 21 passed (26)
|
|
||||||
Failures covered ID 0, corrupt links/dates, corrupt detail shapes, and malformed driver envelopes.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
same command
|
|
||||||
Test Files 3 passed (3)
|
|
||||||
Tests 26 passed (26)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Canonical dependencies and unified support inbox
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "watched state|hydrates desk|strict unified"
|
|
||||||
Test Files 2 failed (2)
|
|
||||||
Tests 3 failed | 22 skipped (25)
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "watched state|hydrates desk|strict unified|normalizes BigInt"
|
|
||||||
Test Files 3 passed (3)
|
|
||||||
Tests 4 passed | 27 skipped (31)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Moderation capability equality
|
|
||||||
|
|
||||||
RED captured before direct authorization:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts -t "eleven-permission"
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed | 18 skipped (19)
|
|
||||||
Expected the route/run eleven-slug union; query metadata still contains six slugs.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN after the user directly authorized only this isolated metadata hunk:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts -t "eleven-permission"
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 1 passed | 18 skipped (19)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Active-ban semantics
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "permanent"
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed | 4 skipped (5)
|
|
||||||
Expected permanent expiresAt 0; received null.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
same command
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 1 passed | 4 skipped (5)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fix round 2 strict behavioral TDD evidence
|
|
||||||
|
|
||||||
### Entity-aware schema sentinels
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts -t "zero sentinels"
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 2 failed | 19 skipped (21)
|
|
||||||
Valid guild userId/roomId zero and CFH senderId/reportedId/moderatorId/roomId zero were rejected by generic identifier validation.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
same command
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 2 passed | 19 skipped (21)
|
|
||||||
```
|
|
||||||
|
|
||||||
### CMS-only ticket desk and help reply counts
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "CMS-only context loader|reply counts"
|
|
||||||
Test Files 2 failed (2)
|
|
||||||
Tests 2 failed | 28 skipped (30)
|
|
||||||
The desk received a help row with synthetic normal priority; help summary omitted replyCount.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
same command
|
|
||||||
Test Files 2 passed (2)
|
|
||||||
Tests 2 passed | 28 skipped (30)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Independent multi-account total
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "beyond the last page"
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed | 8 skipped (9)
|
|
||||||
Expected total 4 on the empty page; received 0.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
same command
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 1 passed | 8 skipped (9)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
|
||||||
Test Files 4 passed (4)
|
|
||||||
Tests 40 passed (40)
|
|
||||||
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/authorization.test.ts src/features/housekeeping/foundation/capability-context.test.ts src/features/housekeeping/foundation/server-capability-context.test.ts src/features/housekeeping/foundation/contracts/contracts.test.ts
|
|
||||||
Test Files 9 passed (9)
|
|
||||||
Tests 86 passed (86)
|
|
||||||
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 49 passed (49)
|
|
||||||
Tests 435 passed (435)
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
tsc --noEmit
|
|
||||||
Exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <7 exact changed Task 11 TypeScript files>
|
|
||||||
Checked 7 files. No fixes applied.
|
|
||||||
|
|
||||||
git diff --check
|
|
||||||
Exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `pnpm test:housekeeping` and `pnpm typecheck` emitted the environment warning: the repository requires Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. Tests and typecheck still exited successfully.
|
|
||||||
|
|
||||||
No database operation, deployment, push, or pull-request update was performed.
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
# Task 12 — People users, community, and staff workflows
|
|
||||||
|
|
||||||
Status: DONE
|
|
||||||
|
|
||||||
## Delivered scope
|
|
||||||
|
|
||||||
- Registered exactly the nine approved real People routes: users list/edit/multi-account/detail, community online/guilds/guild detail, and staff applications/teams. The remaining support and moderation routes stay catalogued in `routes.ts` but unregistered for Task 13.
|
|
||||||
- Added query-backed People pages with explicit loading, empty, partial, dependency-error, forbidden, and ready states. Links are canonical `/ase/people/*` links; optional mail/IP fields and mutation affordances remain absent unless their exact capability is present.
|
|
||||||
- Added the exact fourteen user command IDs plus the six stable People-owned IDs `people.guild.disband`, `people.application.decide`, `people.team.change`, `people.ip.action`, `people.vpn.configure`, and `people.word-filter.update`.
|
|
||||||
- Added bounded Zod command schemas, stable rate limits, dispatcher capability rechecks, confirmation metadata, a redirect-free server-only mutation service, and deterministic bootstrap registration.
|
|
||||||
- Extracted shared mutation behavior behind the existing actions while preserving the legacy action exports, exact ACLs, `/admin` revalidation, VPN redirect/fail-soft behavior, word-filter `ActionResult` shapes, already-gone delete semantics, and failure propagation where legacy persistence errors previously propagated.
|
|
||||||
- Added neutral EN/IT labels only for the nine runtime routes.
|
|
||||||
|
|
||||||
## Security and behavior decisions
|
|
||||||
|
|
||||||
- No ACL slug or route authorization rank threshold was added. Exact legacy capabilities remain authoritative: single ban/unban use `USERS_BAN`, reset-password uses `USERS_RESET_PASSWORD`, bulk/user/community/team/application operations use `USERS_EDIT`, IP/VPN use `SETTINGS_EDIT`, and word filter uses `WORDFILTER_EDIT`.
|
|
||||||
- The existing target hierarchy safeguard remains for legacy user mutations that previously used `guardRank`; alert remains capability-authorized without a new target-rank rule.
|
|
||||||
- Ordinary user edit and alert remain reason-free because their existing semantics are non-destructive. Sanctions, destructive operations, global/security changes, currency delivery, and bulk mutations require a nonblank dispatcher reason. Ban and bulk-ban operational reasons are also persisted with the mutation audit evidence.
|
|
||||||
- Every public service call rechecks the exact capability before production work. The production adapter is module-private; server-only placement is not treated as authorization.
|
|
||||||
- Successful mutations emit before/after audit evidence and a stable correlation ID. Audit persistence failure is fail-closed and maps to `DEPENDENCY_UNAVAILABLE`. User mutation audit snapshots omit mail because it is unnecessary PII; page projection continues to follow Task 11 exactly.
|
|
||||||
- User pages consume only Task 11 guarded read models, preserving bounded pagination, deterministic sorting, fail-closed DTO validation, serialization, zero-sentinel rules, watched state, permission context, and PII projection.
|
|
||||||
- Legacy wrappers remain on `/admin` behavior until Task 25. No `/admin`, `/mod`, API, redirect, database schema, deployment, or cutover behavior was changed.
|
|
||||||
|
|
||||||
## Strict TDD evidence
|
|
||||||
|
|
||||||
### Initial command/page/route RED
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/commands/user-commands.test.ts src/features/housekeeping/domains/people/commands/community-commands.test.ts src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx
|
|
||||||
Test Files 3 failed (3)
|
|
||||||
Tests 0
|
|
||||||
Missing modules: community-commands, ../services/mutations, ../route-handlers
|
|
||||||
```
|
|
||||||
|
|
||||||
Initial focused GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Command tests: 2 files passed, 22 tests passed
|
|
||||||
Primary page/route tests: 3 files passed, 12 tests passed
|
|
||||||
Bootstrap tests: 1 file passed, 2 tests passed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Foundation integration RED/GREEN
|
|
||||||
|
|
||||||
RED after enabling People:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation
|
|
||||||
Test Files 3 failed | 31 passed
|
|
||||||
Tests 4 failed | 376 passed
|
|
||||||
Failures: stale System-only registry assertions, stale preview expectation, and an unapproved People vertical runtime edge.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN after updating the explicit runtime-edge and registry contracts:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Focused foundation contracts: 3 files passed, 40 tests passed
|
|
||||||
People + foundation: 34 files passed, 380 tests passed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audited sanction reason
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/services/mutations-reason-production.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed (1)
|
|
||||||
The ban audit after-snapshot did not contain the nonblank sanction reason.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Production mutation contracts: 2 files passed, 4 tests passed
|
|
||||||
The audited snapshot includes the reason and excludes mail.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Legacy wrapper failure parity
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/actions/people-wrapper-errors.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 3 failed (3)
|
|
||||||
Persistence failures were swallowed and already-gone word-filter deletion was not idempotent.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 3 passed (3)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Single authorization check for positive bulk adjustment
|
|
||||||
|
|
||||||
RED:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/actions/bulk-adjust-wrapper.test.ts
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 failed (1)
|
|
||||||
Expected one requirePermission call; received two.
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 1 passed (1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Static gates during implementation
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm typecheck
|
|
||||||
RED: one unused `describe` import in admin-ip.test.ts
|
|
||||||
GREEN: tsc --noEmit, exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <exact Task 12 src files>
|
|
||||||
RED: 14 import-order assists
|
|
||||||
GREEN: checked 44 files, no fixes applied
|
|
||||||
```
|
|
||||||
|
|
||||||
## Final verification
|
|
||||||
|
|
||||||
```text
|
|
||||||
Focused wrapper/command/page/service/route/authorization/audit matrix
|
|
||||||
Test Files 22 passed (22)
|
|
||||||
Tests 129 passed (129)
|
|
||||||
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation
|
|
||||||
Test Files 35 passed (35)
|
|
||||||
Tests 381 passed (381)
|
|
||||||
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 54 passed (54)
|
|
||||||
Tests 469 passed (469)
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
tsc --noEmit
|
|
||||||
Exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <44 exact changed Task 12 src files>
|
|
||||||
Checked 44 files. No fixes applied.
|
|
||||||
|
|
||||||
git diff --check
|
|
||||||
Exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
The Node engine warning remains the approved non-blocker: the repository requests Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. All test and type gates exited successfully.
|
|
||||||
|
|
||||||
No database operation, deployment, push, or pull-request update was performed.
|
|
||||||
|
|
||||||
## Official review fix round 1
|
|
||||||
|
|
||||||
The official review reported 0 Critical and 5 Important findings. This round addresses the five findings without widening the Task 12 route catalog or changing legacy redirects, safe-action response shapes, or cutover behavior.
|
|
||||||
|
|
||||||
### RED evidence
|
|
||||||
|
|
||||||
```text
|
|
||||||
Production server authority: auth resolver was called 0 times at the public service boundary (1 failing regression).
|
|
||||||
Canonical external audit: 3 failing regressions for missing durable intent/outcome behavior.
|
|
||||||
Transactional audit: expected one transaction and observed zero (1 failing regression).
|
|
||||||
Legacy/production workflows: new production matrix initially exposed bulk truncation/deduplication, trade-lock hierarchy/state, missing StaffActivities, and missing word-filter refresh behavior.
|
|
||||||
Primary workflows: page suite started at 7 passed / 2 failed (no executable command form and no bounded URL parser); preview contract started at 61 passed / 3 failed (searchParams/loading propagation).
|
|
||||||
Import boundary after real forms: test:housekeeping reached 481 passed / 1 failed, then the focused boundary exposed one exact page-state -> People models edge (22 passed / 1 failed).
|
|
||||||
```
|
|
||||||
|
|
||||||
### GREEN implementation
|
|
||||||
|
|
||||||
- Production People services now rehydrate `getHousekeepingCapabilityContext()` on every public mutation. Invocation data can carry correlation and an expected actor only; it cannot synthesize permissions. The production adapter stays private, while test factories inject an authority resolver.
|
|
||||||
- Pure database mutations write their canonical before/after audit evidence in the same transaction. Mixed database/RCON/cache work writes sanitized intent first and a correlated success, failure, or partial outcome afterward. Audit-outcome persistence errors retain truthful completed/partial state, and legacy wrappers preserve their observable behavior.
|
|
||||||
- Ban/unban use observed active-ban state; trade-lock uses observed sanction/settings state. Passwords, hashes, API keys, and secrets are excluded from canonical evidence.
|
|
||||||
- Shared legacy bulk paths preserve original order, duplicates, totals, and iteration with no service-side 100-item cap. The <=100 bound remains in command schemas. Trade lock has no invented hierarchy gate and its missing-user wrapper message remains exactly `User not found`.
|
|
||||||
- Original `StaffActivities` side effects are retained for bulk ban/unban/currency/badge, guild disband, VPN, and trade lock. Missing word-filter deletion still reloads local cache, sends RCON refresh, and returns legacy success.
|
|
||||||
- The nine registered pages now expose capability-gated, accessible command forms backed by `executeHousekeepingCommand`; no inert command spans remain. Edit submits a mutation, list inputs come from bounded URL search parameters, and the dynamic preview route passes them through. Atomic Task 11 queries keep their fail-closed contracts; the impossible synthetic partial state was removed. A real Next loading route was added.
|
|
||||||
- The foundation contract allows only the exact same-domain edges required here: each People page to the shared People command form, the form to the single housekeeping command action, and page-state to the People `ListInput` model. No wildcard or prefix relaxation was introduced.
|
|
||||||
|
|
||||||
### Final verification after review fixes
|
|
||||||
|
|
||||||
```text
|
|
||||||
Focused wrapper/command/page/service/route/auth/audit/staff-smoke matrix
|
|
||||||
Test Files 24 passed (24)
|
|
||||||
Tests 187 passed (187)
|
|
||||||
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 58 passed (58)
|
|
||||||
Tests 482 passed (482)
|
|
||||||
|
|
||||||
pnpm test
|
|
||||||
Test Files 206 passed | 3 skipped (209)
|
|
||||||
Tests 1321 passed | 5 skipped (1326)
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
tsc --noEmit
|
|
||||||
Exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <40 exact changed Task 12 source files>
|
|
||||||
Checked 40 files. No fixes applied.
|
|
||||||
|
|
||||||
git diff --check e1b31ff7738eb5cc59e7765c8ed7290d62130972 --
|
|
||||||
Exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
|
|
||||||
## Official review fix round 2
|
|
||||||
|
|
||||||
The round-1 re-review reported 0 Critical, 7 Important, and no Minor findings. This round addresses all seven findings without changing the nine-route Task 12 manifest or exposing any raw production adapter.
|
|
||||||
|
|
||||||
### RED evidence
|
|
||||||
|
|
||||||
```text
|
|
||||||
Typed partial/result contract: 5 failed / 8 passed before completion metadata and audit-outcome handling were added.
|
|
||||||
Observed active-ban and absent-settings snapshots: 2 focused failures before deterministic active reads and null-preserving trade snapshots.
|
|
||||||
BIGINT preservation: 8 focused failures across application, team, IP, and wordfilter before decimal string/BigInt boundaries.
|
|
||||||
Real form/reset workflow: 2 primary-page failures before the actual action adapter and one-time credential result were added.
|
|
||||||
Production operation closure: 2 failed / 10 passed before unban observed-after and bulk false-RCON partial truth.
|
|
||||||
Post-commit notification/legacy parity: 2 failed / 12 passed before update/reset transactional intent and legacy throw/false mapping.
|
|
||||||
Cumulative gate exposed one unsupported custom Zod schema, one stale direct-alert expectation, and one stale numeric audit-ID expectation; each received a minimal regression-preserving fix.
|
|
||||||
```
|
|
||||||
|
|
||||||
### GREEN implementation
|
|
||||||
|
|
||||||
- Mixed database/external operations now commit sanitized intent with the mutation and return correlated typed `partial` completion when RCON, cache, notification, or final audit persistence fails afterward. Pre-mutation external failure and intent persistence failure remain blocking. The dispatcher and public server action preserve one serializable partial result and emit no contradictory generic failure evidence.
|
|
||||||
- Ban and unban reuse the permanent-or-unexpired Task 11 filter, deterministic timestamp/ID ordering, and observed before/after reads. An absent `UsersSettings` row remains null before and after a no-op trade settings update.
|
|
||||||
- Legacy alert calls RCON without a target query or hierarchy guard. Legacy wrappers retain their prior false/throw behavior while new Housekeeping commands report synchronization false as partial truth.
|
|
||||||
- Application, team, IP, and wordfilter identifiers remain canonical decimal strings/`BigInt` through wrappers and Drizzle, including values above `Number.MAX_SAFE_INTEGER`. The cloneable command regex accepts the full unsigned BIGINT range and rejects overflow without a Zod custom refinement.
|
|
||||||
- Reset-password returns the generated credential once in the current authorized form result. It is rendered through an accessible `output`, excluded from durable service evidence, and recursively redacted by the canonical audit sanitizer.
|
|
||||||
- Production tests execute all twenty Task 12 operation IDs with meaningful database/RCON/audit assertions. The primary-page test invokes the actual form action adapter, and the unused multi-accounts-to-command-form boundary exception was removed.
|
|
||||||
|
|
||||||
### Final verification after review fix round 2
|
|
||||||
|
|
||||||
```text
|
|
||||||
Focused People + foundation + wrappers + audit + action + staff-smoke matrix
|
|
||||||
Test Files 45 passed (45)
|
|
||||||
Tests 459 passed (459)
|
|
||||||
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 58 passed (58)
|
|
||||||
Tests 502 passed (502)
|
|
||||||
|
|
||||||
pnpm test
|
|
||||||
Test Files 206 passed | 3 skipped (209)
|
|
||||||
Tests 1345 passed | 5 skipped (1350)
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
tsc --noEmit
|
|
||||||
Exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <28 exact changed TypeScript/TSX files>
|
|
||||||
Checked 28 files. No fixes applied.
|
|
||||||
```
|
|
||||||
|
|
||||||
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
|
|
||||||
|
|
||||||
## Official review fix round 3
|
|
||||||
|
|
||||||
The round-2 re-review reported 0 Critical, 2 Important, and 2 adjacent Minor findings. This round addresses all four findings without changing the nine-route manifest, the public command IDs, or legacy external call ordering and permissions.
|
|
||||||
|
|
||||||
### RED evidence
|
|
||||||
|
|
||||||
```text
|
|
||||||
Focused external-audit, bulk-production, and dispatcher matrix
|
|
||||||
Test Files 2 failed (2)
|
|
||||||
Tests 15 failed | 54 passed (69)
|
|
||||||
|
|
||||||
The ten external-only false/throw cases persisted optimistic desired after-state instead of confirmed unchanged or unknown delivery evidence. Four bulk currency/badge false/throw cases reported completed=0 and Database error after a committed database write. The dispatcher accepted one ok:false result carrying impossible completion metadata.
|
|
||||||
```
|
|
||||||
|
|
||||||
### GREEN implementation
|
|
||||||
|
|
||||||
- External-only alert, disconnect, mute, unmute, and send-currency keep desired state in intent/success evidence. Confirmed RCON `false` now writes a dedicated unchanged/no-delivery failure snapshot; an exception writes unknown delivery with a null after-state. Correlation and failure outcome stay identical across intent/outcome records.
|
|
||||||
- Bulk currency and badge count a successful database write before RCON. RCON false/throw is additive `externalSyncFailures` sync debt, never a database failure; `failedIds` remains reserved for database/business failures and the result is typed partial with an explicit no-automatic-retry warning.
|
|
||||||
- Webhook notification remains explicit fire-and-forget best effort (`void notify(...)`) and no longer participates in mutation completion. The impossible promise-rejection test was replaced with the real void contract.
|
|
||||||
- The dispatcher runtime schema now accepts `completion` only for `ok: true`, matching the TypeScript `HousekeepingResult` contract; failure envelopes containing it are rejected as malformed.
|
|
||||||
|
|
||||||
### Final verification after review fix round 3
|
|
||||||
|
|
||||||
```text
|
|
||||||
Focused external audit + production bulk + dispatcher
|
|
||||||
Test Files 3 passed (3)
|
|
||||||
Tests 73 passed (73)
|
|
||||||
|
|
||||||
People + foundation + legacy wrappers + staff-smoke matrix
|
|
||||||
Test Files 48 passed (48)
|
|
||||||
Tests 470 passed (470)
|
|
||||||
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 58 passed (58)
|
|
||||||
Tests 516 passed (516)
|
|
||||||
|
|
||||||
pnpm test
|
|
||||||
Test Files 206 passed | 3 skipped (209)
|
|
||||||
Tests 1359 passed | 5 skipped (1364)
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
tsc --noEmit
|
|
||||||
Exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <4 exact changed source/test files>
|
|
||||||
Checked 4 files. No fixes applied.
|
|
||||||
|
|
||||||
git diff --check
|
|
||||||
Exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
|
|
||||||
|
|
||||||
## Official review fix round 4
|
|
||||||
|
|
||||||
The round-3 re-review reported 0 Critical, 2 Important, and 0 Minor findings. This round restores the pre-cutover legacy bulk result contract at the wrapper boundary and makes committed-database/emulator-sync debt explicit in the successful partial operator result. Canonical Housekeeping accounting, audit evidence, routes, commands, and ACLs remain unchanged.
|
|
||||||
|
|
||||||
### Pre-Task12 parity evidence
|
|
||||||
|
|
||||||
`git show e1b31ff7^:src/actions/bulk-users.ts` confirms that currency and badge wrappers awaited RCON inside the same `try`: an RCON exception entered the catch, did not increment `given`, and appended `{ userId, reason: "Database error" }`; an RCON `false` return did not throw and therefore remained a legacy success. Positive bulk adjustment delegated to the same currency wrapper and had the same result semantics.
|
|
||||||
|
|
||||||
### RED evidence
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/actions/bulk-users.test.ts src/actions/bulk-adjust-wrapper.test.ts
|
|
||||||
Test Files 2 failed (2)
|
|
||||||
Tests 3 failed | 3 passed (6)
|
|
||||||
Currency, badge, and positive-adjust wrappers returned given/adjusted=1 with no failedIds for the canonical external-sync debt produced by a thrown RCON call; historical results require 0 plus Database error.
|
|
||||||
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 2 failed | 12 passed (14)
|
|
||||||
The operator result rendered only Partially completed and exposed neither an alert/do-not-retry instruction nor the typed user sync debt returned by the real form adapter.
|
|
||||||
```
|
|
||||||
|
|
||||||
### GREEN implementation
|
|
||||||
|
|
||||||
- `src/actions/bulk-users.ts` translates only `externalSyncFailures` at the legacy wrapper boundary into historical `Database error` failures and subtracts those entries from `given`/positive `adjusted`. Canonical completed counts and sync-debt evidence are untouched; the existing legacy `false` path still produces no external-sync entry and remains successful. Failure entries are restored in input order, including duplicate IDs.
|
|
||||||
- `src/features/housekeeping/domains/people/pages/people-command-form.tsx` reads only a successful typed partial result with failed external completion and a bounded `after.externalSyncFailures` array. It renders an alert, explicit do-not-retry instruction, and safe user/reason debt entries. Unknown payload fields and malformed entries are never rendered, and the generated reset password path remains one-time and unchanged.
|
|
||||||
|
|
||||||
Focused GREEN:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/actions/bulk-users.test.ts src/actions/bulk-adjust-wrapper.test.ts
|
|
||||||
Test Files 2 passed (2)
|
|
||||||
Tests 6 passed (6)
|
|
||||||
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 14 passed (14)
|
|
||||||
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/actions/bulk-users.test.ts src/actions/bulk-adjust-wrapper.test.ts src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts
|
|
||||||
Test Files 4 passed (4)
|
|
||||||
Tests 48 passed (48)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cumulative verification
|
|
||||||
|
|
||||||
```text
|
|
||||||
People + foundation + Task12 legacy wrappers + route/audit/staff-smoke matrix
|
|
||||||
Test Files 52 passed (52)
|
|
||||||
Tests 491 passed (491)
|
|
||||||
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 58 passed (58)
|
|
||||||
Tests 518 passed (518)
|
|
||||||
|
|
||||||
pnpm test
|
|
||||||
Test Files 206 passed | 3 skipped (209)
|
|
||||||
Tests 1364 passed | 5 skipped (1369)
|
|
||||||
|
|
||||||
pnpm typecheck
|
|
||||||
tsc --noEmit
|
|
||||||
Exit 0
|
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false src/actions/bulk-users.ts src/actions/bulk-users.test.ts src/actions/bulk-adjust-wrapper.test.ts src/features/housekeeping/domains/people/pages/people-command-form.tsx src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx
|
|
||||||
Checked 5 files. No fixes applied.
|
|
||||||
|
|
||||||
git diff --check
|
|
||||||
Exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
The only warning is the approved Node engine mismatch: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`.
|
|
||||||
|
|
||||||
### Exact tracked paths
|
|
||||||
|
|
||||||
- `.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md`
|
|
||||||
- `src/actions/bulk-adjust-wrapper.test.ts`
|
|
||||||
- `src/actions/bulk-users.test.ts`
|
|
||||||
- `src/actions/bulk-users.ts`
|
|
||||||
- `src/features/housekeeping/domains/people/pages/people-command-form.tsx`
|
|
||||||
- `src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx`
|
|
||||||
|
|
||||||
The required controller lines were appended to the git-ignored `.superpowers/sdd/2026-08-26-housekeeping-completion/progress.md`; it is excluded from the commit. `.remember/` remains untouched.
|
|
||||||
|
|
||||||
### Self-review
|
|
||||||
|
|
||||||
- Scope and compatibility: the production mutation service, canonical audit/accounting, manifest, route, command, ACL, database, redirect, and cutover behavior are unchanged. The adapter applies only to legacy currency/badge results and their historical positive-adjust delegate.
|
|
||||||
- Security: the operator surface requires an `ok: true` partial/external-failed envelope, accepts at most 100 positive safe-integer user IDs, renders only the canonical safe reason, and does not inspect or serialize arbitrary result payloads. Reset-password display and audit redaction tests remain green.
|
|
||||||
- Test quality: legacy tests exercise the real exported wrappers against a complete canonical partial response and fail on either wrong count or missing historical failure; UI tests render the real component, invoke the real form adapter, and prove malformed/extra payload is not displayed.
|
|
||||||
|
|
||||||
### Commit
|
|
||||||
|
|
||||||
Single local commit message: `fix(housekeeping): restore people partial compatibility`. The final SHA of the commit containing this report is returned to the controller after creation.
|
|
||||||
|
|
||||||
No database operation, deployment, push, pull, or pull-request update was performed.
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
# Task 9 report — canonical route dispatch
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
- DONE: matcher, registry collision guard, empty handler aggregate, preview root routing, and catch-all dispatch are implemented.
|
|
||||||
- Base verified before edits: `2970dff56378cf5259fd125794bf0d89bec25b25` on `codex/housekeeping-complete`.
|
|
||||||
- Commit message: `feat(housekeeping): dispatch canonical domain routes`.
|
|
||||||
- `.remember/` remained untouched and untracked.
|
|
||||||
- No pull, push, PR/MR update, deployment, database operation, Task 10 work, or worktree was performed.
|
|
||||||
|
|
||||||
## TDD evidence
|
|
||||||
|
|
||||||
### RED — tests written before production
|
|
||||||
|
|
||||||
Exact command:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/routing/match-route.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed exit 1:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 4 failed (4)
|
|
||||||
Tests 1 failed | 14 passed (15)
|
|
||||||
|
|
||||||
Cannot find module './match-route'
|
|
||||||
Cannot find module './route-handlers'
|
|
||||||
Cannot find package '@/app/ase-next/[domain]/[[...segments]]/page'
|
|
||||||
|
|
||||||
registry > rejects duplicate dynamic route shapes regardless of parameter name
|
|
||||||
AssertionError: expected [Function] to throw an error
|
|
||||||
```
|
|
||||||
|
|
||||||
This proved the three missing production boundaries and the existing registry's acceptance of equivalent `:id` / `:username` route shapes.
|
|
||||||
|
|
||||||
### First targeted GREEN
|
|
||||||
|
|
||||||
The same exact command after the minimum implementation exited 0:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 4 passed (4)
|
|
||||||
Tests 94 passed (94)
|
|
||||||
```
|
|
||||||
|
|
||||||
An intermediate run had 92/94 passing because two import-boundary fixtures still used the old route file's relative depth. The fixture imports were moved one directory higher for the new catch-all location; the forbidden-module assertions were unchanged.
|
|
||||||
|
|
||||||
### Full-suite contract correction
|
|
||||||
|
|
||||||
The first full housekeeping run correctly exposed one obsolete Task 1 expectation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 1 failed | 37 passed (38)
|
|
||||||
Tests 1 failed | 348 passed (349)
|
|
||||||
Expected NEXT_REDIRECT:/ase-next/operations
|
|
||||||
Received NEXT_NOT_FOUND
|
|
||||||
```
|
|
||||||
|
|
||||||
`server-capability-context.test.ts` was updated to the Task 9 ruling: with the real registered route set still empty, `/ase-next` calls `notFound()` and must not redirect to an empty Operations placeholder. Its request-scoped context isolation assertions remain intact.
|
|
||||||
|
|
||||||
## Implemented behavior
|
|
||||||
|
|
||||||
- `matchHousekeepingRoute` compares decoded path segments without constructing a regular expression from route text.
|
|
||||||
- Static routes win over same-depth dynamic routes; dynamic and nested parameters are returned in a frozen readonly record.
|
|
||||||
- Unknown, cross-domain, malformed, repeated-separator, trailing-separator, query/fragment, invalid-percent, encoded-separator, dot-segment, and backslash paths fail closed.
|
|
||||||
- Registry construction rejects duplicate dynamic shapes even when parameter names differ.
|
|
||||||
- `HousekeepingPageInput` is exactly the readonly `{ context, match }` pair.
|
|
||||||
- The global route-handler aggregate is empty and its test proves one-to-one equality with the currently empty manifest route set; no placeholder handlers were added.
|
|
||||||
- `/ase-next` searches registered routes in manifest order, requires both domain and route capability, redirects to the first permitted route, and calls `notFound()` when none exists.
|
|
||||||
- `/ase-next/<domain>/<segments>` derives the domain's canonical `/ase` path, matches it, finds the exact handler, reacquires the cached request-scoped context, rechecks domain and route capability, and invokes the handler with that same context and match.
|
|
||||||
- Unknown routes fail before context loading; inaccessible matched routes load one context and never invoke a handler.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
Targeted routing/registry/handler/preview tests:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/routing/match-route.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts
|
|
||||||
Test Files 4 passed (4)
|
|
||||||
Tests 94 passed (94)
|
|
||||||
```
|
|
||||||
|
|
||||||
Directly affected context contract:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/server-capability-context.test.ts
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 2 passed (2)
|
|
||||||
```
|
|
||||||
|
|
||||||
Full housekeeping suite:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 38 passed (38)
|
|
||||||
Tests 349 passed (349)
|
|
||||||
```
|
|
||||||
|
|
||||||
TypeScript:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm typecheck
|
|
||||||
$ tsc --noEmit
|
|
||||||
exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
The only output note was the existing engine warning: local Node `26.7.0` is below the package request `>=26.8.1 <27`.
|
|
||||||
|
|
||||||
Targeted Biome with formatting disabled:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec biome check --formatter-enabled=false <11 changed Task 9 source/test files>
|
|
||||||
Checked 11 files in 47ms. No fixes applied.
|
|
||||||
```
|
|
||||||
|
|
||||||
`git diff --check` exited 0 before staging. Cached-diff and committed-tree checks are run as the final staging/commit gates.
|
|
||||||
|
|
||||||
## Exact Task 9 files
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/app/ase-next/[domain]/[[...segments]]/page.tsx
|
|
||||||
src/app/ase-next/[domain]/layout.tsx
|
|
||||||
src/app/ase-next/[domain]/page.tsx (deleted)
|
|
||||||
src/app/ase-next/page.tsx
|
|
||||||
src/features/housekeeping/foundation/preview-route-contract.test.ts
|
|
||||||
src/features/housekeeping/foundation/registry.test.ts
|
|
||||||
src/features/housekeeping/foundation/registry.ts
|
|
||||||
src/features/housekeeping/foundation/routing/match-route.test.ts
|
|
||||||
src/features/housekeeping/foundation/routing/match-route.ts
|
|
||||||
src/features/housekeeping/foundation/server-capability-context.test.ts
|
|
||||||
src/features/housekeeping/route-handlers.test.ts
|
|
||||||
src/features/housekeeping/route-handlers.ts
|
|
||||||
.superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-review and tooling
|
|
||||||
|
|
||||||
- Mutation check: dynamic-name normalization removal, regex-style static matching, static-priority removal, decoded-separator acceptance, domain mismatch acceptance, skipped route ACL, context reload inside the handler, missing handler lookup, placeholder handler addition, and empty-domain redirect each break a focused test.
|
|
||||||
- The catch-all route imports only housekeeping foundation/manifests/handlers and retains the existing forbidden database/auth/permissions/actions/legacy-page boundary audit.
|
|
||||||
- `apply_patch` created all new files, but the Windows sandbox helper repeatedly failed to read existing files with `apply deny-read ACLs`. Existing-file edits therefore used controller-approved exact-anchor/full-file fallbacks only after resolving absolute paths and validating every target under `E:\Users\simol\Desktop\EpicNext-cms`.
|
|
||||||
|
|
||||||
## Fix Round 1 — deterministic encoded route matching
|
|
||||||
|
|
||||||
### Status and scope
|
|
||||||
|
|
||||||
- Fix base: `e5c230ba35aec2b4c471608d32b8a16ecc1ee382`.
|
|
||||||
- Only the two Important matcher blockers were addressed.
|
|
||||||
- The three parked Minor findings remain unchanged; no changed line required an adjustment to them.
|
|
||||||
- Commit message: `fix(housekeeping): make route matching deterministic`.
|
|
||||||
- `.remember/` remained untouched. No worktree, push, PR/MR, database operation, deployment, or Task 10 work was performed.
|
|
||||||
|
|
||||||
### Root-cause evidence
|
|
||||||
|
|
||||||
1. The catch-all receives decoded Next segments and re-encodes them with `encodeURIComponent`. The matcher decoded the request path into `decodedSegments` but compared static route text against `rawSegments`. Therefore literal `a+b[1]` did not equal `a%2Bb%5B1%5D`; the competing `:id` route captured the request and could change the selected capability/handler.
|
|
||||||
2. Candidate sorting used only total dynamic-segment count. Intersecting patterns `/:kind/settings` and `/users/:id` have the same count, so stable sort preserved manifest order and allowed registration order to decide dispatch.
|
|
||||||
|
|
||||||
### RED
|
|
||||||
|
|
||||||
Exact command after test setup was validated:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/routing/match-route.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed exit 1:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 2 failed | 2 passed (4)
|
|
||||||
Tests 2 failed | 95 passed (97)
|
|
||||||
|
|
||||||
catch-all encoded literal:
|
|
||||||
Expected routeId people.literal-tool with params {}
|
|
||||||
Received routeId people.tool-detail with params { id: "a+b[1]" }
|
|
||||||
|
|
||||||
equal-count specificity:
|
|
||||||
Expected routeId people.user-detail with params { id: "settings" }
|
|
||||||
Received routeId people.kind-settings with params { kind: "users" }
|
|
||||||
```
|
|
||||||
|
|
||||||
The registration-order table exercises both orders. Before production changes the general-first order failed while the reverse order passed, proving that order was the deciding variable.
|
|
||||||
|
|
||||||
An earlier RED attempt exposed a test-table setup error (`manifest.routes is not iterable`); the table was changed from spread array rows to named `{ routes }` rows, then rerun to obtain the behavioral RED above before any production edit.
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- A single `decodeCanonicalSegment` boundary now normalizes request segments and static route-pattern segments exactly once.
|
|
||||||
- Invalid percent encoding, empty values, decoded `/` or `\`, and decoded `.` / `..` remain fail closed.
|
|
||||||
- Dynamic markers retain their parameter names and receive the already-decoded request segment.
|
|
||||||
- Candidate specificity is compared left-to-right. At the earliest static/dynamic difference, the static segment wins; manifest order no longer selects among intersecting patterns.
|
|
||||||
- Existing static-over-dynamic behavior and registry duplicate-shape rejection remain unchanged.
|
|
||||||
|
|
||||||
### GREEN and pre-commit verification
|
|
||||||
|
|
||||||
Targeted command above, exit 0:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Test Files 4 passed (4)
|
|
||||||
Tests 97 passed (97)
|
|
||||||
```
|
|
||||||
|
|
||||||
Full housekeeping suite, exit 0:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm test:housekeeping
|
|
||||||
Test Files 38 passed (38)
|
|
||||||
Tests 352 passed (352)
|
|
||||||
```
|
|
||||||
|
|
||||||
TypeScript, exit 0:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm typecheck
|
|
||||||
$ tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
The only output note remained the existing Node warning: local `26.7.0`, package request `>=26.8.1 <27`.
|
|
||||||
|
|
||||||
Exact changed-file Biome, exit 0:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pnpm exec biome check --formatter-enabled=false src/features/housekeeping/foundation/routing/match-route.ts src/features/housekeeping/foundation/routing/match-route.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts
|
|
||||||
Checked 3 files in 25ms. No fixes applied.
|
|
||||||
```
|
|
||||||
|
|
||||||
`git diff --check` exited 0 before the report update. Cached and committed-tree checks are final staging/commit gates.
|
|
||||||
|
|
||||||
### Exact fix files
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/features/housekeeping/foundation/routing/match-route.ts
|
|
||||||
src/features/housekeeping/foundation/routing/match-route.test.ts
|
|
||||||
src/features/housekeeping/foundation/preview-route-contract.test.ts
|
|
||||||
.superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md
|
|
||||||
```
|
|
||||||
@@ -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
@@ -1,422 +0,0 @@
|
|||||||
# Housekeeping Completion and Atomic Cutover Design
|
|
||||||
|
|
||||||
**Status:** Approved in conversation on 2026-08-26
|
|
||||||
**Delivery branch:** `codex/housekeeping-complete`
|
|
||||||
**Delivery shape:** one final pull request
|
|
||||||
**Cutover:** atomic, with no compatibility redirects
|
|
||||||
|
|
||||||
## Relationship to the existing design
|
|
||||||
|
|
||||||
This specification completes the program described by
|
|
||||||
`2026-08-24-housekeeping-modernization-design.md` after the merged Inventory &
|
|
||||||
Foundation subproject. The existing foundation is not the finished product: it
|
|
||||||
provides the 137-route migration matrix, capability-aware contracts, validated
|
|
||||||
domain manifests, shell primitives, and a non-production preview.
|
|
||||||
|
|
||||||
This document defines the remaining implementation and the final cutover. Where
|
|
||||||
delivery details differ, this document is authoritative for phases 02 onward.
|
|
||||||
The master architecture remains authoritative for domain ownership and product
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
This completion specification supersedes the earlier documents only for the
|
|
||||||
route namespace: the new surface uses `/ase`, never `/admin`, as its canonical
|
|
||||||
production root.
|
|
||||||
|
|
||||||
## Approved decisions
|
|
||||||
|
|
||||||
- Build complete verticals behind the existing non-production gate.
|
|
||||||
- Keep all remaining work on one branch and deliver it through one final pull
|
|
||||||
request.
|
|
||||||
- Implement in vertical slices rather than UI-first placeholders.
|
|
||||||
- Keep current `/admin` and `/mod` behavior unchanged until the final cutover
|
|
||||||
commit.
|
|
||||||
- At cutover, make the new Command Deck live at `/ase`, remove the legacy
|
|
||||||
`/admin`, `/admin-next`, and `/mod` trees, and remove obsolete legacy routes
|
|
||||||
without redirects.
|
|
||||||
- Use hybrid personalization: mandatory content is capability-derived; operators
|
|
||||||
may pin and reorder allowed shortcuts and optional widgets.
|
|
||||||
- Add only backward-compatible database migrations before cutover.
|
|
||||||
|
|
||||||
## Outcomes
|
|
||||||
|
|
||||||
The completed program must:
|
|
||||||
|
|
||||||
1. Give every one of the 137 legacy routes a verified canonical destination or
|
|
||||||
an explicit removal decision.
|
|
||||||
2. Replace the fragmented admin and moderator surfaces with one capability-aware
|
|
||||||
Command Deck.
|
|
||||||
3. Deliver real workflows for all retained administration responsibilities, not
|
|
||||||
wrappers around legacy pages.
|
|
||||||
4. Provide global search, safe commands, derived operational inboxes, recent
|
|
||||||
work, favorites, and optional widgets.
|
|
||||||
5. Enforce the existing ACL model on navigation, reads, mutations, commands,
|
|
||||||
search results, inbox items, and widgets.
|
|
||||||
6. Produce durable and sanitized audit evidence for sensitive operations.
|
|
||||||
7. Preserve a release-level rollback path without destructive database rollback.
|
|
||||||
|
|
||||||
## Delivery model
|
|
||||||
|
|
||||||
All work is committed to `codex/housekeeping-complete`, based on the latest
|
|
||||||
`origin/main`. No pull request is opened until every vertical and the cutover are
|
|
||||||
implemented, reviewed, and verified.
|
|
||||||
|
|
||||||
The foundation currently exposes `/admin-next`. The first completion change
|
|
||||||
renames that preview tree and its links to `/ase-next`; the preview remains
|
|
||||||
unavailable when `NODE_ENV=production`. Development and test environments use
|
|
||||||
`/ase-next` to exercise the new shell before cutover. The final cutover publishes
|
|
||||||
the canonical `/ase` tree and removes the preview entry; it does not weaken the
|
|
||||||
production preview gate before that point.
|
|
||||||
|
|
||||||
The branch is built in this order:
|
|
||||||
|
|
||||||
1. access, audit, error, and preference core;
|
|
||||||
2. People, moderation, and support;
|
|
||||||
3. Content and engagement;
|
|
||||||
4. Economy and catalog;
|
|
||||||
5. Hotel, world, and operational systems;
|
|
||||||
6. Command Deck operations and cross-domain composition;
|
|
||||||
7. atomic route cutover and legacy removal.
|
|
||||||
|
|
||||||
## Canonical route structure
|
|
||||||
|
|
||||||
After cutover the public administration route tree is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ase Operations workspace
|
|
||||||
/ase/people/* users, tickets, CFH, bans, moderation, teams
|
|
||||||
/ase/content/* articles, events, polls, media, engagement
|
|
||||||
/ase/economy/* catalog, shop, transactions, vouchers, values
|
|
||||||
/ase/hotel/* rooms, furni, badges, radio, emulator, Studio
|
|
||||||
/ase/system/* settings, ACL, logs, DevOps, maintenance
|
|
||||||
```
|
|
||||||
|
|
||||||
`/ase` is the operational home, not a duplicate menu page. `/admin`,
|
|
||||||
`/admin-next`, and `/mod` have no route after cutover. A workflow has one
|
|
||||||
canonical owner and one canonical destination; the new tree must not retain
|
|
||||||
duplicate hubs or aliases.
|
|
||||||
|
|
||||||
## Module ownership
|
|
||||||
|
|
||||||
`src/features/housekeeping/foundation` owns only cross-cutting composition:
|
|
||||||
|
|
||||||
- request-scoped actor and capability context;
|
|
||||||
- registry and navigation projection;
|
|
||||||
- Command Deck chrome and page-state primitives;
|
|
||||||
- command dispatch contracts;
|
|
||||||
- search and inbox orchestration;
|
|
||||||
- preference reconciliation;
|
|
||||||
- shared error and audit envelopes.
|
|
||||||
|
|
||||||
Each domain owns its routes, pages, query services, commands, search providers,
|
|
||||||
inbox sources, widgets, and domain-specific validation. Domains communicate with
|
|
||||||
the foundation through the published contracts. They do not import another
|
|
||||||
domain's internal modules.
|
|
||||||
|
|
||||||
The foundation must not import database clients, server actions, or domain page
|
|
||||||
modules. Server-only domain adapters may import data and action services.
|
|
||||||
|
|
||||||
## Domain manifests
|
|
||||||
|
|
||||||
Every manifest registers real, non-placeholder definitions for:
|
|
||||||
|
|
||||||
- canonical routes and contextual navigation;
|
|
||||||
- safe and sensitive commands;
|
|
||||||
- entity-search providers;
|
|
||||||
- derived-inbox sources;
|
|
||||||
- mandatory and optional widgets;
|
|
||||||
- localization keys and capability requirements.
|
|
||||||
|
|
||||||
Registry validation rejects duplicate IDs across all provider categories,
|
|
||||||
duplicate routes, invalid ownership, missing localization, unknown capability
|
|
||||||
slugs, invalid widget kinds, and commands without an owning domain.
|
|
||||||
|
|
||||||
The migration matrix and manifests are linked by contract tests. Every retained
|
|
||||||
matrix row must resolve to one registered route or workflow. Every manifest
|
|
||||||
capability set must cover the capabilities attributed to its matrix rows.
|
|
||||||
|
|
||||||
## Authorization flow
|
|
||||||
|
|
||||||
Each request creates one capability context from `getAdminContext()`. The context
|
|
||||||
contains the authenticated actor and immutable effective permission slugs.
|
|
||||||
Rank is informational and may influence presentation defaults only; it is never
|
|
||||||
used as a new authorization threshold.
|
|
||||||
|
|
||||||
Authorization is applied at every layer:
|
|
||||||
|
|
||||||
1. registry projection removes inaccessible domains and routes;
|
|
||||||
2. provider orchestration calls only permitted providers;
|
|
||||||
3. providers filter inaccessible results and items;
|
|
||||||
4. page loaders revalidate their required capability;
|
|
||||||
5. command execution revalidates capability and input on the server;
|
|
||||||
6. the underlying mutation service retains its own permission guard.
|
|
||||||
|
|
||||||
Client state, hidden navigation, preferences, or a previously loaded page never
|
|
||||||
authorize an operation.
|
|
||||||
|
|
||||||
## Commands and audit
|
|
||||||
|
|
||||||
Commands use typed input schemas and typed success/error results. Safe commands
|
|
||||||
may execute directly from the palette. Sensitive commands open a dedicated
|
|
||||||
contextual confirmation flow and require a reason when the command contract says
|
|
||||||
so.
|
|
||||||
|
|
||||||
The existing `admin_audit_log` remains the canonical audit store. An additive
|
|
||||||
migration adds nullable `correlation_id varchar(64)`, `outcome varchar(32)`,
|
|
||||||
`reason text`, and `domain varchar(32)` columns plus an index on
|
|
||||||
`correlation_id`. Existing `action`, `target`, `target_id`, `before`, `after`,
|
|
||||||
`diff`, `ip_address`, and actor fields remain in use.
|
|
||||||
|
|
||||||
- Database mutations write mutation and audit evidence in the same transaction
|
|
||||||
whenever the affected service uses the same database connection.
|
|
||||||
- Sensitive external or file operations persist an audit intent before
|
|
||||||
execution and a final outcome afterward. Failure to persist the intent blocks
|
|
||||||
execution.
|
|
||||||
- Audit payloads pass through the existing recursive secret redaction.
|
|
||||||
- Every command result and audit record carries the same correlation ID.
|
|
||||||
- Failed, denied, and partially completed sensitive operations are audited.
|
|
||||||
|
|
||||||
## Preferences
|
|
||||||
|
|
||||||
No suitable user-scoped HK preference store currently exists. Add
|
|
||||||
`housekeeping_user_preferences` with:
|
|
||||||
|
|
||||||
- `user_id int` as the primary key and unique owner;
|
|
||||||
- `schema_version int not null default 1`;
|
|
||||||
- `payload longtext not null`, containing validated JSON presentation state;
|
|
||||||
- `created_at datetime` and `updated_at datetime` timestamps.
|
|
||||||
|
|
||||||
The payload stores pinned route/command IDs, shortcut order, widget order, and
|
|
||||||
enabled optional widget IDs. It never stores permissions, authorization
|
|
||||||
decisions, workflow state, or inbox status.
|
|
||||||
|
|
||||||
Every read reconciles stored IDs against the current registry and effective
|
|
||||||
capabilities. Unknown, removed, or unauthorized entries are dropped before the
|
|
||||||
payload reaches the UI. Mandatory widgets cannot be disabled.
|
|
||||||
|
|
||||||
## Command Deck experience
|
|
||||||
|
|
||||||
The shell has four stable regions:
|
|
||||||
|
|
||||||
1. a compact six-domain rail;
|
|
||||||
2. domain-owned contextual navigation;
|
|
||||||
3. a global search and command field with keyboard access;
|
|
||||||
4. an operational workspace for pages, inboxes, recent work, and widgets.
|
|
||||||
|
|
||||||
Desktop and mobile share the same semantic hierarchy. Mobile collapses the rail
|
|
||||||
and contextual navigation without changing route ownership or available
|
|
||||||
actions. Focus order, landmarks, headings, active-state uniqueness, keyboard
|
|
||||||
navigation, reduced motion, and semantic theme tokens are tested contracts.
|
|
||||||
|
|
||||||
Loading, empty, partial, error, forbidden, and ready states use the shared page
|
|
||||||
state primitives. Partial provider failure is visible without replacing valid
|
|
||||||
results from other providers.
|
|
||||||
|
|
||||||
## Search
|
|
||||||
|
|
||||||
Search supports navigation, entity results, and commands. It is not a raw
|
|
||||||
database search endpoint.
|
|
||||||
|
|
||||||
- A term shorter than two trimmed characters performs navigation/command
|
|
||||||
matching only.
|
|
||||||
- Entity providers have a two-second timeout and a maximum of 25 results each.
|
|
||||||
- The combined entity response is capped at 50 results before client rendering.
|
|
||||||
- Providers run only when their declared capability is satisfied.
|
|
||||||
- Results include stable ID, owner, type, title, optional description, canonical
|
|
||||||
href, and capability metadata.
|
|
||||||
- Provider errors produce a typed partial result and do not fail unrelated
|
|
||||||
providers.
|
|
||||||
- Search terms and result payloads are not written to audit logs by default.
|
|
||||||
|
|
||||||
## Derived operational inbox
|
|
||||||
|
|
||||||
The inbox is a read model over domain-owned work: tickets, CFH reports, alerts,
|
|
||||||
emulator errors, operational anomalies, and other existing live states. It does
|
|
||||||
not introduce a second assignment or task-status system.
|
|
||||||
|
|
||||||
Each inbox item exposes stable source/item IDs, domain, type, title, priority,
|
|
||||||
age, state, canonical href, available actions, and required capability. Source
|
|
||||||
items are deduplicated by the pair `(sourceId, itemId)`.
|
|
||||||
|
|
||||||
Sources run independently with a two-second timeout. The composed response
|
|
||||||
contains successful items plus per-source errors. The server caps the result at
|
|
||||||
200 items after capability filtering and deterministic priority/age ordering.
|
|
||||||
|
|
||||||
## Recent work, favorites, and widgets
|
|
||||||
|
|
||||||
Recent work is derived from the operator's existing audit events and canonical
|
|
||||||
route visits; it does not create workflow state. Favorites and ordering come
|
|
||||||
from the reconciled preference payload.
|
|
||||||
|
|
||||||
Mandatory widgets are supplied by the system according to capability and cannot
|
|
||||||
be removed. Optional widgets can be enabled and reordered. Widget loaders are
|
|
||||||
server-side, capability-checked, independently timed out, and represented as
|
|
||||||
partial failures rather than shell failures.
|
|
||||||
|
|
||||||
## Vertical scope
|
|
||||||
|
|
||||||
### Access, audit, and system core
|
|
||||||
|
|
||||||
- command dispatcher and confirmation model;
|
|
||||||
- audit extension and correlation IDs;
|
|
||||||
- typed error taxonomy and boundary mapping;
|
|
||||||
- preference repository and reconciliation;
|
|
||||||
- shared provider orchestration and timeout behavior;
|
|
||||||
- System routes for ACL, settings, logs, DevOps, and maintenance.
|
|
||||||
|
|
||||||
### People, moderation, and support
|
|
||||||
|
|
||||||
- user discovery, details, editing, password/reset controls, account relations,
|
|
||||||
bans, and permitted staff actions;
|
|
||||||
- help tickets and moderator tickets;
|
|
||||||
- CFH queues and details;
|
|
||||||
- moderation actions, team views, and ban workflows;
|
|
||||||
- People search providers, inbox sources, commands, and widgets.
|
|
||||||
|
|
||||||
This vertical proves that all retained `/mod` responsibilities work inside the
|
|
||||||
new capability model before `/mod` is removed.
|
|
||||||
|
|
||||||
### Content and engagement
|
|
||||||
|
|
||||||
- articles, events, polls, media, navigation content, tags, banners, and related
|
|
||||||
editorial tools;
|
|
||||||
- Content search, commands, inbox sources, and widgets;
|
|
||||||
- consolidation of duplicate editorial hubs into canonical workflows.
|
|
||||||
|
|
||||||
### Economy and catalog
|
|
||||||
|
|
||||||
- catalog and item management, Builder Club catalog, maintenance, shop,
|
|
||||||
transactions, vouchers, subscriptions, marketplace, and value tools;
|
|
||||||
- Economy search, commands, anomaly sources, and widgets;
|
|
||||||
- existing specialized editors remain components of canonical workflows rather
|
|
||||||
than parallel navigation roots.
|
|
||||||
|
|
||||||
### Hotel, world, and operational systems
|
|
||||||
|
|
||||||
- rooms and room furni, badges, sounds, radio, emulator controls, imports, and
|
|
||||||
Studio tools;
|
|
||||||
- Hotel search, commands, operational sources, and widgets;
|
|
||||||
- long-running operations retain progress/error behavior and gain consistent
|
|
||||||
capability and audit envelopes.
|
|
||||||
|
|
||||||
### Operations composition
|
|
||||||
|
|
||||||
- global search and command palette;
|
|
||||||
- derived inbox and partial-source reporting;
|
|
||||||
- recent work and favorites;
|
|
||||||
- mandatory operational summaries and optional widgets;
|
|
||||||
- no duplicate mutation logic: actions route to the owning domain command.
|
|
||||||
|
|
||||||
## Error model
|
|
||||||
|
|
||||||
All HK services return typed errors from this stable set:
|
|
||||||
|
|
||||||
- `UNAUTHENTICATED`;
|
|
||||||
- `FORBIDDEN`;
|
|
||||||
- `VALIDATION`;
|
|
||||||
- `NOT_FOUND`;
|
|
||||||
- `CONFLICT`;
|
|
||||||
- `RATE_LIMITED`;
|
|
||||||
- `DEPENDENCY_UNAVAILABLE`;
|
|
||||||
- `TIMEOUT`;
|
|
||||||
- `INTERNAL`.
|
|
||||||
|
|
||||||
User messages are localized and do not expose internal details. Server logs and
|
|
||||||
audit evidence include correlation IDs. Expected domain errors do not rely on
|
|
||||||
framework exception text. Unknown errors are sanitized at the boundary and
|
|
||||||
logged once.
|
|
||||||
|
|
||||||
## Database changes
|
|
||||||
|
|
||||||
Allowed pre-cutover migrations are additive only:
|
|
||||||
|
|
||||||
1. nullable HK audit metadata columns on `admin_audit_log`;
|
|
||||||
2. the `housekeeping_user_preferences` table and its unique user index.
|
|
||||||
|
|
||||||
No legacy table or column is dropped or repurposed in this program. Removal of
|
|
||||||
legacy UI routes is an application cutover, not a destructive data migration.
|
|
||||||
|
|
||||||
## Atomic cutover
|
|
||||||
|
|
||||||
The final cutover commit is created only after all vertical gates pass. It:
|
|
||||||
|
|
||||||
1. moves the completed shell and Operations workspace from `/ase-next` to
|
|
||||||
`/ase`;
|
|
||||||
2. changes domain preview hrefs to canonical `/ase/<domain>` hrefs;
|
|
||||||
3. updates internal links, navigation configuration, and authorization fallback
|
|
||||||
destinations;
|
|
||||||
4. removes the legacy `/admin`, `/admin-next`, and `/mod` route trees plus every
|
|
||||||
legacy route marked `REMOVE`;
|
|
||||||
5. removes legacy pages whose behavior moved or merged into canonical routes;
|
|
||||||
6. removes the temporary preview entry and flag if no longer used by tests;
|
|
||||||
7. adds no compatibility redirects.
|
|
||||||
|
|
||||||
The cutover must leave no links, imports, route discovery entries, or tests that
|
|
||||||
depend on removed UI modules.
|
|
||||||
|
|
||||||
## Verification strategy
|
|
||||||
|
|
||||||
Each vertical uses TDD and has four gates:
|
|
||||||
|
|
||||||
1. contract and authorization tests;
|
|
||||||
2. domain query/command behavior tests, including denied and failure paths;
|
|
||||||
3. page and accessibility behavior tests;
|
|
||||||
4. cumulative Housekeeping and repository verification.
|
|
||||||
|
|
||||||
The final branch requires:
|
|
||||||
|
|
||||||
- the migration matrix reporting 137/137 valid with every retained row linked to
|
|
||||||
a canonical implementation;
|
|
||||||
- mutation-sensitive authorization, provider, command, audit, and preference
|
|
||||||
tests;
|
|
||||||
- full project tests, Housekeeping tests, typecheck, semantic Biome, targeted
|
|
||||||
formatting checks, and `git diff --check`;
|
|
||||||
- production build with temporary environment restoration;
|
|
||||||
- visual verification at desktop and mobile widths for every domain and shared
|
|
||||||
state;
|
|
||||||
- route-level smoke checks for canonical `/ase` pages, denied access, and
|
|
||||||
removal of `/admin`, `/admin-next`, `/mod`, and obsolete routes;
|
|
||||||
- a broad whole-branch code review followed by one reviewed fix wave if needed.
|
|
||||||
|
|
||||||
Repository-wide pre-existing formatter debt is reported separately and must not
|
|
||||||
be hidden by mass-formatting unrelated files.
|
|
||||||
|
|
||||||
## Merge, deployment, and rollback
|
|
||||||
|
|
||||||
The single pull request targets `main` only after all final gates pass. Merging
|
|
||||||
is the atomic release boundary; no partial vertical is intentionally exposed to
|
|
||||||
production operators.
|
|
||||||
|
|
||||||
After merge, the deployment pipeline must complete and `/api/health` must be
|
|
||||||
verified live. A push or successful build alone is not deployment evidence.
|
|
||||||
|
|
||||||
Rollback deploys the prior application release. Because database changes are
|
|
||||||
additive and ignored by the prior release, rollback does not require manual data
|
|
||||||
reversal. If audit or preference migrations themselves fail, deployment stops
|
|
||||||
before serving the cutover release.
|
|
||||||
|
|
||||||
## Explicit non-goals
|
|
||||||
|
|
||||||
- A new task-assignment system for inbox items.
|
|
||||||
- A replacement authentication or ACL model.
|
|
||||||
- Rank-based authorization thresholds.
|
|
||||||
- Compatibility redirects for removed `/admin`, `/admin-next`, or `/mod`
|
|
||||||
routes.
|
|
||||||
- Destructive cleanup of legacy database data.
|
|
||||||
- Rewriting specialized domain engines that already work; they are integrated
|
|
||||||
behind consistent domain contracts instead.
|
|
||||||
- Unrelated CMS redesign or repository-wide formatting cleanup.
|
|
||||||
|
|
||||||
## Completion criteria
|
|
||||||
|
|
||||||
The program is complete only when:
|
|
||||||
|
|
||||||
- all retained legacy capabilities are available through canonical new routes;
|
|
||||||
- all six manifests contain real routes/providers/widgets rather than empty
|
|
||||||
placeholders;
|
|
||||||
- the Command Deck search, commands, inbox, preferences, recent work, and widgets
|
|
||||||
operate against real domain services;
|
|
||||||
- capability enforcement and audit evidence cover every exposed read and
|
|
||||||
mutation path;
|
|
||||||
- `/ase` serves the new HK; `/admin`, `/admin-next`, `/mod`, and removed legacy
|
|
||||||
routes are unreachable; and no compatibility redirects exist;
|
|
||||||
- final local, CI, deployment, health, and visual evidence are all recorded.
|
|
||||||
@@ -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`)
|
|
||||||
);
|
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+127
@@ -34,6 +34,12 @@ importers:
|
|||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: 3.2.2
|
specifier: 3.2.2
|
||||||
version: 3.2.2([email protected])
|
version: 3.2.2([email protected])
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: 5.9.1
|
||||||
|
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: 3.14.10
|
||||||
|
version: 3.14.10([email protected]([email protected]))([email protected])
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: 0.7.1
|
specifier: 0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -106,6 +112,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.8
|
specifier: 19.2.8
|
||||||
version: 19.2.8([email protected])
|
version: 19.2.8([email protected])
|
||||||
|
react-hook-form:
|
||||||
|
specifier: 7.85.0
|
||||||
|
version: 7.85.0([email protected])
|
||||||
resend:
|
resend:
|
||||||
specifier: 6.21.0
|
specifier: 6.21.0
|
||||||
version: 6.21.0
|
version: 6.21.0
|
||||||
@@ -540,6 +549,84 @@ packages:
|
|||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
||||||
|
|
||||||
|
'@hookform/[email protected]':
|
||||||
|
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@sinclair/typebox': '>=0.25.24'
|
||||||
|
'@standard-schema/spec': ^1.0.0
|
||||||
|
'@typeschema/main': '>=0.13.7'
|
||||||
|
'@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||||
|
ajv: ^8.12.0
|
||||||
|
ajv-errors: ^3.0.0
|
||||||
|
ajv-formats: ^2.1.1
|
||||||
|
arktype: ^2.0.0
|
||||||
|
ata-validator: ^1.2.0
|
||||||
|
class-transformer: '>=0.4.0'
|
||||||
|
class-validator: '>=0.12.0'
|
||||||
|
computed-types: ^1.0.0
|
||||||
|
effect: ^3.10.3
|
||||||
|
fluentvalidation-ts: ^3.0.0
|
||||||
|
fp-ts: ^2.7.0
|
||||||
|
io-ts: ^2.0.0
|
||||||
|
joi: ^17.0.0 || ^18.0.0
|
||||||
|
nope-validator: '>=0.12.0'
|
||||||
|
react-hook-form: ^7.55.0
|
||||||
|
superstruct: '>=0.12.0'
|
||||||
|
typanion: ^3.3.2
|
||||||
|
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
|
||||||
|
vest: '>=6.0.0'
|
||||||
|
yup: ^1.0.0
|
||||||
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@sinclair/typebox':
|
||||||
|
optional: true
|
||||||
|
'@standard-schema/spec':
|
||||||
|
optional: true
|
||||||
|
'@typeschema/main':
|
||||||
|
optional: true
|
||||||
|
'@vinejs/vine':
|
||||||
|
optional: true
|
||||||
|
ajv:
|
||||||
|
optional: true
|
||||||
|
ajv-errors:
|
||||||
|
optional: true
|
||||||
|
ajv-formats:
|
||||||
|
optional: true
|
||||||
|
arktype:
|
||||||
|
optional: true
|
||||||
|
ata-validator:
|
||||||
|
optional: true
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
computed-types:
|
||||||
|
optional: true
|
||||||
|
effect:
|
||||||
|
optional: true
|
||||||
|
fluentvalidation-ts:
|
||||||
|
optional: true
|
||||||
|
fp-ts:
|
||||||
|
optional: true
|
||||||
|
io-ts:
|
||||||
|
optional: true
|
||||||
|
joi:
|
||||||
|
optional: true
|
||||||
|
nope-validator:
|
||||||
|
optional: true
|
||||||
|
superstruct:
|
||||||
|
optional: true
|
||||||
|
typanion:
|
||||||
|
optional: true
|
||||||
|
valibot:
|
||||||
|
optional: true
|
||||||
|
vest:
|
||||||
|
optional: true
|
||||||
|
yup:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1397,6 +1484,9 @@ packages:
|
|||||||
'@standard-schema/[email protected]':
|
'@standard-schema/[email protected]':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@standard-schema/[email protected]':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
|
resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1595,6 +1685,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
||||||
|
|
||||||
|
'@tanstack/[email protected]':
|
||||||
|
resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/[email protected]':
|
||||||
|
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
|
||||||
|
|
||||||
'@tokenizer/[email protected]':
|
'@tokenizer/[email protected]':
|
||||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2559,6 +2658,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.8
|
react: ^19.2.8
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3197,6 +3302,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/fast-memoize': 3.1.7
|
'@formatjs/fast-memoize': 3.1.7
|
||||||
|
|
||||||
|
'@hookform/[email protected](@standard-schema/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
react-hook-form: 7.85.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
'@img/[email protected]': {}
|
'@img/[email protected]': {}
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
@@ -3760,6 +3873,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/[email protected]': {}
|
'@standard-schema/[email protected]': {}
|
||||||
|
|
||||||
|
'@standard-schema/[email protected]': {}
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3904,6 +4019,14 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
|
|
||||||
|
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.8
|
||||||
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8([email protected])
|
||||||
|
|
||||||
|
'@tanstack/[email protected]': {}
|
||||||
|
|
||||||
'@tokenizer/[email protected]([email protected])':
|
'@tokenizer/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3([email protected])
|
debug: 4.4.3([email protected])
|
||||||
@@ -4739,6 +4862,10 @@ snapshots:
|
|||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|||||||
@@ -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}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,115 +1,98 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { ActionError } from "@/lib/safe-action-shared";
|
import { ActionError } from "@/lib/safe-action-shared";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { createAd, deleteAd } from "./admin-ads";
|
import { createAd, deleteAd } from "./admin-ads";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({
|
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||||
execute: vi.fn(async () => ({
|
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||||
ok: true,
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
data: { before: null, after: { id: "1" }, output: { id: "1" } },
|
return { insertValues, deleteWhere };
|
||||||
correlationId: "legacy",
|
});
|
||||||
})),
|
|
||||||
}));
|
|
||||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
|
||||||
contentMutationService: { execute },
|
|
||||||
createContentMutationInvocation: (actor, correlationId) => ({
|
|
||||||
expectedActorId: actor.id,
|
|
||||||
correlationId,
|
|
||||||
legacy: true,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
update: vi.fn(() => ({
|
||||||
|
set: vi.fn(() => ({
|
||||||
|
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
},
|
||||||
|
WebsiteAds: { id: "id" },
|
||||||
|
}));
|
||||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
adminAction: (_options, handler) => handler,
|
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
ActionError: class ActionError extends Error {},
|
ActionError: class extends Error {},
|
||||||
actionOk: () => "ok",
|
actionOk: vi.fn(() => "ok"),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data) => ({ get: (key) => data[key] ?? null });
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (k: string) => data[k] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
execute.mockResolvedValue({
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
ok: true,
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
data: { before: null, after: { id: "1" }, output: { id: "1" } },
|
});
|
||||||
correlationId: "legacy",
|
|
||||||
|
describe("createAd", () => {
|
||||||
|
it("creates ad and redirects", async () => {
|
||||||
|
await createAd(
|
||||||
|
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(insertValues).toHaveBeenCalled();
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when image empty", async () => {
|
||||||
|
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
||||||
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error on db failure", async () => {
|
||||||
|
insertValues.mockRejectedValue(new Error("db"));
|
||||||
|
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Content advertisement legacy wrappers", () => {
|
describe("deleteAd", () => {
|
||||||
it("delegates creation and preserves redirect", async () => {
|
it("deletes ad and returns ok", async () => {
|
||||||
await createAd(fakeForm({ image: "https://example.com/ad.png" }));
|
const h = deleteAd as unknown as (ctx: {
|
||||||
expect(execute).toHaveBeenCalledWith(
|
data: { id: bigint };
|
||||||
expect.objectContaining({ expectedActorId: 1, legacy: true }),
|
session: { user: { id: string } };
|
||||||
"ad.change",
|
}) => Promise<string>;
|
||||||
{ action: "create", image: "https://example.com/ad.png" },
|
expect(
|
||||||
);
|
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
|
).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when image is empty", async () => {
|
it("throws ActionError when not found", async () => {
|
||||||
await createAd(fakeForm({ image: "" }));
|
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
||||||
expect(execute).not.toHaveBeenCalled();
|
const h = deleteAd as unknown as (ctx: {
|
||||||
});
|
data: { id: bigint };
|
||||||
|
session: { user: { id: string } };
|
||||||
it("logs a redacted service failure", async () => {
|
}) => Promise<string>;
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "DEPENDENCY_UNAVAILABLE",
|
|
||||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
||||||
},
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
|
||||||
await createAd(fakeForm({ image: "x" }));
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
"Action failed: createAd",
|
|
||||||
expect.objectContaining({
|
|
||||||
error: "errors.housekeeping.dependencyUnavailable",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates deletion and preserves action result", async () => {
|
|
||||||
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
|
|
||||||
await expect(
|
await expect(
|
||||||
handler({
|
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
||||||
data: { id: 99n },
|
|
||||||
session: { user: { id: "1" } },
|
|
||||||
requestId: "delete",
|
|
||||||
}),
|
|
||||||
).resolves.toBe("ok");
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ correlationId: "delete" }),
|
|
||||||
"ad.change",
|
|
||||||
{ action: "delete", id: "99" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves not-found ActionError", async () => {
|
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
|
||||||
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
|
|
||||||
await expect(
|
|
||||||
handler({
|
|
||||||
data: { id: 999n },
|
|
||||||
session: { user: { id: "1" } },
|
|
||||||
requestId: "missing",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(ActionError);
|
).rejects.toThrow(ActionError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+67
-37
@@ -1,18 +1,20 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
|
||||||
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 { db, WebsiteAds } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||||
|
// table; it only stores an image URL rendered in the site layout/widgets.
|
||||||
|
|
||||||
export async function createAd(formData: FormData): Promise<void> {
|
export async function createAd(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
@@ -21,68 +23,96 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
const result = await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
const now = new Date();
|
||||||
"ad.change",
|
try {
|
||||||
{ action: "create", image },
|
const [result] = (await db.insert(WebsiteAds).values({
|
||||||
);
|
image,
|
||||||
if (!result.ok) {
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "ad_create",
|
||||||
|
description: `Created advertisement #${result.insertId} (${image})`,
|
||||||
|
targetType: "website_ad",
|
||||||
|
targetId: Number(result.insertId),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
logger.error("Action failed: createAd", {
|
logger.error("Action failed: createAd", {
|
||||||
action: "createAd",
|
action: "createAd",
|
||||||
error: result.error.messageKey,
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/content/media/ads");
|
revalidatePath("/admin/ads");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/ase/content/media/ads");
|
redirect("/admin/ads");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAd(formData: FormData): Promise<void> {
|
export async function updateAd(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||||
if (!/^\d+$/u.test(raw)) return;
|
if (!/^\d+$/.test(raw)) return;
|
||||||
|
const id = BigInt(raw);
|
||||||
const image = String(formData.get("image") ?? "")
|
const image = String(formData.get("image") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
const result = await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
try {
|
||||||
"ad.change",
|
await db
|
||||||
{ action: "update", id: raw, image },
|
.update(WebsiteAds)
|
||||||
);
|
.set({ image, updatedAt: new Date() })
|
||||||
if (!result.ok) {
|
.where(eq(WebsiteAds.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "ad_update",
|
||||||
|
description: `Updated advertisement #${id} (${image})`,
|
||||||
|
targetType: "website_ad",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
logger.error("Action failed: updateAd", {
|
logger.error("Action failed: updateAd", {
|
||||||
action: "updateAd",
|
action: "updateAd",
|
||||||
id: Number(raw),
|
id: Number(id),
|
||||||
error: result.error.messageKey,
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
});
|
});
|
||||||
revalidatePath(`/ase/content/media/ads/${raw}`);
|
revalidatePath(`/admin/ads/${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/ase/content/media/ads");
|
redirect("/admin/ads");
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAdInput = z.object({
|
const deleteAdInput = z.object({
|
||||||
id: z
|
id: z
|
||||||
.union([z.string(), z.number(), z.bigint()])
|
.union([z.string(), z.number(), z.bigint()])
|
||||||
.transform((value) => BigInt(String(value))),
|
.transform((v) => BigInt(String(v))),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteAd = adminAction(
|
export const deleteAd = adminAction(
|
||||||
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
|
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const id = ctx.data.id;
|
||||||
{
|
try {
|
||||||
correlationId: String(ctx.requestId),
|
const [result] = (await db
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
.delete(WebsiteAds)
|
||||||
legacy: true,
|
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
|
||||||
},
|
if (!result.affectedRows) {
|
||||||
"ad.change",
|
throw new ActionError("Advertisement not found");
|
||||||
{ action: "delete", id: ctx.data.id.toString() },
|
}
|
||||||
);
|
} catch (err) {
|
||||||
if (!result.ok) throw new ActionError("Advertisement not found");
|
if (err instanceof ActionError) throw err;
|
||||||
revalidatePath("/ase/content/media/ads");
|
throw new ActionError("Advertisement not found");
|
||||||
|
}
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: Number(ctx.session.user.id),
|
||||||
|
action: "ad_delete",
|
||||||
|
description: `Deleted advertisement #${id}`,
|
||||||
|
targetType: "website_ad",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/ads");
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -1,73 +1,120 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
|
||||||
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 {
|
||||||
|
db,
|
||||||
|
WebsiteArticleComments,
|
||||||
|
WebsiteArticleReactions,
|
||||||
|
WebsiteArticles,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { slugify } from "@/lib/format";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
function articleInput(formData: FormData) {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
return {
|
const base = slugify(title);
|
||||||
|
let slug = base;
|
||||||
|
let n = 2;
|
||||||
|
for (;;) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: WebsiteArticles.id })
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.slug, slug))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) return slug;
|
||||||
|
slug = `${base}-${n++}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createArticle(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
|
const title = String(formData.get("title") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const shortStory = String(formData.get("shortStory") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const fullStory = String(formData.get("fullStory") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const image = String(formData.get("image") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(WebsiteArticles).values({
|
||||||
|
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||||
|
title: title.slice(0, 255),
|
||||||
|
shortStory: shortStory.slice(0, 255),
|
||||||
|
fullStory,
|
||||||
|
image: image.slice(0, 255),
|
||||||
|
userId: staff.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Database error — re-render unchanged with error.
|
||||||
|
redirect(
|
||||||
|
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
redirect("/admin/articles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateArticle(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.NEWS_EDIT);
|
||||||
|
const id = BigInt(String(formData.get("id")));
|
||||||
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteArticles)
|
||||||
|
.set({
|
||||||
title: String(formData.get("title") ?? "")
|
title: String(formData.get("title") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim(),
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
shortStory: String(formData.get("shortStory") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim(),
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
fullStory: String(formData.get("fullStory") ?? "")
|
fullStory: String(formData.get("fullStory") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim(),
|
.trim(),
|
||||||
image: String(formData.get("image") ?? "")
|
image: String(formData.get("image") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim(),
|
.trim()
|
||||||
slug: String(formData.get("slug") ?? "").trim(),
|
.slice(0, 255),
|
||||||
};
|
updatedAt: new Date(),
|
||||||
}
|
})
|
||||||
|
.where(eq(WebsiteArticles.id, id));
|
||||||
export async function createArticle(formData: FormData): Promise<void> {
|
} catch {
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
redirect("/admin/articles?error=Update failed");
|
||||||
const input = articleInput(formData);
|
|
||||||
if (!input.title) return;
|
|
||||||
const result = await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
|
||||||
"article.change",
|
|
||||||
{ action: "create", ...input },
|
|
||||||
);
|
|
||||||
if (!result.ok) {
|
|
||||||
redirect(
|
|
||||||
"/ase/content/editorial/articles/new?error=Database error while creating article. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
redirect("/ase/content/editorial/articles");
|
revalidatePath(`/admin/articles/${id}`);
|
||||||
}
|
redirect("/admin/articles");
|
||||||
|
|
||||||
export async function updateArticle(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
|
||||||
const id = String(formData.get("id") ?? "");
|
|
||||||
const result = await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
|
||||||
"article.change",
|
|
||||||
{ action: "update", id, ...articleInput(formData) },
|
|
||||||
);
|
|
||||||
if (!result.ok)
|
|
||||||
redirect("/ase/content/editorial/articles?error=Update failed");
|
|
||||||
revalidatePath(`/ase/content/editorial/articles/${id}`);
|
|
||||||
redirect("/ase/content/editorial/articles");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = String(formData.get("id") ?? "");
|
const id = BigInt(String(formData.get("id")));
|
||||||
const result = await contentMutationService.execute(
|
try {
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
await db.transaction(async (tx) => {
|
||||||
"article.change",
|
await tx
|
||||||
{ action: "delete", id },
|
.delete(WebsiteArticleReactions)
|
||||||
);
|
.where(eq(WebsiteArticleReactions.articleId, id));
|
||||||
if (!result.ok)
|
await tx
|
||||||
redirect("/ase/content/editorial/articles?error=Delete failed");
|
.delete(WebsiteArticleComments)
|
||||||
redirect("/ase/content/editorial/articles");
|
.where(eq(WebsiteArticleComments.articleId, id));
|
||||||
|
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
redirect("/admin/articles?error=Delete failed");
|
||||||
|
}
|
||||||
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,67 +1,84 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
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", () => ({ requirePermissionRateLimited: vi.fn() }));
|
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
||||||
|
|
||||||
import { createBan, liftBan } from "./admin-bans";
|
import { createBan, liftBan } from "./admin-bans";
|
||||||
|
|
||||||
|
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
|
||||||
|
const selectLimit = vi.fn();
|
||||||
|
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
return { selectLimit, insertValues, deleteWhere };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: selectLimit,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
},
|
||||||
|
Ban: { id: "id", userId: "userId" },
|
||||||
|
User: { id: "id", username: "username" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string>) =>
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
({ get: (key: string) => data[key] ?? null }) as unknown as FormData;
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
execute.mockImplementation(async (invocation) => ({
|
selectLimit.mockResolvedValue([{ username: "baduser" }]);
|
||||||
ok: true,
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
data: { before: null, after: {} },
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
correlationId: invocation.correlationId,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("legacy admin ban wrappers", () => {
|
describe("createBan", () => {
|
||||||
it("preserves parsed create input, service delegation, and revalidation", async () => {
|
it("creates a ban for valid inputs", async () => {
|
||||||
await createBan(
|
await createBan(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
userId: "42",
|
userId: "42",
|
||||||
reason: "Spam",
|
reason: "Spam",
|
||||||
hours: "24",
|
hours: "24",
|
||||||
type: "account",
|
type: "account",
|
||||||
}),
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(execute).toHaveBeenCalledWith(
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ expectedActorId: 1, legacy: true }),
|
expect.objectContaining({ userId: 42, type: "account" }),
|
||||||
"ban.create",
|
|
||||||
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
|
|
||||||
);
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when userId is invalid", async () => {
|
it("returns early when userId is invalid", async () => {
|
||||||
await createBan(fakeForm({ userId: "0", hours: "1", type: "account" }));
|
await createBan(
|
||||||
expect(execute).not.toHaveBeenCalled();
|
fakeForm({
|
||||||
});
|
userId: "0",
|
||||||
|
hours: "1",
|
||||||
it("delegates lift by exact ban id and preserves revalidation", async () => {
|
type: "account",
|
||||||
await liftBan(fakeForm({ id: "42" }));
|
}) as unknown as FormData,
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ expectedActorId: 1, legacy: true }),
|
|
||||||
"ban.lift",
|
|
||||||
{ id: 42 },
|
|
||||||
);
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liftBan", () => {
|
||||||
|
it("deletes ban and revalidates", async () => {
|
||||||
|
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+42
-23
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,48 +1,91 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { disbandGuild } from "./admin-guilds";
|
import { disbandGuild } from "./admin-guilds";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
() => {
|
||||||
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
const selectLimit = vi.fn();
|
||||||
expectedActorId: staff.id,
|
const transactionFn = vi.fn();
|
||||||
correlationId,
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
legacy: true,
|
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
|
||||||
})),
|
return { selectLimit, transactionFn, deleteWhere, updateSet };
|
||||||
peopleMutationService: { execute },
|
},
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
PERMS: { USERS_EDIT: "admin.users.edit" },
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: selectLimit,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
transaction: transactionFn,
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
update: vi.fn(() => ({ set: updateSet })),
|
||||||
|
},
|
||||||
|
Guilds: { id: "id", name: "name", userId: "userId" },
|
||||||
|
GuildsForumsThreads: { id: "id", guildId: "guildId" },
|
||||||
|
GuildsForumsComments: { threadId: "threadId" },
|
||||||
|
GuildForumViews: { guildId: "guildId" },
|
||||||
|
GuildsMembers: { guildId: "guildId" },
|
||||||
|
Rooms: { guildId: "guildId" },
|
||||||
|
Items: { guildId: "guildId" },
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const form = (data: Record<string, string>) =>
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: { before: { id: 1 }, after: null },
|
|
||||||
correlationId: "guild",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("disbandGuild legacy wrapper", () => {
|
describe("disbandGuild", () => {
|
||||||
it("keeps rate-limited ACL, service input, and ASE revalidation", async () => {
|
it("disbands guild and cleans related data", async () => {
|
||||||
await disbandGuild(form({ id: "9" }));
|
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
|
transactionFn.mockImplementation(
|
||||||
guildId: 9,
|
async (fn: (tx: unknown) => Promise<void>) => {
|
||||||
|
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
|
||||||
|
const tx = {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: txSelectLimit,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||||
|
update: vi.fn(() => ({
|
||||||
|
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
// For threads findMany (no limit) — make where resolve to array
|
||||||
|
tx.select = vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn().mockResolvedValue([{ id: 10 }]),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
await fn(tx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/community/guilds");
|
|
||||||
});
|
it("returns early when id is not positive", async () => {
|
||||||
it("keeps invalid IDs as a no-op", async () => {
|
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
||||||
await disbandGuild(form({ id: "0" }));
|
expect(selectLimit).not.toHaveBeenCalled();
|
||||||
expect(execute).not.toHaveBeenCalled();
|
|
||||||
expect(revalidatePath).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+53
-16
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,22 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createPeopleMutationInvocation,
|
Ban,
|
||||||
peopleMutationService,
|
db,
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
WebsiteHelpCenterTicketReplies,
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
WebsiteHelpCenterTickets,
|
||||||
|
} 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 { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { canonicalTicketId } from "@/lib/services/ticket-replies";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
|
||||||
const ticketIdField = z
|
const ticketIdField = z
|
||||||
.union([z.string(), z.number(), z.bigint()])
|
.union([z.string(), z.number(), z.bigint()])
|
||||||
.transform((value, context) => {
|
.transform((v) => BigInt(String(v)));
|
||||||
try {
|
|
||||||
return canonicalTicketId(value);
|
|
||||||
} catch {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Invalid ticket identifier",
|
|
||||||
});
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const replyHelpCenterTicketSchema = z.object({
|
const replyHelpCenterTicketSchema = z.object({
|
||||||
ticketId: ticketIdField,
|
ticketId: ticketIdField,
|
||||||
@@ -37,24 +29,14 @@ const helpCenterTicketIdSchema = z.object({
|
|||||||
|
|
||||||
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||||
const id = String(ticketId);
|
const id = String(ticketId);
|
||||||
revalidatePath("/ase/people/support/help-tickets");
|
revalidatePath("/admin/help-tickets");
|
||||||
revalidatePath(`/ase/people/support/help-tickets/${id}`);
|
revalidatePath(`/admin/help-tickets/${id}`);
|
||||||
|
revalidatePath("/mod/help-tickets");
|
||||||
|
revalidatePath(`/mod/help-tickets/${id}`);
|
||||||
revalidatePath("/help/tickets");
|
revalidatePath("/help/tickets");
|
||||||
revalidatePath(`/help/tickets/${id}`);
|
revalidatePath(`/help/tickets/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execute(
|
|
||||||
staff: { readonly id: number },
|
|
||||||
operation: "help-ticket.reply" | "help-ticket.status" | "help-ticket.unban",
|
|
||||||
input: unknown,
|
|
||||||
) {
|
|
||||||
return peopleMutationService.execute(
|
|
||||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
|
||||||
operation,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const liftBanFromHelpTicket = adminAction(
|
export const liftBanFromHelpTicket = adminAction(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_BAN,
|
permission: PERMS.USERS_BAN,
|
||||||
@@ -62,23 +44,50 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const result = await execute(ctx.session.user, "help-ticket.unban", {
|
const [ticket] = await db
|
||||||
ticketId: ticketId.toString(),
|
.select({
|
||||||
});
|
id: WebsiteHelpCenterTickets.id,
|
||||||
if (!result.ok) {
|
userId: WebsiteHelpCenterTickets.userId,
|
||||||
throw new ActionError(
|
open: WebsiteHelpCenterTickets.open,
|
||||||
result.error.code === "CONFLICT"
|
title: WebsiteHelpCenterTickets.title,
|
||||||
? "Ticket has no requester to unban"
|
})
|
||||||
: "Ticket not found",
|
.from(WebsiteHelpCenterTickets)
|
||||||
);
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
if (ticket.userId == null) {
|
||||||
|
throw new ActionError("Ticket has no requester to unban");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
||||||
|
const removed = Number(
|
||||||
|
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (ticket.open) {
|
||||||
|
await db
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ open: false, updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "unban_via_help_ticket",
|
||||||
|
target: "User",
|
||||||
|
targetId: ticket.userId,
|
||||||
|
after: {
|
||||||
|
ticketId: String(ticketId),
|
||||||
|
removedBans: removed,
|
||||||
|
title: ticket.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
revalidatePath("/ase/people/moderation/bans");
|
revalidatePath("/admin/bans");
|
||||||
const removed = Number(result.data.output?.removed ?? 0);
|
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
||||||
const userId = Number(result.data.output?.userId);
|
return actionOk({ removed, userId: ticket.userId });
|
||||||
revalidatePath(`/ase/people/users/${userId}`);
|
|
||||||
return actionOk({ removed, userId });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,11 +97,40 @@ export const replyHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const result = await execute(ctx.session.user, "help-ticket.reply", {
|
const [ticket] = await db
|
||||||
ticketId: ticketId.toString(),
|
.select({
|
||||||
|
id: WebsiteHelpCenterTickets.id,
|
||||||
|
open: WebsiteHelpCenterTickets.open,
|
||||||
|
})
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const staffId = Number(ctx.session.user.id);
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
||||||
|
ticketId,
|
||||||
|
userId: staffId,
|
||||||
content: ctx.data.content.trim(),
|
content: ctx.data.content.trim(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await tx
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
});
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: staffId,
|
||||||
|
action: "help_center_ticket_reply",
|
||||||
|
target: "WebsiteHelpCenterTickets",
|
||||||
|
targetId: Number(ticketId),
|
||||||
});
|
});
|
||||||
if (!result.ok) throw new ActionError("Ticket not found");
|
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
@@ -103,17 +141,32 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const result = await execute(ctx.session.user, "help-ticket.status", {
|
const [ticket] = await db
|
||||||
ticketId: ticketId.toString(),
|
.select({
|
||||||
status: "close",
|
id: WebsiteHelpCenterTickets.id,
|
||||||
|
open: WebsiteHelpCenterTickets.open,
|
||||||
|
})
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ open: false, updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: Number(ctx.session.user.id),
|
||||||
|
action: "help_center_ticket_close",
|
||||||
|
target: "WebsiteHelpCenterTickets",
|
||||||
|
targetId: Number(ticketId),
|
||||||
|
before: { open: true },
|
||||||
|
after: { open: false },
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
|
||||||
throw new ActionError(
|
|
||||||
result.error.code === "CONFLICT"
|
|
||||||
? "Ticket is already closed"
|
|
||||||
: "Ticket not found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
@@ -124,17 +177,32 @@ export const reopenHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const result = await execute(ctx.session.user, "help-ticket.status", {
|
const [ticket] = await db
|
||||||
ticketId: ticketId.toString(),
|
.select({
|
||||||
status: "reopen",
|
id: WebsiteHelpCenterTickets.id,
|
||||||
|
open: WebsiteHelpCenterTickets.open,
|
||||||
|
})
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
if (ticket.open) throw new ActionError("Ticket is already open");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ open: true, updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: Number(ctx.session.user.id),
|
||||||
|
action: "help_center_ticket_reopen",
|
||||||
|
target: "WebsiteHelpCenterTickets",
|
||||||
|
targetId: Number(ticketId),
|
||||||
|
before: { open: false },
|
||||||
|
after: { open: true },
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
|
||||||
throw new ActionError(
|
|
||||||
result.error.code === "CONFLICT"
|
|
||||||
? "Ticket is already open"
|
|
||||||
: "Ticket not found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
|
|||||||
@@ -7,16 +7,24 @@ import {
|
|||||||
updateHelpQuestion,
|
updateHelpQuestion,
|
||||||
} from "./admin-help";
|
} from "./admin-help";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
|
||||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
|
||||||
contentMutationService: { execute },
|
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
createContentMutationInvocation: (
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
actor: { id: number },
|
return { insertValues, updateWhere, deleteWhere };
|
||||||
correlationId: string,
|
});
|
||||||
) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
},
|
||||||
|
WebsiteHelpCenterCategories: { id: "id" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
@@ -28,43 +36,42 @@ const fakeForm = (data: Record<string, string | null>) => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
execute.mockResolvedValue({
|
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
||||||
ok: true,
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
data: { before: null, after: { id: "5" } },
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
correlationId: "legacy",
|
});
|
||||||
|
|
||||||
|
describe("createHelpQuestion", () => {
|
||||||
|
it("creates a help question and redirects", async () => {
|
||||||
|
await createHelpQuestion(
|
||||||
|
fakeForm({
|
||||||
|
name: "FAQ",
|
||||||
|
content: "<p>Answer</p>",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(insertValues).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Content help legacy wrappers", () => {
|
describe("updateHelpQuestion", () => {
|
||||||
it("delegates create and redirects", async () => {
|
it("updates and redirects", async () => {
|
||||||
await createHelpQuestion(
|
|
||||||
fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData,
|
|
||||||
);
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
"help-question.change",
|
|
||||||
expect.objectContaining({ action: "create", name: "FAQ" }),
|
|
||||||
);
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
|
||||||
});
|
|
||||||
it("delegates update and redirects", async () => {
|
|
||||||
await updateHelpQuestion(
|
await updateHelpQuestion(
|
||||||
fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData,
|
fakeForm({
|
||||||
|
id: "42",
|
||||||
|
name: "Updated",
|
||||||
|
content: "New",
|
||||||
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(execute).toHaveBeenCalledWith(
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
expect.anything(),
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
"help-question.change",
|
|
||||||
expect.objectContaining({ action: "update", id: "42" }),
|
|
||||||
);
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
|
||||||
});
|
});
|
||||||
it("delegates delete and redirects", async () => {
|
});
|
||||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
describe("deleteHelpQuestion", () => {
|
||||||
expect.anything(),
|
it("deletes and redirects", async () => {
|
||||||
"help-question.change",
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
{ action: "delete", id: "42" },
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
);
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+107
-53
@@ -1,79 +1,133 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
|
||||||
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 { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||||
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
function helpInput(formData: FormData) {
|
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
||||||
return {
|
// is a titled content block with an optional image and call-to-action button.
|
||||||
name: sanitizeField(formData.get("name")),
|
|
||||||
content: canonicalize(String(formData.get("content") ?? "")),
|
function parsePosition(value: FormDataEntryValue | null): number {
|
||||||
position:
|
const n = Number(value);
|
||||||
Number(formData.get("position")) > 0
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||||
? Math.floor(Number(formData.get("position")))
|
|
||||||
: 1,
|
|
||||||
imageUrl: sanitizeField(formData.get("imageUrl")),
|
|
||||||
buttonText: sanitizeField(formData.get("buttonText")),
|
|
||||||
buttonUrl: sanitizeField(formData.get("buttonUrl")),
|
|
||||||
buttonColor: sanitizeField(formData.get("buttonColor"), 16) || "#eeb425",
|
|
||||||
buttonBorderColor:
|
|
||||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15",
|
|
||||||
smallBox: formData.get("smallBox") != null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const input = helpInput(formData);
|
const name = sanitizeField(formData.get("name"));
|
||||||
if (!input.name || !input.content) return;
|
const content = canonicalize(String(formData.get("content") ?? ""));
|
||||||
const result = await contentMutationService.execute(
|
if (!name || !content) return;
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
|
||||||
"help-question.change",
|
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||||
{ action: "create", ...input },
|
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||||
);
|
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||||
if (!result.ok) {
|
const buttonColor =
|
||||||
revalidatePath("/ase/content/help/questions");
|
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||||
|
const buttonBorderColor =
|
||||||
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
position: parsePosition(formData.get("position")),
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
buttonText: buttonText || null,
|
||||||
|
buttonUrl: buttonUrl || null,
|
||||||
|
buttonColor,
|
||||||
|
buttonBorderColor,
|
||||||
|
smallBox: formData.get("smallBox") != null,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "help_create",
|
||||||
|
description: `Created help-center entry #${result.insertId} (${name})`,
|
||||||
|
targetType: "help_center_category",
|
||||||
|
targetId: Number(result.insertId),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Unique name collision or DB error — re-render unchanged with error.
|
||||||
|
revalidatePath("/admin/help-questions");
|
||||||
redirect(
|
redirect(
|
||||||
"/ase/content/help/questions/new?error=Unique name collision or database error. Please try again.",
|
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
revalidatePath("/ase/content/help/questions");
|
revalidatePath("/admin/help-questions");
|
||||||
redirect("/ase/content/help/questions");
|
redirect("/admin/help-questions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = String(formData.get("id") ?? "").trim();
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
if (!id) return;
|
||||||
const input = helpInput(formData);
|
|
||||||
if (!input.name || !input.content) return;
|
const name = sanitizeField(formData.get("name"));
|
||||||
const result = await contentMutationService.execute(
|
const content = canonicalize(String(formData.get("content") ?? ""));
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
if (!name || !content) return;
|
||||||
"help-question.change",
|
|
||||||
{ action: "update", id, ...input },
|
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||||
);
|
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||||
if (!result.ok) {
|
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||||
revalidatePath(`/ase/content/help/questions/${id}`);
|
const buttonColor =
|
||||||
|
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||||
|
const buttonBorderColor =
|
||||||
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteHelpCenterCategories)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
position: parsePosition(formData.get("position")),
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
buttonText: buttonText || null,
|
||||||
|
buttonUrl: buttonUrl || null,
|
||||||
|
buttonColor,
|
||||||
|
buttonBorderColor,
|
||||||
|
smallBox: formData.get("smallBox") != null,
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "help_update",
|
||||||
|
description: `Updated help-center entry #${id} (${name})`,
|
||||||
|
targetType: "help_center_category",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Not found, unique collision, or DB error — ignore.
|
||||||
|
revalidatePath(`/admin/help-questions/${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/ase/content/help/questions");
|
redirect("/admin/help-questions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = String(formData.get("id") ?? "").trim();
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
if (!id) return;
|
||||||
await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
try {
|
||||||
"help-question.change",
|
await db
|
||||||
{ action: "delete", id },
|
.delete(WebsiteHelpCenterCategories)
|
||||||
);
|
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
redirect("/ase/content/help/questions");
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "help_delete",
|
||||||
|
description: `Deleted help-center entry #${id}`,
|
||||||
|
targetType: "help_center_category",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Not found or DB error — ignore.
|
||||||
|
}
|
||||||
|
redirect("/admin/help-questions");
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import {
|
import {
|
||||||
addBlacklist,
|
addBlacklist,
|
||||||
@@ -8,65 +9,80 @@ import {
|
|||||||
deleteWhitelist,
|
deleteWhitelist,
|
||||||
} from "./admin-ip";
|
} from "./admin-ip";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||||
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
expectedActorId: staff.id,
|
return { insertValues, deleteWhere };
|
||||||
correlationId,
|
});
|
||||||
legacy: true,
|
|
||||||
})),
|
|
||||||
peopleMutationService: { execute },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
},
|
||||||
|
WebsiteIpWhitelist: { id: "id" },
|
||||||
|
WebsiteIpBlacklist: { id: "id" },
|
||||||
}));
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const form = (data: Record<string, string>) =>
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
execute.mockResolvedValue({
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
ok: true,
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
data: { before: null, after: {} },
|
});
|
||||||
correlationId: "ip",
|
|
||||||
|
describe("addWhitelist", () => {
|
||||||
|
it("creates whitelist entry", async () => {
|
||||||
|
await addWhitelist(
|
||||||
|
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(insertValues).toHaveBeenCalledWith({
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
asn: null,
|
||||||
|
whitelistAsn: false,
|
||||||
|
});
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when ip is empty", async () => {
|
||||||
|
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
||||||
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves all four IP actions and /admin revalidation", async () => {
|
describe("deleteWhitelist", () => {
|
||||||
await addWhitelist(form({ ipAddress: "192.0.2.1", asn: "AS1" }));
|
it("deletes whitelist entry", async () => {
|
||||||
await addBlacklist(form({ ipAddress: "198.51.100.1" }));
|
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
await deleteWhitelist(form({ id: "42" }));
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
await deleteBlacklist(form({ id: "99" }));
|
});
|
||||||
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
|
||||||
[
|
|
||||||
"ip.action",
|
|
||||||
{ action: "add-whitelist", ipAddress: "192.0.2.1", asn: "AS1" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"ip.action",
|
|
||||||
{ action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" },
|
|
||||||
],
|
|
||||||
["ip.action", { action: "delete-whitelist", id: "42" }],
|
|
||||||
["ip.action", { action: "delete-blacklist", id: "99" }],
|
|
||||||
]);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps empty IP input as a no-op after authorization", async () => {
|
describe("addBlacklist", () => {
|
||||||
await addWhitelist(form({ ipAddress: "" }));
|
it("creates blacklist entry", async () => {
|
||||||
expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit");
|
await addBlacklist(
|
||||||
expect(execute).not.toHaveBeenCalled();
|
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(insertValues).toHaveBeenCalledWith({
|
||||||
|
ipAddress: "203.0.113.1",
|
||||||
|
asn: null,
|
||||||
|
blacklistAsn: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves an IP rule ID above Number.MAX_SAFE_INTEGER", async () => {
|
describe("deleteBlacklist", () => {
|
||||||
await deleteBlacklist(form({ id: "9007199254740993" }));
|
it("deletes blacklist entry", async () => {
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "ip.action", {
|
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||||
action: "delete-blacklist",
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
id: "9007199254740993",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+51
-39
@@ -1,60 +1,72 @@
|
|||||||
"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, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
function parse(formData: FormData, key: string): string {
|
function parseIp(formData: FormData): string {
|
||||||
return String(formData.get(key) ?? "")
|
return String(formData.get("ipAddress") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(
|
function parseAsn(formData: FormData): string | null {
|
||||||
formData: FormData,
|
const asn = String(formData.get("asn") ?? "")
|
||||||
action:
|
.normalize("NFC")
|
||||||
| "add-whitelist"
|
.trim()
|
||||||
| "delete-whitelist"
|
.slice(0, 255);
|
||||||
| "add-blacklist"
|
return asn || null;
|
||||||
| "delete-blacklist",
|
|
||||||
): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
|
||||||
const adding = action.startsWith("add-");
|
|
||||||
const rawId = adding ? null : formPositiveBigInt(formData, "id");
|
|
||||||
const input = adding
|
|
||||||
? {
|
|
||||||
action,
|
|
||||||
ipAddress: parse(formData, "ipAddress"),
|
|
||||||
asn: parse(formData, "asn"),
|
|
||||||
}
|
|
||||||
: { action, id: rawId?.toString() ?? "" };
|
|
||||||
if (adding && !("ipAddress" in input && input.ipAddress)) return;
|
|
||||||
if (!adding && !rawId) return;
|
|
||||||
const result = await peopleMutationService.execute(
|
|
||||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
|
||||||
"ip.action",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new Error("Could not update IP rules");
|
|
||||||
revalidatePath("/ase/people/moderation/ip");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addWhitelist(formData: FormData): Promise<void> {
|
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||||
return run(formData, "add-whitelist");
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const ipAddress = parseIp(formData);
|
||||||
|
if (!ipAddress) return;
|
||||||
|
const asn = parseAsn(formData);
|
||||||
|
await db.insert(WebsiteIpWhitelist).values({
|
||||||
|
ipAddress,
|
||||||
|
asn,
|
||||||
|
whitelistAsn: asn != null,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWhitelist(formData: FormData): Promise<void> {
|
export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||||
return run(formData, "delete-whitelist");
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const raw = String(formData.get("id") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!raw) return;
|
||||||
|
await db
|
||||||
|
.delete(WebsiteIpWhitelist)
|
||||||
|
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
|
||||||
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addBlacklist(formData: FormData): Promise<void> {
|
export async function addBlacklist(formData: FormData): Promise<void> {
|
||||||
return run(formData, "add-blacklist");
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const ipAddress = parseIp(formData);
|
||||||
|
if (!ipAddress) return;
|
||||||
|
const asn = parseAsn(formData);
|
||||||
|
await db.insert(WebsiteIpBlacklist).values({
|
||||||
|
ipAddress,
|
||||||
|
asn,
|
||||||
|
blacklistAsn: asn != null,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBlacklist(formData: FormData): Promise<void> {
|
export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||||
return run(formData, "delete-blacklist");
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const raw = String(formData.get("id") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!raw) return;
|
||||||
|
await db
|
||||||
|
.delete(WebsiteIpBlacklist)
|
||||||
|
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
|
||||||
|
revalidatePath("/admin/ip");
|
||||||
}
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,65 +1,59 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { resolveMediaPath } from "@/lib/media-storage";
|
||||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
|
||||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
|
||||||
contentMutationService: { execute },
|
|
||||||
createContentMutationInvocation: (actor, correlationId) => ({
|
|
||||||
expectedActorId: actor.id,
|
|
||||||
correlationId,
|
|
||||||
legacy: true,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/media-storage", () => {
|
||||||
|
const root = path.join("/tmp", "nexst-test-media");
|
||||||
|
return {
|
||||||
|
MEDIA_ROOT: root,
|
||||||
|
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("node:fs/promises", () => ({
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
unlink: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
id: 1,
|
});
|
||||||
rank: 7,
|
|
||||||
username: "admin",
|
describe("uploadMedia", () => {
|
||||||
});
|
it("returns error when no file provided", async () => {
|
||||||
execute.mockResolvedValue({
|
const result = await uploadMedia(new FormData());
|
||||||
ok: true,
|
expect(result.ok).toBe(false);
|
||||||
data: {
|
expect(result.error).toBe("No file provided");
|
||||||
before: null,
|
|
||||||
after: { name: "photo.png" },
|
|
||||||
output: { url: "/api/media/photo.png" },
|
|
||||||
},
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Content media legacy wrappers", () => {
|
describe("uploadMediaAndReturn", () => {
|
||||||
it("retains no-file validation", async () => {
|
it("returns empty string when no file", async () => {
|
||||||
expect(await uploadMedia(new FormData())).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "No file provided",
|
|
||||||
});
|
|
||||||
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||||
expect(execute).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
it("delegates a valid upload and preserves both result shapes", async () => {
|
});
|
||||||
const file = new File(["bytes"], "photo.png", { type: "image/png" });
|
|
||||||
const form = new FormData();
|
describe("deleteMedia", () => {
|
||||||
form.set("file", file);
|
it("deletes media file and revalidates", async () => {
|
||||||
expect(await uploadMedia(form)).toEqual({ ok: true });
|
|
||||||
expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png");
|
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.upload", {
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("delegates deletion and preserves revalidation", async () => {
|
|
||||||
await deleteMedia("photo.png");
|
await deleteMedia("photo.png");
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", {
|
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||||
filename: "photo.png",
|
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/library");
|
it("skips deletion when path is outside media root", async () => {
|
||||||
|
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
||||||
|
await deleteMedia("../../../etc/passwd");
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
expect(unlink).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+59
-50
@@ -1,74 +1,83 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
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 { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MAX_SIZE = 5 * 1024 * 1024;
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
|
||||||
function mediaFile(formData: FormData): File | null {
|
|
||||||
const value = formData.get("file");
|
|
||||||
return value && typeof value === "object" ? (value as File) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateMediaFile(file: File | null): string | null {
|
|
||||||
if (!file || file.size === 0) return "No file provided";
|
|
||||||
if (file.size > MAX_SIZE) return "File too large (max 5MB)";
|
|
||||||
if (!ALLOWED.includes(file.type))
|
|
||||||
return "Invalid file type. Allowed: PNG, JPEG, GIF, WebP";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadMedia(
|
export async function uploadMedia(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<{ ok: boolean; error?: string }> {
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const file = mediaFile(formData);
|
const file = formData.get("file") as File | null;
|
||||||
const error = validateMediaFile(file);
|
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
|
||||||
if (error) return { ok: false, error };
|
if (file.size > MAX_SIZE)
|
||||||
const result = await contentMutationService.execute(
|
return { ok: false, error: "File too large (max 5MB)" };
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
if (!ALLOWED.includes(file.type))
|
||||||
"media.upload",
|
return {
|
||||||
{ file },
|
ok: false,
|
||||||
);
|
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
|
||||||
if (!result.ok) throw new Error("Media upload failed");
|
};
|
||||||
|
|
||||||
|
const baseDir = MEDIA_ROOT;
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await mkdir(baseDir, { recursive: true });
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop() ?? "png";
|
||||||
|
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const filePath = resolveMediaPath(name);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await writeFile(filePath, Buffer.from(bytes));
|
||||||
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/ase/content/media/library");
|
revalidatePath("/admin/media");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMedia(name: string): Promise<void> {
|
export async function deleteMedia(name: string): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
await contentMutationService.execute(
|
const { unlink } = await import("node:fs/promises");
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
const baseDir = MEDIA_ROOT;
|
||||||
"media.delete",
|
const filePath = resolveMediaPath(name);
|
||||||
{ filename: name },
|
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||||
);
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
} catch {
|
||||||
|
// File may not exist
|
||||||
|
}
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/ase/content/media/library");
|
revalidatePath("/admin/media");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadMediaAndReturn(
|
export async function uploadMediaAndReturn(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const file = mediaFile(formData);
|
const file = formData.get("file") as File | null;
|
||||||
if (validateMediaFile(file)) return "";
|
if (!file || file.size === 0) return "";
|
||||||
const result = await contentMutationService.execute(
|
if (file.size > MAX_SIZE) return "";
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
if (!ALLOWED.includes(file.type)) return "";
|
||||||
"media.upload",
|
|
||||||
{ file },
|
const baseDir = MEDIA_ROOT;
|
||||||
);
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
if (!result.ok) return "";
|
await mkdir(baseDir, { recursive: true });
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop() ?? "png";
|
||||||
|
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const filePath = resolveMediaPath(name);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await writeFile(filePath, Buffer.from(bytes));
|
||||||
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/ase/content/media/library");
|
revalidatePath("/admin/media");
|
||||||
return typeof result.data.output?.url === "string"
|
return `/api/media/${name}`;
|
||||||
? result.data.output.url
|
|
||||||
: "";
|
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,72 +1,72 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { deletePhoto } from "./admin-photos";
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
||||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
const limit = vi.fn();
|
||||||
contentMutationService: { execute },
|
const whereSelect = vi.fn(() => ({ limit }));
|
||||||
createContentMutationInvocation: (actor, correlationId) => ({
|
const from = vi.fn(() => ({ where: whereSelect }));
|
||||||
expectedActorId: actor.id,
|
const select = vi.fn(() => ({ from }));
|
||||||
correlationId,
|
const whereDelete = vi.fn();
|
||||||
legacy: true,
|
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
||||||
}),
|
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
||||||
}));
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/admin/photo-files", () => ({
|
||||||
|
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: (...args) => select(...args),
|
||||||
|
delete: (...args) => deleteFn(...args),
|
||||||
|
},
|
||||||
|
CameraWeb: { id: "id", url: "url" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fakeForm = (data) => ({
|
||||||
|
get: (key) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
||||||
|
whereDelete.mockResolvedValue(undefined);
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
id: 1,
|
id: 1,
|
||||||
rank: 7,
|
rank: 7,
|
||||||
username: "admin",
|
username: "admin",
|
||||||
});
|
});
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: { before: { id: 42 }, after: null },
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deletePhoto", () => {
|
describe("deletePhoto", () => {
|
||||||
it("delegates deletion and preserves both revalidations", async () => {
|
it("deletes a photo and revalidates", async () => {
|
||||||
await deletePhoto({ get: (key) => (key === "id" ? "42" : null) });
|
await deletePhoto(fakeForm({ id: "42" }));
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
expect(select).toHaveBeenCalled();
|
||||||
id: 42,
|
expect(deleteFn).toHaveBeenCalled();
|
||||||
});
|
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/photos");
|
expect(logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "photo_delete",
|
||||||
|
targetId: 42,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
await deletePhoto({ get: () => "0" });
|
await deletePhoto(fakeForm({ id: "0" }));
|
||||||
expect(execute).not.toHaveBeenCalled();
|
expect(select).not.toHaveBeenCalled();
|
||||||
});
|
expect(deleteFn).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin-photos extracted runtime contract", () => {
|
|
||||||
it("keeps the wrapper and owning runtime responsible for purge and audit", () => {
|
|
||||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
|
||||||
const runtime = readFileSync(
|
|
||||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(wrapper).toContain('"photo.delete"');
|
|
||||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
|
||||||
expect(runtime).toContain("CameraWeb");
|
|
||||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
|
||||||
expect(runtime).toContain("logStaffActivity");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects traversal and remote photo purge targets", async () => {
|
|
||||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
|
||||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+24
-11
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -1,40 +1,136 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, RadioApiKeys } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
function text(formData: FormData, key: string): string {
|
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
||||||
return String(formData.get(key) ?? "")
|
// widgets, bots) authenticate with a server-generated key. The key itself is
|
||||||
.normalize("NFC")
|
// minted here with crypto.randomBytes — never accepted from the form — and the
|
||||||
.trim();
|
// `permissions` JSON column is intentionally left untouched by this CMS slice.
|
||||||
|
|
||||||
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a BigInt id from a form value, or null when blank/invalid. */
|
||||||
|
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||||
|
const s = str(raw).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
return BigInt(s);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
||||||
|
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
|
||||||
|
const n = Number(str(raw).trim());
|
||||||
|
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||||
|
return Math.floor(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApiKey(formData: FormData): Promise<void> {
|
export async function createApiKey(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.api-key.create", {
|
|
||||||
name: text(formData, "name"),
|
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||||
allowedIps: text(formData, "allowedIps") || undefined,
|
if (!name) return;
|
||||||
rateLimit: Number(text(formData, "rateLimit") || 300),
|
|
||||||
|
const rateLimit = intOr(formData.get("rateLimit"), 300);
|
||||||
|
const allowedIps =
|
||||||
|
str(formData.get("allowedIps")).trim().slice(0, 255) || null;
|
||||||
|
|
||||||
|
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
|
||||||
|
const key = randomBytes(24).toString("hex");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const [result] = await db.insert(RadioApiKeys).values({
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
allowedIps,
|
||||||
|
rateLimit,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/hotel/radio/api-keys");
|
const createdId = BigInt(result.insertId);
|
||||||
redirect("/ase/hotel/radio/api-keys?created=1");
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_api_key_create",
|
||||||
|
description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
|
||||||
|
targetType: "radio_api_key",
|
||||||
|
targetId: Number(createdId),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/radio/api-keys");
|
||||||
|
redirect("/admin/radio/api-keys?created=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
|
|
||||||
id: text(formData, "id"),
|
const id = parseId(formData.get("id"));
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({
|
||||||
|
name: RadioApiKeys.name,
|
||||||
|
isActive: RadioApiKeys.isActive,
|
||||||
|
})
|
||||||
|
.from(RadioApiKeys)
|
||||||
|
.where(eq(RadioApiKeys.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
const next = !existing.isActive;
|
||||||
|
await db
|
||||||
|
.update(RadioApiKeys)
|
||||||
|
.set({ isActive: next, updatedAt: new Date() })
|
||||||
|
.where(eq(RadioApiKeys.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_api_key_toggle",
|
||||||
|
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
|
||||||
|
targetType: "radio_api_key",
|
||||||
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/hotel/radio/api-keys");
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/radio/api-keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
|
|
||||||
id: text(formData, "id"),
|
const id = parseId(formData.get("id"));
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_api_key_delete",
|
||||||
|
description: `Deleted radio API key #${id}`,
|
||||||
|
targetType: "radio_api_key",
|
||||||
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/hotel/radio/api-keys");
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/radio/api-keys");
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,138 @@
|
|||||||
"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, RadioAutoDjPlaylist } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
function text(formData: FormData, key: string): string {
|
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
|
||||||
return String(formData.get(key) ?? "")
|
// fallback playlist the radio rotates through when no live DJ is streaming.
|
||||||
.normalize("NFC")
|
// Faithful to AtomCMS: a flat list of tracks ordered by sort_order then title.
|
||||||
.trim();
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enabled(formData: FormData, key: string): boolean {
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
return ["1", "true", "on"].includes(text(formData, key).toLowerCase());
|
return typeof raw === "string" ? raw : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||||
|
function bool(raw: FormDataEntryValue | null): boolean {
|
||||||
|
const v = str(raw).trim().toLowerCase();
|
||||||
|
return v === "1" || v === "true" || v === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a non-negative UnsignedInt, falling back to 0. */
|
||||||
|
function reqUInt(raw: FormDataEntryValue | null): number {
|
||||||
|
const n = Number(str(raw).trim());
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
|
||||||
|
function optUInt(raw: FormDataEntryValue | null): number | null {
|
||||||
|
const s = str(raw).trim();
|
||||||
|
if (s === "") return null;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return null;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
|
||||||
|
|
||||||
export async function createTrack(formData: FormData): Promise<void> {
|
export async function createTrack(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
const duration = text(formData, "duration");
|
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||||
await executeLegacyHotelMutation(staff, "radio.autodj.create", {
|
if (!title) return;
|
||||||
title: text(formData, "title"),
|
|
||||||
artist: text(formData, "artist") || undefined,
|
const artist = str(formData.get("artist")).trim().slice(0, 255);
|
||||||
album: text(formData, "album") || undefined,
|
const album = str(formData.get("album")).trim().slice(0, 255);
|
||||||
artworkUrl: text(formData, "artworkUrl") || undefined,
|
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
|
||||||
duration: duration ? Number(duration) : null,
|
const duration = optUInt(formData.get("duration"));
|
||||||
sortOrder: Number(text(formData, "sortOrder") || 0),
|
const sortOrder = reqUInt(formData.get("sortOrder"));
|
||||||
isActive: enabled(formData, "isActive"),
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await db.insert(RadioAutoDjPlaylist).values({
|
||||||
|
title,
|
||||||
|
artist: artist || null,
|
||||||
|
album: album || null,
|
||||||
|
artworkUrl: artworkUrl || null,
|
||||||
|
duration,
|
||||||
|
sortOrder,
|
||||||
|
isActive,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/hotel/radio/autodj");
|
const createdId = Number(result.insertId);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_autodj_create",
|
||||||
|
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
||||||
|
targetType: "radio_auto_dj_track",
|
||||||
|
targetId: createdId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fail soft — DB unavailable; re-render without throwing.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/autodj");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.autodj.toggle", {
|
const id = parseId(formData.get("id"));
|
||||||
id: text(formData, "id"),
|
if (id === null) return;
|
||||||
isActive: enabled(formData, "isActive"),
|
|
||||||
|
// The form posts the desired next state so the toggle is idempotent.
|
||||||
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(RadioAutoDjPlaylist)
|
||||||
|
.set({ isActive, updatedAt: new Date() })
|
||||||
|
.where(eq(RadioAutoDjPlaylist.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_autodj_toggle",
|
||||||
|
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
|
||||||
|
targetType: "radio_auto_dj_track",
|
||||||
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/hotel/radio/autodj");
|
} catch {
|
||||||
|
// Row may be gone; ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/autodj");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
export async function deleteTrack(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
|
const id = parseId(formData.get("id"));
|
||||||
id: text(formData, "id"),
|
if (id === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_autodj_delete",
|
||||||
|
description: `Deleted AutoDJ track #${id}`,
|
||||||
|
targetType: "radio_auto_dj_track",
|
||||||
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/hotel/radio/autodj");
|
} catch {
|
||||||
|
// Already deleted; ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/autodj");
|
||||||
}
|
}
|
||||||
@@ -1,119 +1,234 @@
|
|||||||
"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, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
function text(formData: FormData, key: string): string {
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
return String(formData.get(key) ?? "")
|
|
||||||
.normalize("NFC")
|
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||||
.trim();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enabled(formData: FormData, key: string): boolean {
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
return ["1", "true", "on"].includes(text(formData, key).toLowerCase());
|
return typeof raw === "string" ? raw : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function actor() {
|
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||||
return requirePermission(PERMS.RADIO_EDIT);
|
function bool(raw: FormDataEntryValue | null): boolean {
|
||||||
|
const v = str(raw).trim().toLowerCase();
|
||||||
|
return v === "1" || v === "true" || v === "on";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert one radio_* website_settings key. Mirrors AtomCMS's
|
||||||
|
* RadioSettings Filament page (key/value rows in website_settings). Busts the
|
||||||
|
* siteSettings cache so the public radio pages pick the change up immediately.
|
||||||
|
*/
|
||||||
export async function saveRadioSetting(formData: FormData): Promise<void> {
|
export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.settings.save-one", {
|
const key = str(formData.get("key")).trim().slice(0, 255);
|
||||||
key: text(formData, "key"),
|
const value = str(formData.get("value"));
|
||||||
value: String(formData.get("value") ?? ""),
|
const comment = str(formData.get("comment")).trim().slice(0, 255);
|
||||||
comment: text(formData, "comment") || undefined,
|
if (!key) return;
|
||||||
});
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment: comment || null })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
revalidatePath("/ase/hotel/radio/settings");
|
} catch (err) {
|
||||||
|
logger.error("Failed to save radio setting", { err, key });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-save every radio_* field submitted by the settings form in one pass.
|
||||||
|
* The form posts a hidden `__keys` field listing the keys it rendered so we
|
||||||
|
* only touch those (and never wipe unrelated settings).
|
||||||
|
*/
|
||||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
const entries = text(formData, "__keys")
|
const keysRaw = str(formData.get("__keys"));
|
||||||
|
const keys = keysRaw
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((key) => key.trim())
|
.map((k) => k.trim())
|
||||||
.filter(Boolean)
|
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
|
||||||
.map((key) => ({ key, value: String(formData.get(key) ?? "") }));
|
if (keys.length === 0) return;
|
||||||
await executeLegacyHotelMutation(staff, "radio.settings.save-many", {
|
|
||||||
entries,
|
try {
|
||||||
});
|
await Promise.all(
|
||||||
|
keys.map((key) => {
|
||||||
|
const value = str(formData.get(key));
|
||||||
|
return db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment: null })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
revalidatePath("/ase/hotel/radio/settings");
|
} catch (err) {
|
||||||
|
logger.error("Failed to bulk-save radio settings", { err, keys });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
function bannerInput(formData: FormData) {
|
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
|
||||||
return {
|
|
||||||
imagePath: text(formData, "imagePath"),
|
|
||||||
title: text(formData, "title") || undefined,
|
|
||||||
description: text(formData, "description") || undefined,
|
|
||||||
sortOrder: Number(text(formData, "sortOrder") || 0),
|
|
||||||
isActive: enabled(formData, "isActive"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRadioBanner(formData: FormData): Promise<void> {
|
export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(
|
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||||
staff,
|
if (!imagePath) return;
|
||||||
"radio.banner.create",
|
|
||||||
bannerInput(formData),
|
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||||
);
|
const description = str(formData.get("description")).trim();
|
||||||
revalidatePath("/ase/hotel/radio/banners");
|
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||||
|
const sortOrder = Number.isFinite(sortOrderNum)
|
||||||
|
? Math.trunc(sortOrderNum)
|
||||||
|
: 0;
|
||||||
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(RadioBanners).values({
|
||||||
|
userId: BigInt(staff.id),
|
||||||
|
imagePath,
|
||||||
|
title: title || null,
|
||||||
|
description: description || null,
|
||||||
|
sortOrder,
|
||||||
|
isActive,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to create radio banner", { err, imagePath });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/banners");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.banner.update", {
|
const id = parseId(formData.get("id"));
|
||||||
id: text(formData, "id"),
|
if (id === null) return;
|
||||||
...bannerInput(formData),
|
|
||||||
});
|
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||||
revalidatePath("/ase/hotel/radio/banners");
|
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||||
|
const description = str(formData.get("description")).trim();
|
||||||
|
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||||
|
const sortOrder = Number.isFinite(sortOrderNum)
|
||||||
|
? Math.trunc(sortOrderNum)
|
||||||
|
: 0;
|
||||||
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
if (!imagePath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(RadioBanners)
|
||||||
|
.set({
|
||||||
|
imagePath,
|
||||||
|
title: title || null,
|
||||||
|
description: description || null,
|
||||||
|
sortOrder,
|
||||||
|
isActive,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(RadioBanners.id, id));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to update radio banner", { err, id: String(id) });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/banners");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
|
const id = parseId(formData.get("id"));
|
||||||
id: text(formData, "id"),
|
if (id === null) return;
|
||||||
});
|
try {
|
||||||
revalidatePath("/ase/hotel/radio/banners");
|
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to delete radio banner", { err, id: String(id) });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/banners");
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankInput(formData: FormData) {
|
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
|
||||||
return {
|
|
||||||
name: text(formData, "name"),
|
|
||||||
description: text(formData, "description") || undefined,
|
|
||||||
badgeCode: text(formData, "badgeCode") || undefined,
|
|
||||||
isActive: enabled(formData, "isActive"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRadioRank(formData: FormData): Promise<void> {
|
export async function createRadioRank(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(
|
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||||
staff,
|
if (!name) return;
|
||||||
"radio.rank.create",
|
|
||||||
rankInput(formData),
|
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||||
);
|
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||||
revalidatePath("/ase/hotel/radio/ranks");
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(RadioRanks).values({
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
badgeCode: badgeCode || null,
|
||||||
|
isActive,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to create radio rank", { err, name });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/ranks");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.rank.update", {
|
const id = parseId(formData.get("id"));
|
||||||
id: text(formData, "id"),
|
if (id === null) return;
|
||||||
...rankInput(formData),
|
|
||||||
});
|
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||||
revalidatePath("/ase/hotel/radio/ranks");
|
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||||
|
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||||
|
const isActive = bool(formData.get("isActive"));
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(RadioRanks)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
badgeCode: badgeCode || null,
|
||||||
|
isActive,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(RadioRanks.id, id));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to update radio rank", { err, id: String(id) });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/ranks");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||||
const staff = await actor();
|
await requirePermission(PERMS.RADIO_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
|
const id = parseId(formData.get("id"));
|
||||||
id: text(formData, "id"),
|
if (id === null) return;
|
||||||
});
|
try {
|
||||||
revalidatePath("/ase/hotel/radio/ranks");
|
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to delete radio rank", { err, id: String(id) });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/radio/ranks");
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -2,39 +2,93 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
|
||||||
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";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
function text(formData: FormData, key: string): string {
|
// Radio listener-points settings (website_settings radio_points_* keys).
|
||||||
return String(formData.get(key) ?? "")
|
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
|
||||||
.normalize("NFC")
|
// website_settings that reward listeners for time spent on the radio. Booleans
|
||||||
.trim();
|
// use the string '0' / '1'. Busts the siteSettings cache so the public radio
|
||||||
|
// pages pick the change up immediately.
|
||||||
|
|
||||||
|
const POINTS_KEYS = [
|
||||||
|
"radio_points_enabled",
|
||||||
|
"radio_points_per_minute",
|
||||||
|
"radio_points_currency",
|
||||||
|
"radio_points_max_per_day",
|
||||||
|
"radio_points_min_listeners",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ALLOWED_CURRENCIES = new Set([
|
||||||
|
"credits",
|
||||||
|
"duckets",
|
||||||
|
"diamonds",
|
||||||
|
"points",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checkbox/select truthiness → '1' / '0'. */
|
||||||
|
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
|
||||||
|
const v = str(raw).trim().toLowerCase();
|
||||||
|
return v === "1" || v === "true" || v === "on" ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
|
||||||
|
function intStr(raw: FormDataEntryValue | null): string {
|
||||||
|
const n = Number(str(raw).trim());
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "0";
|
||||||
|
return String(Math.floor(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePoints(formData: FormData): Promise<void> {
|
export async function savePoints(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||||
const enabled = ["1", "true", "on"].includes(
|
|
||||||
text(formData, "radio_points_enabled").toLowerCase(),
|
const currencyRaw = str(formData.get("radio_points_currency"))
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
|
||||||
|
? currencyRaw
|
||||||
|
: "credits";
|
||||||
|
|
||||||
|
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||||
|
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||||
|
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||||
|
radio_points_currency: currency,
|
||||||
|
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
|
||||||
|
radio_points_min_listeners: intStr(
|
||||||
|
formData.get("radio_points_min_listeners"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
POINTS_KEYS.map((key) =>
|
||||||
|
db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||||
|
.values({ key, value: values[key], comment: "Radio points" })
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||||
|
set: { value: values[key] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await executeLegacyHotelMutation(staff, "radio.points.save", {
|
|
||||||
radio_points_enabled: enabled,
|
|
||||||
radio_points_per_minute: Number(
|
|
||||||
text(formData, "radio_points_per_minute") || 0,
|
|
||||||
),
|
|
||||||
radio_points_currency: text(
|
|
||||||
formData,
|
|
||||||
"radio_points_currency",
|
|
||||||
).toLowerCase(),
|
|
||||||
radio_points_max_per_day: Number(
|
|
||||||
text(formData, "radio_points_max_per_day") || 0,
|
|
||||||
),
|
|
||||||
radio_points_min_listeners: Number(
|
|
||||||
text(formData, "radio_points_min_listeners") || 0,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
revalidatePath("/ase/hotel/radio/points");
|
await logStaffActivity({
|
||||||
redirect("/ase/hotel/radio/points?saved=1");
|
staffId: staff.id,
|
||||||
|
action: "radio_points_update",
|
||||||
|
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — fail soft so the action does not throw.
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/radio/points");
|
||||||
|
redirect("/admin/radio/points?saved=1");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||||
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export async function createCategory(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const badge = String(formData.get("badge") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const priorityRaw = Number(formData.get("priority"));
|
||||||
|
const priority =
|
||||||
|
Number.isFinite(priorityRaw) && priorityRaw > 0
|
||||||
|
? Math.floor(priorityRaw)
|
||||||
|
: 1;
|
||||||
|
if (!name || !badge) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(WebsiteRareValueCategories).values({
|
||||||
|
name,
|
||||||
|
badge,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/rare-values");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove the category's values first to avoid orphaned rows.
|
||||||
|
await db
|
||||||
|
.delete(WebsiteRareValues)
|
||||||
|
.where(eq(WebsiteRareValues.categoryId, id));
|
||||||
|
await db
|
||||||
|
.delete(WebsiteRareValueCategories)
|
||||||
|
.where(eq(WebsiteRareValueCategories.id, id));
|
||||||
|
} catch {
|
||||||
|
// Not found or DB error — ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/rare-values");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createValue(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||||
|
if (!categoryId) return;
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!name || !furnitureIcon) return;
|
||||||
|
|
||||||
|
const itemIdRaw = Number(formData.get("itemId"));
|
||||||
|
const itemId =
|
||||||
|
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||||
|
|
||||||
|
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const currencyType =
|
||||||
|
String(formData.get("currencyType") ?? "diamonds")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255) || "diamonds";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(WebsiteRareValues).values({
|
||||||
|
categoryId,
|
||||||
|
itemId,
|
||||||
|
name,
|
||||||
|
creditValue: creditValueRaw || null,
|
||||||
|
currencyValue: currencyValueRaw || null,
|
||||||
|
currencyType,
|
||||||
|
furnitureIcon,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// DB error — ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/rare-values");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteValue(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
|
||||||
|
} catch {
|
||||||
|
// Not found or DB error — ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/rare-values");
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||||
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
|
import {
|
||||||
|
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
||||||
|
normalizeHabboGamedataHotel,
|
||||||
|
} from "@/lib/habbo-gamedata-hotel";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
|
||||||
|
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||||
|
|
||||||
|
function normalizeSettingValue(key: string, value: string): string {
|
||||||
|
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
||||||
|
return normalizeHabboGamedataHotel(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bustGamedataCachesIfNeeded(key: string): void {
|
||||||
|
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
||||||
|
clearOfficialHabboFurnidataCache();
|
||||||
|
clearBadgeCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveManagedSchema = z.object({
|
||||||
|
settings: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveManagedSettings = adminAction(
|
||||||
|
{
|
||||||
|
permission: PERMS.SETTINGS_EDIT,
|
||||||
|
schema: saveManagedSchema,
|
||||||
|
rateLimitKey: "admin-settings-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const entries = Object.entries(ctx.data.settings)
|
||||||
|
.filter(([key]) => managedKeySet.has(key))
|
||||||
|
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(([key, value]) =>
|
||||||
|
db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await siteSettings.reload();
|
||||||
|
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
||||||
|
clearOfficialHabboFurnidataCache();
|
||||||
|
clearBadgeCache();
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return actionOk({ saved: entries.length });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function updateSetting(formData: FormData): Promise<void> {
|
||||||
|
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||||
|
const key = String(formData.get("key") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const value = normalizeSettingValue(
|
||||||
|
key,
|
||||||
|
String(formData.get("value") ?? "").normalize("NFC"),
|
||||||
|
);
|
||||||
|
if (!key) return;
|
||||||
|
await db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
await siteSettings.reload();
|
||||||
|
bustGamedataCachesIfNeeded(key);
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSetting(formData: FormData): Promise<void> {
|
||||||
|
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||||
|
const key = String(formData.get("key") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const value = normalizeSettingValue(
|
||||||
|
key,
|
||||||
|
String(formData.get("value") ?? "").normalize("NFC"),
|
||||||
|
);
|
||||||
|
const comment = String(formData.get("comment") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!key) return;
|
||||||
|
await db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment: comment || null })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
await siteSettings.reload();
|
||||||
|
bustGamedataCachesIfNeeded(key);
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSetting(formData: FormData): Promise<void> {
|
||||||
|
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||||
|
const key = String(formData.get("key") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!key) return;
|
||||||
|
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||||
|
await siteSettings.reload();
|
||||||
|
bustGamedataCachesIfNeeded(key);
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteShopArticles } from "@/lib/db";
|
||||||
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
// Website store packages (website_shop_articles). This CMS-owned table backs
|
||||||
|
// the public store; rows here are the buyable packages, not orders. The closest
|
||||||
|
// "orders" record is website_paypal_transactions, exposed read-only by the page.
|
||||||
|
|
||||||
|
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
|
||||||
|
function optUInt(formData: FormData, key: string): number | null {
|
||||||
|
const raw = String(formData.get(key) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (raw === "") return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return null;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a required non-negative UnsignedInt, falling back to 0. */
|
||||||
|
function reqUInt(formData: FormData, key: string): number {
|
||||||
|
const n = optUInt(formData, key);
|
||||||
|
return n ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const costs = reqUInt(formData, "costs");
|
||||||
|
try {
|
||||||
|
const [result] = (await db.insert(WebsiteShopArticles).values({
|
||||||
|
name,
|
||||||
|
info: String(formData.get("info") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
iconUrl: String(formData.get("icon") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
color: String(formData.get("color") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
costs,
|
||||||
|
giveRank: optUInt(formData, "giveRank"),
|
||||||
|
credits: optUInt(formData, "credits"),
|
||||||
|
duckets: optUInt(formData, "duckets"),
|
||||||
|
diamonds: optUInt(formData, "diamonds"),
|
||||||
|
badges:
|
||||||
|
String(formData.get("badges") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255) || null,
|
||||||
|
position: reqUInt(formData, "position"),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "shop_create",
|
||||||
|
description: `Created shop package "${name}" (${costs} costs)`,
|
||||||
|
targetType: "shop_article",
|
||||||
|
targetId: Number(result.insertId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.shop_create_failed", error, {
|
||||||
|
staffId: staff.id,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/admin/shop");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteShopArticles)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
info: String(formData.get("info") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
iconUrl: String(formData.get("icon") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
color: String(formData.get("color") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
costs: reqUInt(formData, "costs"),
|
||||||
|
giveRank: optUInt(formData, "giveRank"),
|
||||||
|
credits: optUInt(formData, "credits"),
|
||||||
|
duckets: optUInt(formData, "duckets"),
|
||||||
|
diamonds: optUInt(formData, "diamonds"),
|
||||||
|
badges:
|
||||||
|
String(formData.get("badges") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255) || null,
|
||||||
|
position: reqUInt(formData, "position"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteShopArticles.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "shop_update",
|
||||||
|
description: `Updated shop package #${id} ("${name}")`,
|
||||||
|
targetType: "shop_article",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.shop_update_failed", error, {
|
||||||
|
staffId: staff.id,
|
||||||
|
articleId: String(id),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/admin/shop/${id}`);
|
||||||
|
redirect("/admin/shop");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "shop_delete",
|
||||||
|
description: `Deleted shop package #${id}`,
|
||||||
|
targetType: "shop_article",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.shop_delete_failed", error, {
|
||||||
|
staffId: staff.id,
|
||||||
|
articleId: String(id),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/admin/shop");
|
||||||
|
}
|
||||||
+105
-66
@@ -2,94 +2,133 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
|
||||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
() => {
|
||||||
contentMutationService: { execute },
|
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||||
createContentMutationInvocation: (actor, correlationId) => ({
|
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
expectedActorId: actor.id,
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
correlationId,
|
const transaction = vi.fn(async (fn) =>
|
||||||
legacy: true,
|
fn({
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
|
return { insertValues, updateWhere, deleteWhere, transaction };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
|
||||||
|
Taggables: { tagId: "tagId" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const form = (data) => ({ get: (key) => data[key] ?? null });
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
beforeEach(() => {
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
vi.clearAllMocks();
|
get: (key: string) => data[key] ?? null,
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
rank: 7,
|
|
||||||
username: "admin",
|
|
||||||
});
|
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: { before: null, after: { id: "1" } },
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Content tag legacy wrappers", () => {
|
beforeEach(() => {
|
||||||
it("delegates create with normalized values", async () => {
|
vi.clearAllMocks();
|
||||||
await createTag(form({ name: "News", backgroundColor: "#ff0000" }));
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", {
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
action: "create",
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
transaction.mockImplementation(async (fn) =>
|
||||||
|
fn({
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTag", () => {
|
||||||
|
it("creates a tag and revalidates", async () => {
|
||||||
|
await createTag(
|
||||||
|
fakeForm({
|
||||||
name: "News",
|
name: "News",
|
||||||
backgroundColor: "#ff0000",
|
backgroundColor: "#ff0000",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: "News" }),
|
||||||
|
);
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
|
||||||
|
it("returns early when name is empty", async () => {
|
||||||
|
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
||||||
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("uses the legacy default color", async () => {
|
|
||||||
await createTag(form({ name: "Test" }));
|
it("uses default color when not provided", async () => {
|
||||||
expect(execute).toHaveBeenCalledWith(
|
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
||||||
expect.anything(),
|
|
||||||
"tag.change",
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ backgroundColor: "#888888" }),
|
expect.objectContaining({ backgroundColor: "#888888" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("returns early for an empty name", async () => {
|
|
||||||
await createTag(form({ name: "" }));
|
it("handles db error gracefully", async () => {
|
||||||
expect(execute).not.toHaveBeenCalled();
|
insertValues.mockRejectedValue(new Error("DB error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
it("revalidates after a fail-soft dependency result", async () => {
|
});
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: false,
|
describe("updateTag", () => {
|
||||||
error: {
|
it("updates a tag and revalidates", async () => {
|
||||||
code: "DEPENDENCY_UNAVAILABLE",
|
|
||||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
||||||
},
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
|
||||||
await createTag(form({ name: "News" }));
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
|
||||||
});
|
|
||||||
it("delegates update", async () => {
|
|
||||||
await updateTag(
|
await updateTag(
|
||||||
form({ id: "42", name: "Updated", backgroundColor: "#00ff00" }),
|
fakeForm({
|
||||||
|
id: "42",
|
||||||
|
name: "Updated",
|
||||||
|
backgroundColor: "#00ff00",
|
||||||
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
"tag.change",
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect.objectContaining({ action: "update", id: "42" }),
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
);
|
|
||||||
});
|
});
|
||||||
it("rejects invalid update id or name", async () => {
|
|
||||||
await updateTag(form({ id: "", name: "Test" }));
|
it("returns early when id is invalid", async () => {
|
||||||
await updateTag(form({ id: "42", name: "" }));
|
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
||||||
expect(execute).not.toHaveBeenCalled();
|
expect(updateWhere).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("delegates delete", async () => {
|
|
||||||
await deleteTag(form({ id: "42" }));
|
it("returns early when name is empty after update", async () => {
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", {
|
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||||
action: "delete",
|
expect(updateWhere).not.toHaveBeenCalled();
|
||||||
id: "42",
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteTag", () => {
|
||||||
|
it("deletes a tag and its taggables", async () => {
|
||||||
|
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
|
||||||
|
expect(transaction).toHaveBeenCalled();
|
||||||
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
it("rejects invalid delete id", async () => {
|
|
||||||
await deleteTag(form({ id: "" }));
|
it("returns early when id is invalid", async () => {
|
||||||
expect(execute).not.toHaveBeenCalled();
|
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
||||||
|
expect(transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+94
-41
@@ -1,60 +1,113 @@
|
|||||||
"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 {
|
|
||||||
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 { db, Taggables, Tags } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
function tagInput(formData: FormData) {
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
return {
|
|
||||||
name: String(formData.get("name") ?? "")
|
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||||
.trim()
|
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||||
.slice(0, 255),
|
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||||
backgroundColor:
|
try {
|
||||||
String(formData.get("backgroundColor") ?? "")
|
const id = BigInt(raw.trim());
|
||||||
.trim()
|
return id > 0n ? id : null;
|
||||||
.slice(0, 10) || "#888888",
|
} catch {
|
||||||
};
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise a hex-ish colour into the 10-char background_color column. */
|
||||||
|
function normaliseColor(raw: string): string {
|
||||||
|
const v = raw.trim().slice(0, 10);
|
||||||
|
return v || "#888888";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
|
||||||
|
|
||||||
export async function createTag(formData: FormData): Promise<void> {
|
export async function createTag(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const input = tagInput(formData);
|
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||||
if (!input.name) return;
|
if (!name) return;
|
||||||
await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||||
"tag.change",
|
const now = new Date();
|
||||||
{ action: "create", ...input },
|
|
||||||
);
|
try {
|
||||||
revalidatePath("/ase/content/editorial/tags");
|
const [result] = (await db.insert(Tags).values({
|
||||||
|
name,
|
||||||
|
backgroundColor,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "tag_create",
|
||||||
|
description: `Created tag "${name}" (#${result.insertId})`,
|
||||||
|
targetType: "tag",
|
||||||
|
targetId: Number(result.insertId),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fail soft — DB unavailable or duplicate.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTag(formData: FormData): Promise<void> {
|
export async function updateTag(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = String(formData.get("id") ?? "").trim();
|
const id = parseId(formData.get("id"));
|
||||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
if (id === null) return;
|
||||||
const input = tagInput(formData);
|
|
||||||
if (!input.name) return;
|
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||||
await contentMutationService.execute(
|
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
if (!name) return;
|
||||||
"tag.change",
|
|
||||||
{ action: "update", id, ...input },
|
try {
|
||||||
);
|
await db
|
||||||
revalidatePath("/ase/content/editorial/tags");
|
.update(Tags)
|
||||||
|
.set({ name, backgroundColor, updatedAt: new Date() })
|
||||||
|
.where(eq(Tags.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "tag_update",
|
||||||
|
description: `Updated tag #${id} → "${name}"`,
|
||||||
|
targetType: "tag",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Row may be gone; ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTag(formData: FormData): Promise<void> {
|
export async function deleteTag(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = String(formData.get("id") ?? "").trim();
|
const id = parseId(formData.get("id"));
|
||||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
if (id === null) return;
|
||||||
await contentMutationService.execute(
|
|
||||||
createContentMutationInvocation(staff, createCorrelationId()),
|
try {
|
||||||
"tag.change",
|
// Remove the tag and any taggable links pointing at it.
|
||||||
{ action: "delete", id },
|
await db.transaction(async (tx) => {
|
||||||
);
|
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
|
||||||
revalidatePath("/ase/content/editorial/tags");
|
await tx.delete(Tags).where(eq(Tags.id, id));
|
||||||
|
});
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "tag_delete",
|
||||||
|
description: `Deleted tag #${id}`,
|
||||||
|
targetType: "tag",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Already deleted; ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/tags");
|
||||||
}
|
}
|
||||||
@@ -1,69 +1,59 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { createTeam, deleteTeam } from "./admin-teams";
|
import { createTeam, deleteTeam } from "./admin-teams";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||||
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
expectedActorId: staff.id,
|
return { insertValues, deleteWhere };
|
||||||
correlationId,
|
});
|
||||||
legacy: true,
|
|
||||||
})),
|
|
||||||
peopleMutationService: { execute },
|
|
||||||
}));
|
|
||||||
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: { USERS_EDIT: "users.edit" } }));
|
||||||
PERMS: { USERS_EDIT: "admin.users.edit" },
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
|
},
|
||||||
|
WebsiteTeams: { id: "id" },
|
||||||
}));
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const form = (data: Record<string, string>) =>
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
const fakeForm = (data: Record<string, string | null>) => ({
|
||||||
|
get: (key: string) => (key in data ? data[key] : null),
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
id: 1,
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
rank: 7,
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
username: "admin",
|
});
|
||||||
|
|
||||||
|
describe("createTeam", () => {
|
||||||
|
it("creates a team entry", async () => {
|
||||||
|
await createTeam(
|
||||||
|
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(insertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ rankName: "Moderator" }),
|
||||||
|
);
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||||
});
|
});
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: true,
|
it("returns early when rankName is empty", async () => {
|
||||||
data: { before: null, after: {} },
|
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
|
||||||
correlationId: "team",
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves create and delete team payloads plus /admin revalidation", async () => {
|
describe("deleteTeam", () => {
|
||||||
await createTeam(form({ rankName: "Moderator" }));
|
it("deletes a team entry", async () => {
|
||||||
await deleteTeam(form({ id: "42" }));
|
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
[
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||||
"team.change",
|
|
||||||
{
|
|
||||||
action: "create",
|
|
||||||
rankName: "Moderator",
|
|
||||||
badge: "",
|
|
||||||
jobDescription: "",
|
|
||||||
staffColor: "#327fa8",
|
|
||||||
hiddenRank: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
["team.change", { action: "delete", teamId: "42" }],
|
|
||||||
]);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves empty rank name as a no-op", async () => {
|
|
||||||
await createTeam(form({ rankName: "" }));
|
|
||||||
expect(execute).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a team ID above Number.MAX_SAFE_INTEGER", async () => {
|
|
||||||
await deleteTeam(form({ id: "9007199254740993" }));
|
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "team.change", {
|
|
||||||
action: "delete",
|
|
||||||
teamId: "9007199254740993",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+37
-40
@@ -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");
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||||
|
import {
|
||||||
|
deleteCustomThemeStore,
|
||||||
|
getCustomTheme,
|
||||||
|
snapshotCurrentTheme,
|
||||||
|
upsertCustomTheme,
|
||||||
|
} from "@/lib/theme-custom-store";
|
||||||
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||||
|
|
||||||
|
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||||
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
|
// Extra colour settings beyond the preset palette (buttons + links + gradients).
|
||||||
|
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
|
||||||
|
const CUSTOM_CSS_MAX = 20000;
|
||||||
|
|
||||||
|
async function writeSetting(key: string, value: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTheme(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const mode of ["light", "dark"] as const) {
|
||||||
|
const bag: Record<string, string> = {};
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
const dbKey = settingKey(key, mode);
|
||||||
|
const raw = String(formData.get(dbKey) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
||||||
|
}
|
||||||
|
const fixed = ensureReadableThemeColors(bag);
|
||||||
|
for (const [key, value] of Object.entries(fixed)) {
|
||||||
|
await writeSetting(
|
||||||
|
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ADMIN_KEYS = [
|
||||||
|
"admin_canvas",
|
||||||
|
"admin_surface",
|
||||||
|
"admin_text",
|
||||||
|
"admin_text_muted",
|
||||||
|
"admin_border",
|
||||||
|
"admin_sidebar_bg",
|
||||||
|
] as const;
|
||||||
|
const adminBag: Record<string, string> = {};
|
||||||
|
for (const key of ADMIN_KEYS) {
|
||||||
|
const raw = String(formData.get(key) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
||||||
|
}
|
||||||
|
const adminFixed = ensureReadableThemeColors(adminBag);
|
||||||
|
for (const [key, value] of Object.entries(adminFixed)) {
|
||||||
|
await writeSetting(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radius = String(formData.get("border_radius") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
const font = String(formData.get("font_family") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (font in FONTS) await writeSetting("font_family", font);
|
||||||
|
for (const key of HEADING_KEYS) {
|
||||||
|
const v = String(formData.get(key) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
|
||||||
|
if (formData.has("custom_css")) {
|
||||||
|
const cssRaw = String(formData.get("custom_css") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.slice(0, CUSTOM_CSS_MAX);
|
||||||
|
await writeSetting("custom_css", cssRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_update",
|
||||||
|
description: "Updated theme settings",
|
||||||
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
} catch {
|
||||||
|
// ignore — page re-renders current state
|
||||||
|
}
|
||||||
|
redirect("/admin/theme?saved=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPreset(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||||
|
const preset = PRESETS[name];
|
||||||
|
if (!preset) redirect("/admin/theme");
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [key, value] of presetSettings(preset))
|
||||||
|
await writeSetting(key, value);
|
||||||
|
await writeSetting("theme_preset", name);
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_preset",
|
||||||
|
description: `Applied theme preset "${name}"`,
|
||||||
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!name) redirect("/admin/theme");
|
||||||
|
const snapshot = await snapshotCurrentTheme();
|
||||||
|
try {
|
||||||
|
await upsertCustomTheme(name, snapshot);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_preset",
|
||||||
|
description: `Saved custom theme "${name}"`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/theme");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme?savedTheme=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCustomTheme(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const id = String(formData.get("id") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!id) redirect("/admin/theme");
|
||||||
|
const theme = await getCustomTheme(id);
|
||||||
|
if (!theme) redirect("/admin/theme");
|
||||||
|
try {
|
||||||
|
for (const [key, value] of Object.entries(theme.settings)) {
|
||||||
|
if (value) await writeSetting(key, value);
|
||||||
|
}
|
||||||
|
await writeSetting("theme_preset", theme.name);
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "theme_preset",
|
||||||
|
description: `Applied custom theme "${theme.name}"`,
|
||||||
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const id = String(formData.get("id") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!id || !name) redirect("/admin/theme");
|
||||||
|
const snapshot = await snapshotCurrentTheme();
|
||||||
|
try {
|
||||||
|
await upsertCustomTheme(name, snapshot, id);
|
||||||
|
revalidatePath("/admin/theme");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme?renamed=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomTheme(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const id = String(formData.get("id") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!id) redirect("/admin/theme");
|
||||||
|
try {
|
||||||
|
await deleteCustomThemeStore(id);
|
||||||
|
revalidatePath("/admin/theme");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
redirect("/admin/theme?deletedTheme=1");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +1,60 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { saveVpn } from "./admin-vpn";
|
import { saveVpn } from "./admin-vpn";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
const mockValues = vi.fn(() => ({
|
||||||
expectedActorId: staff.id,
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
correlationId,
|
}));
|
||||||
legacy: true,
|
return { mockValues, mockOnDuplicateKeyUpdate };
|
||||||
})),
|
});
|
||||||
peopleMutationService: { execute },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({
|
||||||
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mockValues })),
|
||||||
|
},
|
||||||
|
WebsiteSetting: { key: "key", value: "value" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
const form = (data: Record<string, string>) =>
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
const fakeForm = (data: Record<string, string | null>) => ({
|
||||||
|
get: (key: string) => (key in data ? data[key] : null),
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
id: 1,
|
mockValues.mockReturnValue({
|
||||||
rank: 7,
|
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||||
username: "admin",
|
|
||||||
});
|
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: { before: {}, after: {} },
|
|
||||||
correlationId: "vpn",
|
|
||||||
});
|
});
|
||||||
|
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves VPN payload, ASE revalidation, and redirect", async () => {
|
describe("saveVpn", () => {
|
||||||
|
it("saves VPN settings and redirects", async () => {
|
||||||
await saveVpn(
|
await saveVpn(
|
||||||
form({
|
fakeForm({
|
||||||
vpn_block_enabled: "1",
|
vpn_block_enabled: "1",
|
||||||
vpn_provider: "proxycheck",
|
vpn_provider: "proxycheck",
|
||||||
vpn_api_key: "abc123",
|
vpn_api_key: "abc123",
|
||||||
}),
|
}) as unknown as FormData,
|
||||||
);
|
);
|
||||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "vpn.configure", {
|
expect(mockValues).toHaveBeenCalledTimes(4);
|
||||||
enabled: true,
|
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
|
||||||
provider: "proxycheck",
|
expect(siteSettings.reload).toHaveBeenCalled();
|
||||||
apiKey: "abc123",
|
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||||
blockMessage: "",
|
|
||||||
});
|
});
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/vpn");
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
|
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "DEPENDENCY_UNAVAILABLE",
|
|
||||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
||||||
},
|
|
||||||
correlationId: "vpn-fail",
|
|
||||||
});
|
|
||||||
await saveVpn(form({ vpn_provider: "none" }));
|
|
||||||
expect(revalidatePath).not.toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
|
|
||||||
});
|
});
|
||||||
+69
-21
@@ -2,40 +2,88 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
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, WebsiteSetting } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
// VPN / proxy detection config. Stored as website_settings key/value rows
|
||||||
|
// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to
|
||||||
|
// AtomCMS's setting() convention. This is registration-time protection only;
|
||||||
|
// the raw IP allow/deny list lives under /admin/ip (website_ip_*).
|
||||||
|
|
||||||
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
||||||
|
|
||||||
|
/** Upsert one website_settings key with a stable housekeeping comment. */
|
||||||
|
async function writeSetting(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
comment: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveVpn(formData: FormData): Promise<void> {
|
export async function saveVpn(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
const rawProvider = String(formData.get("vpn_provider") ?? "")
|
|
||||||
|
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||||
|
const enabled =
|
||||||
|
String(formData.get("vpn_block_enabled") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim() !== "";
|
||||||
|
|
||||||
|
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
|
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||||
const result = await peopleMutationService.execute(
|
|
||||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
||||||
"vpn.configure",
|
|
||||||
{
|
|
||||||
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
|
|
||||||
provider,
|
|
||||||
apiKey: String(formData.get("vpn_api_key") ?? "")
|
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255);
|
||||||
blockMessage: String(formData.get("vpn_block_message") ?? "")
|
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255);
|
||||||
},
|
|
||||||
|
try {
|
||||||
|
await writeSetting(
|
||||||
|
"vpn_block_enabled",
|
||||||
|
enabled ? "1" : "0",
|
||||||
|
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||||
);
|
);
|
||||||
if (result.ok) revalidatePath("/ase/people/moderation/vpn");
|
await writeSetting(
|
||||||
// Preserve fail-soft legacy navigation even when persistence is unavailable.
|
"vpn_provider",
|
||||||
redirect("/ase/people/moderation/vpn?saved=1");
|
provider,
|
||||||
|
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||||
|
);
|
||||||
|
await writeSetting(
|
||||||
|
"vpn_api_key",
|
||||||
|
apiKey,
|
||||||
|
"API key for the VPN/proxy detection provider",
|
||||||
|
);
|
||||||
|
await writeSetting(
|
||||||
|
"vpn_block_message",
|
||||||
|
blockMessage,
|
||||||
|
"Message shown to users blocked for using a VPN/proxy",
|
||||||
|
);
|
||||||
|
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "vpn_update",
|
||||||
|
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/vpn");
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — fail soft so the action does not throw; the page
|
||||||
|
// re-renders the current (stored) state.
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/admin/vpn?saved=1");
|
||||||
}
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, WebsiteWriteableBoxes } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
|
||||||
|
// content panels rendered on the public home page. Active boxes (is_active)
|
||||||
|
// are the ones shown publicly, ordered by `position`.
|
||||||
|
|
||||||
|
/** Parse a non-negative Int form value, falling back to 0. */
|
||||||
|
function reqInt(formData: FormData, key: string): number {
|
||||||
|
const raw = String(formData.get(key) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (raw === "") return 0;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
|
||||||
|
function parseId(formData: FormData): bigint | null {
|
||||||
|
const raw = String(formData.get("id") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return BigInt(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidate(): void {
|
||||||
|
revalidatePath("/admin/writeable-boxes");
|
||||||
|
// Active boxes render on the public home page (root layout).
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBox(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
|
||||||
|
const title = String(formData.get("title") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
|
||||||
|
title,
|
||||||
|
icon:
|
||||||
|
String(formData.get("icon") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255) || null,
|
||||||
|
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||||
|
position: reqInt(formData, "position"),
|
||||||
|
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "writeable_box_create",
|
||||||
|
description: `Created writeable box "${title}" (#${result.insertId})`,
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: Number(result.insertId),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — swallow and re-render.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBox(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
|
||||||
|
const id = parseId(formData);
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
const title = String(formData.get("title") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteWriteableBoxes)
|
||||||
|
.set({
|
||||||
|
title,
|
||||||
|
icon:
|
||||||
|
String(formData.get("icon") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255) || null,
|
||||||
|
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||||
|
position: reqInt(formData, "position"),
|
||||||
|
isActive:
|
||||||
|
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "writeable_box_update",
|
||||||
|
description: `Updated writeable box #${id} ("${title}")`,
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBox(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
|
||||||
|
const id = parseId(formData);
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(WebsiteWriteableBoxes)
|
||||||
|
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "writeable_box_delete",
|
||||||
|
description: `Deleted writeable box #${id}`,
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleBox(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
|
||||||
|
const id = parseId(formData);
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
// `next` carries the desired state ("1" to activate, anything else to hide).
|
||||||
|
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteWriteableBoxes)
|
||||||
|
.set({ isActive: next, updatedAt: new Date() })
|
||||||
|
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "writeable_box_toggle",
|
||||||
|
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+155
-129
@@ -1,30 +1,95 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import {
|
import {
|
||||||
bulkBan,
|
bulkBan,
|
||||||
bulkGiveBadge,
|
bulkGiveBadge,
|
||||||
bulkGiveCurrency,
|
bulkGiveCurrency,
|
||||||
bulkUnban,
|
bulkUnban,
|
||||||
setTradeLock,
|
|
||||||
} from "./bulk-users";
|
} from "./bulk-users";
|
||||||
|
|
||||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
const {
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
deleteWhere,
|
||||||
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
insertValues,
|
||||||
expectedActorId: staff.id,
|
updateWhere,
|
||||||
correlationId,
|
selectLimit,
|
||||||
legacy: true,
|
selectWhereResolved,
|
||||||
})),
|
onDuplicateKeyUpdate,
|
||||||
peopleMutationService: { execute },
|
} = vi.hoisted(() => {
|
||||||
}));
|
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]);
|
||||||
|
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
const insertValues = vi.fn(() => ({
|
||||||
|
onDuplicateKeyUpdate,
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||||
|
then(resolve, reject) {
|
||||||
|
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
const selectLimit = vi.fn().mockResolvedValue([]);
|
||||||
|
/** Rows returned when a select chain is awaited without `.limit()`. */
|
||||||
|
const selectWhereResolved = vi.fn().mockResolvedValue([]);
|
||||||
|
return {
|
||||||
|
deleteWhere,
|
||||||
|
insertValues,
|
||||||
|
updateWhere,
|
||||||
|
selectLimit,
|
||||||
|
selectWhereResolved,
|
||||||
|
onDuplicateKeyUpdate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
PERMS: { USERS_EDIT: "admin.users.edit" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
vi.mock("@/lib/db", () => ({
|
||||||
db: {},
|
db: {
|
||||||
User: {},
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||||
UsersCurrency: {},
|
insert: vi.fn(() => ({ values: insertValues })),
|
||||||
|
update: vi.fn(() => ({
|
||||||
|
set: vi.fn(() => ({ where: updateWhere })),
|
||||||
|
})),
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: selectLimit,
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||||
|
then(resolve, reject) {
|
||||||
|
return selectWhereResolved().then(resolve, reject);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
Ban: { userId: "userId", id: "id" },
|
||||||
|
User: {
|
||||||
|
id: "id",
|
||||||
|
credits: "credits",
|
||||||
|
username: "username",
|
||||||
|
online: "online",
|
||||||
|
},
|
||||||
|
UsersCurrency: { userId: "userId", type: "type", amount: "amount" },
|
||||||
|
UsersBadges: {
|
||||||
|
id: "id",
|
||||||
|
userId: "userId",
|
||||||
|
badgeCode: "badgeCode",
|
||||||
|
slotId: "slotId",
|
||||||
|
},
|
||||||
|
Sanctions: { id: "id", habboId: "habboId" },
|
||||||
|
UsersSettings: {
|
||||||
|
userId: "userId",
|
||||||
|
canTrade: "canTrade",
|
||||||
|
tradelockAmount: "tradelockAmount",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: {
|
||||||
|
giveCredits: vi.fn(),
|
||||||
|
giveDuckets: vi.fn(),
|
||||||
|
givePointsGotw: vi.fn(),
|
||||||
|
giveBadge: vi.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
|
||||||
@@ -33,122 +98,83 @@ const staff = { id: 1, rank: 7, username: "admin" };
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
execute.mockImplementation(async (context, operation, input) => ({
|
deleteWhere.mockResolvedValue([{ affectedRows: 3 }]);
|
||||||
ok: true,
|
insertValues.mockImplementation(() => ({
|
||||||
data: {
|
onDuplicateKeyUpdate,
|
||||||
before: { input },
|
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||||
after: {
|
then(resolve, reject) {
|
||||||
completed: operation === "users.bulk-unban" ? 3 : 2,
|
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
|
||||||
total: Array.isArray(input.userIds) ? input.userIds.length : 1,
|
|
||||||
failedIds: [],
|
|
||||||
},
|
},
|
||||||
output: operation === "user.trade-lock" ? input : undefined,
|
|
||||||
},
|
|
||||||
correlationId: context.correlationId,
|
|
||||||
}));
|
}));
|
||||||
|
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
selectLimit.mockResolvedValue([]);
|
||||||
|
selectWhereResolved.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("legacy bulk user wrappers", () => {
|
describe("bulkUnban", () => {
|
||||||
it("preserves result shapes while delegating the exact operations", async () => {
|
it("unbans users", async () => {
|
||||||
await expect(bulkUnban({ userIds: [1, 2, 3] })).resolves.toEqual({
|
const r = await bulkUnban({ userIds: [1, 2, 3] });
|
||||||
ok: true,
|
expect(r.ok).toBe(true);
|
||||||
data: { unbanned: 3, total: 3 },
|
expect(r.data).toEqual({ unbanned: 3, total: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkBan", () => {
|
||||||
|
it("bans users", async () => {
|
||||||
|
const r = await bulkBan({
|
||||||
|
userIds: [1, 2],
|
||||||
|
reason: "Spam",
|
||||||
|
duration: 3600,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.data.banned).toBe(2);
|
||||||
|
expect(insertValues).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkGiveCurrency", () => {
|
||||||
|
it("gives credits", async () => {
|
||||||
|
const r = await bulkGiveCurrency({
|
||||||
|
userIds: [1],
|
||||||
|
amount: 100,
|
||||||
|
type: "credits",
|
||||||
|
});
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
||||||
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives pixels", async () => {
|
||||||
|
const r = await bulkGiveCurrency({
|
||||||
|
userIds: [2],
|
||||||
|
amount: 50,
|
||||||
|
type: "pixels",
|
||||||
|
});
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
|
||||||
|
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives points", async () => {
|
||||||
|
const r = await bulkGiveCurrency({
|
||||||
|
userIds: [3],
|
||||||
|
amount: 25,
|
||||||
|
type: "points",
|
||||||
|
});
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
|
||||||
|
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkGiveBadge", () => {
|
||||||
|
it("gives badge to user", async () => {
|
||||||
|
selectLimit.mockResolvedValueOnce([]);
|
||||||
|
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
|
||||||
|
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(insertValues).toHaveBeenCalled();
|
||||||
|
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
|
||||||
});
|
});
|
||||||
await expect(
|
|
||||||
bulkBan({ userIds: [1, 2], reason: "Spam", duration: 3600 }),
|
|
||||||
).resolves.toEqual({ ok: true, data: { banned: 2 } });
|
|
||||||
await expect(
|
|
||||||
bulkGiveCurrency({ userIds: [1], amount: 100, type: "credits" }),
|
|
||||||
).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: { given: 2, total: 1, failedIds: [] },
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }),
|
|
||||||
).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: { given: 2, total: 1, failedIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(execute.mock.calls.map((call) => call[1])).toEqual([
|
|
||||||
"users.bulk-unban",
|
|
||||||
"users.bulk-ban",
|
|
||||||
"users.bulk-currency",
|
|
||||||
"users.bulk-badge",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the trade-lock API and exact normalized payload", async () => {
|
|
||||||
await expect(
|
|
||||||
setTradeLock({ userId: 9, untilUnix: 1234.8 }),
|
|
||||||
).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: { userId: 9, untilUnix: 1234 },
|
|
||||||
});
|
|
||||||
expect(execute).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({ expectedActorId: 1 }),
|
|
||||||
"user.trade-lock",
|
|
||||||
{ userId: 9, untilUnix: 1234 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
"currency",
|
|
||||||
"users.bulk-currency",
|
|
||||||
() => bulkGiveCurrency({ userIds: [7], amount: 100, type: "credits" }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"badge",
|
|
||||||
"users.bulk-badge",
|
|
||||||
() => bulkGiveBadge({ userIds: [7], badgeCode: "ADM" }),
|
|
||||||
],
|
|
||||||
] as const)(
|
|
||||||
"restores the pre-Task12 legacy result when %s RCON throws after the database commit",
|
|
||||||
async (_kind, operation, invoke) => {
|
|
||||||
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-external-sync-failure",
|
|
||||||
completion: {
|
|
||||||
status: "partial",
|
|
||||||
external: "failed",
|
|
||||||
audit: "persisted",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(invoke()).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
given: 0,
|
|
||||||
total: 1,
|
|
||||||
failedIds: [{ userId: 7, reason: "Database error" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
operation,
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
+226
-151
@@ -1,100 +1,40 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||||
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, User, UsersCurrency } from "@/lib/db";
|
import {
|
||||||
|
Ban,
|
||||||
|
db,
|
||||||
|
Sanctions,
|
||||||
|
User,
|
||||||
|
UsersBadges,
|
||||||
|
UsersCurrency,
|
||||||
|
UsersSettings,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
async function executeLegacy(
|
|
||||||
staff: {
|
|
||||||
readonly id: number;
|
|
||||||
readonly username: string;
|
|
||||||
readonly rank: number;
|
|
||||||
},
|
|
||||||
operation:
|
|
||||||
| "users.bulk-ban"
|
|
||||||
| "users.bulk-unban"
|
|
||||||
| "users.bulk-currency"
|
|
||||||
| "users.bulk-badge"
|
|
||||||
| "user.trade-lock",
|
|
||||||
input: unknown,
|
|
||||||
) {
|
|
||||||
return peopleMutationService.execute(
|
|
||||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
|
||||||
operation,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberValue(value: unknown): number {
|
|
||||||
return Number.isSafeInteger(Number(value)) ? Number(value) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function failedIds(value: unknown): Array<{ userId: number; reason: string }> {
|
|
||||||
return Array.isArray(value)
|
|
||||||
? value.flatMap((item) =>
|
|
||||||
typeof item === "object" && item !== null
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
userId: numberValue(Reflect.get(item, "userId")),
|
|
||||||
reason: String(Reflect.get(item, "reason") ?? "Database error"),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacyBulkOutcome(
|
|
||||||
after: Readonly<Record<string, unknown>> | null,
|
|
||||||
userIds: readonly number[],
|
|
||||||
): {
|
|
||||||
readonly completed: number;
|
|
||||||
readonly total: number;
|
|
||||||
readonly failedIds: Array<{ userId: number; reason: string }>;
|
|
||||||
} {
|
|
||||||
const databaseFailures = failedIds(after?.failedIds);
|
|
||||||
const externalSyncFailures = failedIds(after?.externalSyncFailures).map(
|
|
||||||
({ userId }) => ({ userId, reason: "Database error" }),
|
|
||||||
);
|
|
||||||
const pendingFailures = [...databaseFailures, ...externalSyncFailures];
|
|
||||||
const orderedFailures = userIds.flatMap((userId) => {
|
|
||||||
const index = pendingFailures.findIndex(
|
|
||||||
(failure) => failure.userId === userId,
|
|
||||||
);
|
|
||||||
return index === -1 ? [] : pendingFailures.splice(index, 1);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
completed: Math.max(
|
|
||||||
0,
|
|
||||||
numberValue(after?.completed) - externalSyncFailures.length,
|
|
||||||
),
|
|
||||||
total: numberValue(after?.total),
|
|
||||||
failedIds: [...orderedFailures, ...pendingFailures],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkUnban({
|
export async function bulkUnban({
|
||||||
userIds,
|
userIds,
|
||||||
}: {
|
}: {
|
||||||
userIds: number[];
|
userIds: number[];
|
||||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const result = await executeLegacy(staff, "users.bulk-unban", { userIds });
|
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
|
||||||
if (!result.ok) return { ok: false, error: "Bulk unban failed" };
|
const unbanned = Number(
|
||||||
|
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||||
|
);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bulk_unban",
|
||||||
|
description: `Unbanned ${unbanned} user(s)`,
|
||||||
|
targetType: "user",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true as const,
|
||||||
data: {
|
data: { unbanned, total: userIds.length },
|
||||||
unbanned: numberValue(result.data.after?.completed),
|
|
||||||
total: numberValue(result.data.after?.total),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,16 +48,34 @@ export async function bulkBan({
|
|||||||
duration: number;
|
duration: number;
|
||||||
}): Promise<ActionResult<{ banned: number }>> {
|
}): Promise<ActionResult<{ banned: number }>> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const result = await executeLegacy(staff, "users.bulk-ban", {
|
const now = Math.floor(Date.now() / 1000);
|
||||||
userIds,
|
let banned = 0;
|
||||||
reason,
|
|
||||||
duration,
|
for (const userId of userIds) {
|
||||||
|
try {
|
||||||
|
await db.insert(Ban).values({
|
||||||
|
userId,
|
||||||
|
ip: "",
|
||||||
|
machineId: "",
|
||||||
|
userStaffId: staff.id,
|
||||||
|
timestamp: now,
|
||||||
|
banExpire: duration > 0 ? now + duration : 0,
|
||||||
|
banReason: reason,
|
||||||
|
type: "account",
|
||||||
});
|
});
|
||||||
if (!result.ok) return { ok: false, error: "Bulk ban failed" };
|
banned++;
|
||||||
return {
|
} catch {
|
||||||
ok: true,
|
// skip duplicates
|
||||||
data: { banned: numberValue(result.data.after?.completed) },
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bulk_ban",
|
||||||
|
description: `Banned ${banned} user(s)`,
|
||||||
|
targetType: "user",
|
||||||
|
});
|
||||||
|
return { ok: true as const, data: { banned } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkGiveCurrency({
|
export async function bulkGiveCurrency({
|
||||||
@@ -136,20 +94,49 @@ export async function bulkGiveCurrency({
|
|||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const result = await executeLegacy(staff, "users.bulk-currency", {
|
let given = 0;
|
||||||
userIds,
|
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||||
amount,
|
|
||||||
type,
|
for (const userId of userIds) {
|
||||||
|
try {
|
||||||
|
if (type === "credits") {
|
||||||
|
await db
|
||||||
|
.update(User)
|
||||||
|
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||||
|
.where(eq(User.id, userId));
|
||||||
|
await rcon.giveCredits(userId, amount);
|
||||||
|
} else if (type === "pixels") {
|
||||||
|
await db
|
||||||
|
.insert(UsersCurrency)
|
||||||
|
.values({ userId, type: 0, amount })
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||||
|
});
|
||||||
|
await rcon.giveDuckets(userId, amount);
|
||||||
|
} else if (type === "points") {
|
||||||
|
await db
|
||||||
|
.insert(UsersCurrency)
|
||||||
|
.values({ userId, type: 101, amount })
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||||
|
});
|
||||||
|
await rcon.givePointsGotw(userId, amount);
|
||||||
|
}
|
||||||
|
given++;
|
||||||
|
} catch {
|
||||||
|
failedIds.push({ userId, reason: "Database error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bulk_give_currency",
|
||||||
|
description: `Gave ${amount} ${type} to ${given} user(s)`,
|
||||||
|
targetType: "user",
|
||||||
});
|
});
|
||||||
if (!result.ok) return { ok: false, error: "Bulk currency failed" };
|
|
||||||
const outcome = legacyBulkOutcome(result.data.after, userIds);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true as const,
|
||||||
data: {
|
data: { given, total: userIds.length, failedIds },
|
||||||
given: outcome.completed,
|
|
||||||
total: outcome.total,
|
|
||||||
failedIds: outcome.failedIds,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,19 +154,45 @@ export async function bulkGiveBadge({
|
|||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const result = await executeLegacy(staff, "users.bulk-badge", {
|
let given = 0;
|
||||||
userIds,
|
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||||
badgeCode,
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
try {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: UsersBadges.id })
|
||||||
|
.from(UsersBadges)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(UsersBadges.userId, userId),
|
||||||
|
eq(UsersBadges.badgeCode, badgeCode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) {
|
||||||
|
const [agg] = await db
|
||||||
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||||
|
.from(UsersBadges)
|
||||||
|
.where(eq(UsersBadges.userId, userId));
|
||||||
|
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||||
|
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
|
||||||
|
await rcon.giveBadge(userId, badgeCode);
|
||||||
|
}
|
||||||
|
given++;
|
||||||
|
} catch {
|
||||||
|
failedIds.push({ userId, reason: "Database error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bulk_give_badge",
|
||||||
|
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
|
||||||
|
targetType: "user",
|
||||||
});
|
});
|
||||||
if (!result.ok) return { ok: false, error: "Bulk badge failed" };
|
|
||||||
const outcome = legacyBulkOutcome(result.data.after, userIds);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true as const,
|
||||||
data: {
|
data: { given, total: userIds.length, failedIds },
|
||||||
given: outcome.completed,
|
|
||||||
total: outcome.total,
|
|
||||||
failedIds: outcome.failedIds,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +202,7 @@ export async function bulkAdjustCurrency({
|
|||||||
type,
|
type,
|
||||||
}: {
|
}: {
|
||||||
userIds: number[];
|
userIds: number[];
|
||||||
|
/** Positive = give, negative = take. Balances clamped at 0. */
|
||||||
amount: number;
|
amount: number;
|
||||||
type: "credits" | "pixels" | "points";
|
type: "credits" | "pixels" | "points";
|
||||||
}): Promise<
|
}): Promise<
|
||||||
@@ -200,29 +214,29 @@ export async function bulkAdjustCurrency({
|
|||||||
> {
|
> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
if (!Number.isFinite(amount) || amount === 0) {
|
if (!Number.isFinite(amount) || amount === 0) {
|
||||||
return { ok: false, error: "Amount must be a non-zero number" };
|
return { ok: false as const, error: "Amount must be a non-zero number" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
const result = await executeLegacy(staff, "users.bulk-currency", {
|
const given = await bulkGiveCurrency({ userIds, amount, type });
|
||||||
userIds,
|
if (!given.ok) return given;
|
||||||
amount,
|
if (!given.data) {
|
||||||
type,
|
return { ok: false as const, error: "Currency adjustment failed" };
|
||||||
});
|
}
|
||||||
if (!result.ok) return { ok: false, error: "Currency adjustment failed" };
|
|
||||||
const outcome = legacyBulkOutcome(result.data.after, userIds);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true as const,
|
||||||
data: {
|
data: {
|
||||||
adjusted: outcome.completed,
|
adjusted: given.data.given,
|
||||||
total: outcome.total,
|
total: given.data.total,
|
||||||
failedIds: outcome.failedIds,
|
failedIds: given.data.failedIds,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const take = Math.abs(Math.trunc(amount));
|
const take = Math.abs(Math.trunc(amount));
|
||||||
let adjusted = 0;
|
let adjusted = 0;
|
||||||
const failures: Array<{ userId: number; reason: string }> = [];
|
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||||
|
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
if (type === "credits") {
|
if (type === "credits") {
|
||||||
@@ -232,13 +246,11 @@ export async function bulkAdjustCurrency({
|
|||||||
.where(eq(User.id, userId))
|
.where(eq(User.id, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
failures.push({ userId, reason: "Not found" });
|
failedIds.push({ userId, reason: "Not found" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await db
|
const next = Math.max(0, user.credits - take);
|
||||||
.update(User)
|
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
|
||||||
.set({ credits: Math.max(0, user.credits - take) })
|
|
||||||
.where(eq(User.id, userId));
|
|
||||||
} else {
|
} else {
|
||||||
const currencyType = type === "pixels" ? 0 : 101;
|
const currencyType = type === "pixels" ? 0 : 101;
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -251,17 +263,19 @@ export async function bulkAdjustCurrency({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const next = Math.max(0, (row?.amount ?? 0) - take);
|
const current = row?.amount ?? 0;
|
||||||
|
const next = Math.max(0, current - take);
|
||||||
await db
|
await db
|
||||||
.insert(UsersCurrency)
|
.insert(UsersCurrency)
|
||||||
.values({ userId, type: currencyType, amount: next })
|
.values({ userId, type: currencyType, amount: next })
|
||||||
.onDuplicateKeyUpdate({ set: { amount: next } });
|
.onDuplicateKeyUpdate({ set: { amount: next } });
|
||||||
}
|
}
|
||||||
adjusted += 1;
|
adjusted++;
|
||||||
} catch {
|
} catch {
|
||||||
failures.push({ userId, reason: "Database error" });
|
failedIds.push({ userId, reason: "Database error" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "bulk_adjust_currency",
|
action: "bulk_adjust_currency",
|
||||||
@@ -269,32 +283,93 @@ export async function bulkAdjustCurrency({
|
|||||||
targetType: "user",
|
targetType: "user",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true as const,
|
||||||
data: { adjusted, total: userIds.length, failedIds: failures },
|
data: { adjusted, total: userIds.length, failedIds },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade`
|
||||||
|
* via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online).
|
||||||
|
*/
|
||||||
export async function setTradeLock({
|
export async function setTradeLock({
|
||||||
userId,
|
userId,
|
||||||
untilUnix,
|
untilUnix,
|
||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
/** Unix seconds; 0 clears the lock. */
|
||||||
untilUnix: number;
|
untilUnix: number;
|
||||||
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const until = Math.max(0, Math.trunc(untilUnix));
|
const until = Math.max(0, Math.trunc(untilUnix));
|
||||||
const result = await executeLegacy(staff, "user.trade-lock", {
|
const locked = until > 0;
|
||||||
userId,
|
|
||||||
untilUnix: until,
|
const [user] = await db
|
||||||
});
|
.select({
|
||||||
if (!result.ok) {
|
id: User.id,
|
||||||
return {
|
username: User.username,
|
||||||
ok: false,
|
online: User.online,
|
||||||
error:
|
})
|
||||||
result.error.code === "NOT_FOUND"
|
.from(User)
|
||||||
? "User not found"
|
.where(eq(User.id, userId))
|
||||||
: "Trade lock update failed",
|
.limit(1);
|
||||||
};
|
if (!user) {
|
||||||
|
return { ok: false as const, error: "User not found" };
|
||||||
}
|
}
|
||||||
return { ok: true, data: { userId, untilUnix: until } };
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: Sanctions.id })
|
||||||
|
.from(Sanctions)
|
||||||
|
.where(eq(Sanctions.habboId, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) {
|
||||||
|
await tx
|
||||||
|
.update(Sanctions)
|
||||||
|
.set({
|
||||||
|
tradeLockedUntil: until,
|
||||||
|
...(locked ? { reason: "Trade lock (CMS)" } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(Sanctions.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(Sanctions).values({
|
||||||
|
habboId: userId,
|
||||||
|
tradeLockedUntil: until,
|
||||||
|
reason: locked ? "Trade lock (CMS)" : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(UsersSettings)
|
||||||
|
.set({
|
||||||
|
canTrade: locked ? "0" : "1",
|
||||||
|
...(locked
|
||||||
|
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.where(eq(UsersSettings.userId, userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
await rcon.setTradeLock(userId, locked);
|
||||||
|
await rcon.alertUser(
|
||||||
|
userId,
|
||||||
|
locked
|
||||||
|
? "Trading has been disabled by staff."
|
||||||
|
: "Trading has been re-enabled by staff.",
|
||||||
|
);
|
||||||
|
if (user.online === "1") {
|
||||||
|
await rcon.disconnectUser(userId, user.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: locked ? "trade_lock" : "trade_unlock",
|
||||||
|
description: locked
|
||||||
|
? `Trade-locked ${user.username} (#${userId}) until ${until}`
|
||||||
|
: `Cleared trade lock for ${user.username} (#${userId})`,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true as const, data: { userId, untilUnix: until } };
|
||||||
}
|
}
|
||||||
+220
-40
@@ -1,12 +1,50 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
function revalidateBuilderClub(): void {
|
const BC_PAGE_FIELDS = [
|
||||||
revalidatePath("/ase/economy/catalog");
|
"caption",
|
||||||
|
"parentId",
|
||||||
|
"pageLayout",
|
||||||
|
"enabled",
|
||||||
|
"visible",
|
||||||
|
"orderNum",
|
||||||
|
"iconImage",
|
||||||
|
"iconColor",
|
||||||
|
"pageHeadline",
|
||||||
|
"pageTeaser",
|
||||||
|
"pageSpecial",
|
||||||
|
"pageText1",
|
||||||
|
"pageText2",
|
||||||
|
"pageTextDetails",
|
||||||
|
"pageTextTeaser",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BC_ITEM_FIELDS = [
|
||||||
|
"itemIds",
|
||||||
|
"catalogName",
|
||||||
|
"orderNumber",
|
||||||
|
"extradata",
|
||||||
|
"pageId",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickAllowed(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
allowed: readonly string[],
|
||||||
|
) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||||
|
out[key] = fields[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBcPage({
|
export async function updateBcPage({
|
||||||
@@ -14,22 +52,38 @@ export async function updateBcPage({
|
|||||||
...fields
|
...fields
|
||||||
}: { id: number } & Record<string, unknown>) {
|
}: { id: number } & Record<string, unknown>) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "bc-page.change", {
|
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
||||||
action: "update",
|
if (Object.keys(data).length === 0) {
|
||||||
id,
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
...fields,
|
}
|
||||||
|
await db
|
||||||
|
.update(CatalogPagesBc)
|
||||||
|
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||||
|
.where(eq(CatalogPagesBc.id, id));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_page_update",
|
||||||
|
description: `Updated BC catalog page #${id}`,
|
||||||
|
targetType: "catalog_page_bc",
|
||||||
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidateBuilderClub();
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBcItem({ id }: { id: number }) {
|
export async function deleteBcItem({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "bc-item.change", {
|
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||||
action: "delete",
|
await rcon.updateCatalog();
|
||||||
id,
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_item_delete",
|
||||||
|
description: `Deleted BC catalog item #${id}`,
|
||||||
|
targetType: "catalog_item_bc",
|
||||||
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidateBuilderClub();
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,12 +98,23 @@ export async function updateBcItem({
|
|||||||
extradata?: string;
|
extradata?: string;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "bc-item.change", {
|
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
||||||
action: "update",
|
if (Object.keys(safe).length === 0) {
|
||||||
id,
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
...data,
|
}
|
||||||
|
await db
|
||||||
|
.update(CatalogItemsBc)
|
||||||
|
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||||
|
.where(eq(CatalogItemsBc.id, id));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_item_update",
|
||||||
|
description: `Updated BC catalog item #${id}`,
|
||||||
|
targetType: "catalog_item_bc",
|
||||||
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidateBuilderClub();
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +129,18 @@ export async function createBcItem({
|
|||||||
extradata: string;
|
extradata: string;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const snapshot = await executeLegacyEconomyMutation(staff, "bc-item.change", {
|
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
|
||||||
action: "create",
|
const createdId = Number(result.insertId);
|
||||||
pageId,
|
await rcon.updateCatalog();
|
||||||
...data,
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_item_create",
|
||||||
|
description: `Created BC catalog item #${createdId}`,
|
||||||
|
targetType: "catalog_item_bc",
|
||||||
|
targetId: createdId,
|
||||||
});
|
});
|
||||||
revalidateBuilderClub();
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
|
return { ok: true as const, data: { id: createdId } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleBcPage({
|
export async function toggleBcPage({
|
||||||
@@ -80,12 +150,22 @@ export async function toggleBcPage({
|
|||||||
id: number;
|
id: number;
|
||||||
field: "enabled" | "visible";
|
field: "enabled" | "visible";
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "bc-page.change", {
|
const [page] = await db
|
||||||
action: field === "enabled" ? "toggle-enabled" : "toggle-visible",
|
.select({
|
||||||
id,
|
enabled: CatalogPagesBc.enabled,
|
||||||
});
|
visible: CatalogPagesBc.visible,
|
||||||
revalidateBuilderClub();
|
})
|
||||||
|
.from(CatalogPagesBc)
|
||||||
|
.where(eq(CatalogPagesBc.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!page) return { ok: false as const, error: "Page not found" };
|
||||||
|
await db
|
||||||
|
.update(CatalogPagesBc)
|
||||||
|
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||||
|
.where(eq(CatalogPagesBc.id, id));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +180,52 @@ export async function createBcPage(input: {
|
|||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const snapshot = await executeLegacyEconomyMutation(staff, "bc-page.change", {
|
const [result] = await db.insert(CatalogPagesBc).values({
|
||||||
action: "create",
|
caption: input.caption,
|
||||||
...input,
|
parentId: input.parentId,
|
||||||
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
|
iconColor: input.iconColor ?? 0,
|
||||||
|
iconImage: input.iconImage ?? 0,
|
||||||
|
orderNum: input.orderNum ?? 0,
|
||||||
|
visible: input.visible ?? "1",
|
||||||
|
enabled: input.enabled ?? "1",
|
||||||
|
pageHeadline: "",
|
||||||
|
pageTeaser: "",
|
||||||
});
|
});
|
||||||
revalidateBuilderClub();
|
const createdId = Number(result.insertId);
|
||||||
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_page_create",
|
||||||
|
description: `Created BC catalog page "${input.caption}"`,
|
||||||
|
targetType: "catalog_page_bc",
|
||||||
|
targetId: createdId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const, data: { id: createdId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
|
||||||
|
if (newParentId > 0) {
|
||||||
|
let currentId = newParentId;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (currentId === pageId) {
|
||||||
|
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||||
|
}
|
||||||
|
const [parent] = await db
|
||||||
|
.select({ parentId: CatalogPagesBc.parentId })
|
||||||
|
.from(CatalogPagesBc)
|
||||||
|
.where(eq(CatalogPagesBc.id, currentId))
|
||||||
|
.limit(1);
|
||||||
|
if (!parent || parent.parentId <= 0) break;
|
||||||
|
currentId = parent.parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(CatalogPagesBc)
|
||||||
|
.set({ parentId: newParentId })
|
||||||
|
.where(eq(CatalogPagesBc.id, pageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderBcTreePage(input: {
|
export async function reorderBcTreePage(input: {
|
||||||
@@ -113,9 +233,24 @@ export async function reorderBcTreePage(input: {
|
|||||||
newParentId?: number;
|
newParentId?: number;
|
||||||
newOrderNum: number;
|
newOrderNum: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "bc-page.reorder", input);
|
if (input.newParentId !== undefined) {
|
||||||
revalidateBuilderClub();
|
try {
|
||||||
|
await moveBcPage(input.pageId, input.newParentId);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: err instanceof Error ? err.message : "Invalid move",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(CatalogPagesBc)
|
||||||
|
.set({ orderNum: input.newOrderNum })
|
||||||
|
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,8 +258,53 @@ export async function deleteBcTreePage(input: {
|
|||||||
pageId: number;
|
pageId: number;
|
||||||
mode: "reparent" | "cascade";
|
mode: "reparent" | "cascade";
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "bc-page.delete-tree", input);
|
const [page] = await db
|
||||||
revalidateBuilderClub();
|
.select({ parentId: CatalogPagesBc.parentId })
|
||||||
|
.from(CatalogPagesBc)
|
||||||
|
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||||
|
.limit(1);
|
||||||
|
if (!page) return { ok: false as const, error: "Page not found" };
|
||||||
|
|
||||||
|
if (input.mode === "reparent") {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(CatalogPagesBc)
|
||||||
|
.set({ parentId: page.parentId })
|
||||||
|
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
||||||
|
await tx
|
||||||
|
.delete(CatalogItemsBc)
|
||||||
|
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
||||||
|
await tx
|
||||||
|
.delete(CatalogPagesBc)
|
||||||
|
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const toDelete: number[] = [input.pageId];
|
||||||
|
const queue: number[] = [input.pageId];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const children = await db
|
||||||
|
.select({ id: CatalogPagesBc.id })
|
||||||
|
.from(CatalogPagesBc)
|
||||||
|
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||||
|
queue.length = 0;
|
||||||
|
for (const child of children) {
|
||||||
|
toDelete.push(child.id);
|
||||||
|
queue.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(CatalogItemsBc)
|
||||||
|
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||||
|
await tx
|
||||||
|
.delete(CatalogPagesBc)
|
||||||
|
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
+388
-62
@@ -1,15 +1,108 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
|
||||||
type EconomyMutationSnapshot,
|
|
||||||
executeLegacyEconomyMutation,
|
|
||||||
} from "@/features/housekeeping/domains/economy/services/mutations";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { translateItemsSchema } from "@/lib/validators/catalog";
|
||||||
|
|
||||||
function revalidateCatalog(): void {
|
const CATALOG_ITEM_FIELDS = [
|
||||||
revalidatePath("/ase/economy/catalog");
|
"pageId",
|
||||||
|
"itemIds",
|
||||||
|
"catalogName",
|
||||||
|
"costCredits",
|
||||||
|
"costPoints",
|
||||||
|
"pointsType",
|
||||||
|
"amount",
|
||||||
|
"orderNumber",
|
||||||
|
"offerId",
|
||||||
|
"songId",
|
||||||
|
"limitedSells",
|
||||||
|
"limitedStack",
|
||||||
|
"extradata",
|
||||||
|
"haveOffer",
|
||||||
|
"clubOnly",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ITEMS_BASE_FIELDS = [
|
||||||
|
"publicName",
|
||||||
|
"itemName",
|
||||||
|
"type",
|
||||||
|
"width",
|
||||||
|
"length",
|
||||||
|
"stackHeight",
|
||||||
|
"allowStack",
|
||||||
|
"allowSit",
|
||||||
|
"allowLay",
|
||||||
|
"allowWalk",
|
||||||
|
"allowGift",
|
||||||
|
"allowTrade",
|
||||||
|
"allowRecycle",
|
||||||
|
"allowMarketplaceSell",
|
||||||
|
"allowInventoryStack",
|
||||||
|
"interactionType",
|
||||||
|
"interactionModesCount",
|
||||||
|
"vendingIds",
|
||||||
|
"customparams",
|
||||||
|
"effectIdMale",
|
||||||
|
"effectIdFemale",
|
||||||
|
"clothingOnWalk",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickAllowed(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
allowed: readonly string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||||
|
out[key] = fields[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
||||||
|
async function insertCatalogItemRow(data: {
|
||||||
|
pageId: number;
|
||||||
|
itemIds: string;
|
||||||
|
catalogName: string;
|
||||||
|
costCredits: number;
|
||||||
|
costPoints: number;
|
||||||
|
pointsType: number;
|
||||||
|
amount: number;
|
||||||
|
orderNumber: number;
|
||||||
|
offerId: number;
|
||||||
|
songId: number;
|
||||||
|
limitedSells: number;
|
||||||
|
limitedStack: number;
|
||||||
|
extradata: string;
|
||||||
|
haveOffer: string;
|
||||||
|
clubOnly: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
const pageIdStr = String(data.pageId);
|
||||||
|
return allocateCatalogItemId(async (nextId) => {
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO catalog_items (
|
||||||
|
id, page_id, item_ids, catalog_name,
|
||||||
|
cost_credits, cost_points, points_type, amount,
|
||||||
|
order_number, offer_id, song_id,
|
||||||
|
limited_sells, limited_stack, extradata, have_offer, club_only
|
||||||
|
) VALUES (
|
||||||
|
${nextId}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName},
|
||||||
|
${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount},
|
||||||
|
${data.orderNumber}, ${data.offerId}, ${data.songId},
|
||||||
|
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
|
||||||
|
${data.haveOffer}, ${data.clubOnly}
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
return nextId;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCatalogItem(data: {
|
export async function createCatalogItem(data: {
|
||||||
@@ -30,16 +123,39 @@ export async function createCatalogItem(data: {
|
|||||||
clubOnly: "0" | "1";
|
clubOnly: "0" | "1";
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const snapshot = await executeLegacyEconomyMutation(
|
let catalogName = data.catalogName.trim();
|
||||||
staff,
|
if (!catalogName) {
|
||||||
"catalog-item.change",
|
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||||
{ action: "create", ...data },
|
if (firstId > 0) {
|
||||||
);
|
const [base] = await db
|
||||||
revalidateCatalog();
|
.select({
|
||||||
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
|
publicName: ItemsBase.publicName,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(eq(ItemsBase.id, firstId))
|
||||||
|
.limit(1);
|
||||||
|
catalogName = base?.publicName || base?.itemName || String(firstId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = await insertCatalogItemRow({ ...data, catalogName });
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_item_create",
|
||||||
|
description: `Created catalog item #${id}`,
|
||||||
|
targetType: "catalog_item",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: { id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkCreateCatalogItems(input: {
|
/** Bulk create with one RCON refresh at the end. */
|
||||||
|
export async function bulkCreateCatalogItems({
|
||||||
|
pageId,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
pageId: number;
|
pageId: number;
|
||||||
rows: Array<{
|
rows: Array<{
|
||||||
baseId: number;
|
baseId: number;
|
||||||
@@ -47,92 +163,302 @@ export async function bulkCreateCatalogItems(input: {
|
|||||||
points?: number;
|
points?: number;
|
||||||
pointsType?: number;
|
pointsType?: number;
|
||||||
}>;
|
}>;
|
||||||
}): Promise<
|
}) {
|
||||||
| { ok: true; data: { created: number; failed: number } }
|
|
||||||
| { ok: false; error: string }
|
|
||||||
> {
|
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
let snapshot: EconomyMutationSnapshot;
|
if (rows.length === 0) {
|
||||||
try {
|
return { ok: true as const, data: { created: 0, failed: 0 } };
|
||||||
snapshot = await executeLegacyEconomyMutation(
|
|
||||||
staff,
|
|
||||||
"catalog-item.bulk-create",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return { ok: false, error: "Bulk import failed" };
|
|
||||||
}
|
}
|
||||||
revalidateCatalog();
|
if (rows.length > 500) {
|
||||||
return {
|
return { ok: false as const, error: "Max 500 items per bulk import" };
|
||||||
ok: true as const,
|
}
|
||||||
data: {
|
|
||||||
created: Number(snapshot.output?.created ?? 0),
|
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||||
failed: Number(snapshot.output?.failed ?? 0),
|
const bases = await db
|
||||||
},
|
.select({
|
||||||
};
|
id: ItemsBase.id,
|
||||||
|
publicName: ItemsBase.publicName,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(inArray(ItemsBase.id, baseIds));
|
||||||
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const base = baseMap.get(row.baseId);
|
||||||
|
if (!base) {
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await insertCatalogItemRow({
|
||||||
|
pageId,
|
||||||
|
itemIds: String(row.baseId),
|
||||||
|
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||||
|
costCredits: row.credits ?? 0,
|
||||||
|
costPoints: row.points ?? 0,
|
||||||
|
pointsType: row.pointsType ?? 0,
|
||||||
|
amount: 1,
|
||||||
|
limitedSells: 0,
|
||||||
|
limitedStack: 0,
|
||||||
|
orderNumber: 1,
|
||||||
|
offerId: -1,
|
||||||
|
songId: 0,
|
||||||
|
haveOffer: "1",
|
||||||
|
clubOnly: "0",
|
||||||
|
extradata: "",
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created > 0) {
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_items_bulk_create",
|
||||||
|
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: pageId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true as const, data: { created, failed } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-item.change", {
|
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||||
action: "delete",
|
await rcon.updateCatalog();
|
||||||
ids,
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_items_delete",
|
||||||
|
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||||
|
targetType: "catalog_item",
|
||||||
});
|
});
|
||||||
revalidateCatalog();
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveCatalogItems(input: {
|
export async function moveCatalogItems({
|
||||||
|
ids,
|
||||||
|
targetPageId,
|
||||||
|
}: {
|
||||||
ids: number[];
|
ids: number[];
|
||||||
targetPageId: number;
|
targetPageId: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-item.move", input);
|
if (ids.length === 0) {
|
||||||
revalidateCatalog();
|
return { ok: true as const, data: {} };
|
||||||
|
}
|
||||||
|
const pageIdStr = String(targetPageId);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE catalog_items
|
||||||
|
SET page_id = ${pageIdStr}
|
||||||
|
WHERE id IN (${sql.join(
|
||||||
|
ids.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})
|
||||||
|
`);
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderCatalogItems(input: {
|
export async function reorderCatalogItems({
|
||||||
|
orders,
|
||||||
|
}: {
|
||||||
orders: Array<{ id: number; orderNumber: number }>;
|
orders: Array<{ id: number; orderNumber: number }>;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-item.reorder", input);
|
for (const { id, orderNumber } of orders) {
|
||||||
revalidateCatalog();
|
await db
|
||||||
|
.update(CatalogItems)
|
||||||
|
.set({ orderNumber })
|
||||||
|
.where(eq(CatalogItems.id, id));
|
||||||
|
}
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCatalogItem(input: {
|
export async function updateCatalogItem({
|
||||||
|
id,
|
||||||
|
catalogFields,
|
||||||
|
baseItem,
|
||||||
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
catalogFields: Record<string, unknown>;
|
catalogFields: Record<string, unknown>;
|
||||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-item.change", {
|
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
||||||
action: "update",
|
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
||||||
...input,
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// page_id is often VARCHAR — update it via raw SQL when present.
|
||||||
|
const pageIdRaw = safeCatalog.pageId;
|
||||||
|
if (pageIdRaw !== undefined) {
|
||||||
|
const pageIdStr = String(pageIdRaw);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||||
|
`);
|
||||||
|
delete safeCatalog.pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(safeCatalog).length > 0) {
|
||||||
|
await db
|
||||||
|
.update(CatalogItems)
|
||||||
|
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||||
|
.where(eq(CatalogItems.id, id));
|
||||||
|
}
|
||||||
|
if (baseItem) {
|
||||||
|
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||||
|
if (Object.keys(safeBase).length > 0) {
|
||||||
|
await db
|
||||||
|
.update(ItemsBase)
|
||||||
|
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||||
|
.where(eq(ItemsBase.id, baseItem.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_item_update",
|
||||||
|
description: `Updated catalog item #${id}`,
|
||||||
|
targetType: "catalog_item",
|
||||||
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidateCatalog();
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function translateCatalogItems(input: {
|
export async function translateCatalogItems(input: {
|
||||||
|
/** `id` is items_base.id (not catalog_items.id) */
|
||||||
items: Array<{ id: number; publicName: string; description?: string }>;
|
items: Array<{ id: number; publicName: string; description?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const snapshot = await executeLegacyEconomyMutation(
|
const parsed = translateItemsSchema.safeParse(input);
|
||||||
staff,
|
if (!parsed.success) {
|
||||||
"catalog-item.translate",
|
return {
|
||||||
input,
|
ok: false as const,
|
||||||
|
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { items } = parsed.data;
|
||||||
|
const { invalidateFurniDataCache } = await import(
|
||||||
|
"@/lib/services/catalog-items-loader"
|
||||||
);
|
);
|
||||||
revalidateCatalog();
|
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
||||||
|
|
||||||
|
let namesUpdated = 0;
|
||||||
|
let descriptionsUpdated = 0;
|
||||||
|
const furniPatches: Array<{
|
||||||
|
classname: string;
|
||||||
|
itemType: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
spriteId?: number;
|
||||||
|
createIfMissing?: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const [base] = await db
|
||||||
|
.select({
|
||||||
|
id: ItemsBase.id,
|
||||||
|
publicName: ItemsBase.publicName,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
type: ItemsBase.type,
|
||||||
|
spriteId: ItemsBase.spriteId,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(eq(ItemsBase.id, item.id))
|
||||||
|
.limit(1);
|
||||||
|
if (!base) continue;
|
||||||
|
|
||||||
|
const nextName = item.publicName?.trim() ?? "";
|
||||||
|
const nextDesc = item.description ?? "";
|
||||||
|
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
||||||
|
|
||||||
|
if (nameChanged) {
|
||||||
|
await db
|
||||||
|
.update(ItemsBase)
|
||||||
|
.set({ publicName: nextName })
|
||||||
|
.where(eq(ItemsBase.id, base.id));
|
||||||
|
const idStr = String(base.id);
|
||||||
|
const related = await db
|
||||||
|
.select({
|
||||||
|
id: CatalogItems.id,
|
||||||
|
catalogName: CatalogItems.catalogName,
|
||||||
|
})
|
||||||
|
.from(CatalogItems)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(CatalogItems.itemIds, idStr),
|
||||||
|
like(CatalogItems.itemIds, `${idStr};%`),
|
||||||
|
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||||
|
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const row of related) {
|
||||||
|
if (row.catalogName !== nextName) {
|
||||||
|
await db
|
||||||
|
.update(CatalogItems)
|
||||||
|
.set({ catalogName: nextName })
|
||||||
|
.where(eq(CatalogItems.id, row.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namesUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextDesc !== "" || nameChanged) {
|
||||||
|
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
||||||
|
furniPatches.push({
|
||||||
|
classname: base.itemName,
|
||||||
|
itemType: base.type || "s",
|
||||||
|
name: nextName || base.publicName || base.itemName,
|
||||||
|
description: nextDesc,
|
||||||
|
spriteId: base.spriteId,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const furniResult =
|
||||||
|
furniPatches.length > 0
|
||||||
|
? await patchFurniEntryNames(furniPatches)
|
||||||
|
: { updated: 0, inserted: 0 };
|
||||||
|
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
||||||
|
invalidateFurniDataCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logAudit({
|
||||||
|
userId: staff.id,
|
||||||
|
action: "items_base_translate",
|
||||||
|
target: "ItemsBase",
|
||||||
|
after: {
|
||||||
|
namesUpdated,
|
||||||
|
descriptionsUpdated,
|
||||||
|
furniDataUpdated: furniResult.updated > 0,
|
||||||
|
furniDataInserted: furniResult.inserted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return {
|
return {
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
data: {
|
data: {
|
||||||
namesUpdated: Number(snapshot.output?.namesUpdated ?? 0),
|
namesUpdated,
|
||||||
descriptionsUpdated: Number(snapshot.output?.descriptionsUpdated ?? 0),
|
descriptionsUpdated,
|
||||||
furniDataUpdated: Number(snapshot.output?.furniDataUpdated ?? 0),
|
furniDataUpdated: furniResult.updated,
|
||||||
furniDataInserted: Number(snapshot.output?.furniDataInserted ?? 0),
|
furniDataInserted: furniResult.inserted,
|
||||||
updated: input.items.length,
|
updated: items.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+132
-29
@@ -1,32 +1,88 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
|
import { deletePage, movePage } from "@/lib/services/catalog-tree";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
const CATALOG_PAGE_FIELDS = [
|
||||||
|
"caption",
|
||||||
|
"parentId",
|
||||||
|
"pageLayout",
|
||||||
|
"enabled",
|
||||||
|
"visible",
|
||||||
|
"minRank",
|
||||||
|
"clubOnly",
|
||||||
|
"vipOnly",
|
||||||
|
"orderNum",
|
||||||
|
"iconImage",
|
||||||
|
"iconColor",
|
||||||
|
"pageHeadline",
|
||||||
|
"pageTeaser",
|
||||||
|
"pageSpecial",
|
||||||
|
"pageText1",
|
||||||
|
"pageText2",
|
||||||
|
"pageTextDetails",
|
||||||
|
"pageTextTeaser",
|
||||||
|
"includes",
|
||||||
|
"captionSave",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickPageFields(fields: Record<string, unknown>) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of CATALOG_PAGE_FIELDS) {
|
||||||
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||||
|
out[key] = fields[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCatalogPage({
|
export async function updateCatalogPage({
|
||||||
id,
|
id,
|
||||||
...fields
|
...fields
|
||||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
|
const data = pickPageFields(fields);
|
||||||
action: "update",
|
if (Object.keys(data).length === 0) {
|
||||||
id,
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
...fields,
|
}
|
||||||
|
if (typeof data.caption === "string" && !data.captionSave) {
|
||||||
|
data.captionSave = data.caption.slice(0, 25);
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(CatalogPages)
|
||||||
|
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||||
|
.where(eq(CatalogPages.id, id));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_page_update",
|
||||||
|
description: `Updated catalog page #${id}`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/economy/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
|
await deletePage(id, "reparent");
|
||||||
action: "delete",
|
await rcon.updateCatalog();
|
||||||
id,
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_page_delete",
|
||||||
|
description: `Deleted catalog page #${id}`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidatePath("/ase/economy/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +93,24 @@ export async function toggleCatalogPage({
|
|||||||
id: number;
|
id: number;
|
||||||
action: "toggleEnabled" | "toggleVisible";
|
action: "toggleEnabled" | "toggleVisible";
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
|
const [page] = await db
|
||||||
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
|
.select({
|
||||||
id,
|
enabled: CatalogPages.enabled,
|
||||||
});
|
visible: CatalogPages.visible,
|
||||||
revalidatePath("/ase/economy/catalog");
|
})
|
||||||
|
.from(CatalogPages)
|
||||||
|
.where(eq(CatalogPages.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||||
|
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||||
|
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||||
|
await db
|
||||||
|
.update(CatalogPages)
|
||||||
|
.set({ [field]: current === "1" ? "0" : "1" })
|
||||||
|
.where(eq(CatalogPages.id, id));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +126,33 @@ export async function createCatalogPage(input: {
|
|||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
}): Promise<ActionResult<{ id: number }>> {
|
}): Promise<ActionResult<{ id: number }>> {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const snapshot = await executeLegacyEconomyMutation(
|
const [result] = await db.insert(CatalogPages).values({
|
||||||
staff,
|
caption: input.caption,
|
||||||
"catalog-page.change",
|
parentId: input.parentId,
|
||||||
{ action: "create", ...input },
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
);
|
captionSave: input.caption.slice(0, 25),
|
||||||
const createdId = Number(snapshot.output?.id);
|
iconColor: input.iconColor ?? 0,
|
||||||
revalidatePath("/ase/economy/catalog");
|
iconImage: input.iconImage ?? 0,
|
||||||
|
minRank: input.minRank ?? 1,
|
||||||
|
orderNum: input.orderNum ?? 0,
|
||||||
|
visible: input.visible ?? "1",
|
||||||
|
enabled: input.enabled ?? "1",
|
||||||
|
clubOnly: "0",
|
||||||
|
vipOnly: "0",
|
||||||
|
pageHeadline: "",
|
||||||
|
pageTeaser: "",
|
||||||
|
includes: "",
|
||||||
|
});
|
||||||
|
const createdId = Number(result.insertId);
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_page_create",
|
||||||
|
description: `Created catalog page "${input.caption}"`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: createdId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: { id: createdId } };
|
return { ok: true as const, data: { id: createdId } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +161,23 @@ export async function reorderTreePage(input: {
|
|||||||
newParentId?: number;
|
newParentId?: number;
|
||||||
newOrderNum: number;
|
newOrderNum: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
|
if (input.newParentId !== undefined) {
|
||||||
revalidatePath("/ase/economy/catalog");
|
try {
|
||||||
|
await movePage(input.pageId, input.newParentId);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: err instanceof Error ? err.message : "Invalid move",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(CatalogPages)
|
||||||
|
.set({ orderNum: input.newOrderNum })
|
||||||
|
.where(eq(CatalogPages.id, input.pageId));
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +185,9 @@ export async function deleteTreePage(input: {
|
|||||||
pageId: number;
|
pageId: number;
|
||||||
mode: "reparent" | "cascade";
|
mode: "reparent" | "cascade";
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
|
await deletePage(input.pageId, input.mode);
|
||||||
revalidatePath("/ase/economy/catalog");
|
await rcon.updateCatalog();
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
|
const PATH = "/admin/commandocentrum";
|
||||||
|
|
||||||
|
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||||
|
|
||||||
|
async function requireRconOk(ok: boolean): Promise<void> {
|
||||||
|
if (!ok) throw new ActionError(RCON_FAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||||
|
export const updateCatalog = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
|
async () => {
|
||||||
|
await requireRconOk(await rcon.updateCatalog());
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||||
|
export const updateWordFilter = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
|
async () => {
|
||||||
|
await requireRconOk(await rcon.updateWordFilter());
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||||
|
export const updateNavigator = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
|
async () => {
|
||||||
|
await requireRconOk(await rcon.send("updatenavigator", null));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const hotelAlertSchema = z.object({
|
||||||
|
message: z.string().trim().min(1).max(512),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||||
|
export const hotelAlert = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const message = ctx.data.message.normalize("NFC");
|
||||||
|
await requireRconOk(await rcon.send("hotelalert", { message }));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const disconnectSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
username: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||||
|
export const disconnectUser = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const username = ctx.data.username.normalize("NFC");
|
||||||
|
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertUserSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
message: z.string().trim().min(1).max(512),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Send an alert to a specific user (rcon: alertuser). */
|
||||||
|
export const alertUser = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const message = ctx.data.message.normalize("NFC");
|
||||||
|
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const forwardUserSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
roomId: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||||
|
export const forwardUser = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await requireRconOk(
|
||||||
|
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
|
||||||
|
);
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const giveCreditsSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
credits: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Give credits to a user (rcon: givecredits). */
|
||||||
|
export const giveCredits = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await requireRconOk(
|
||||||
|
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
|
||||||
|
);
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const giveAmountSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
amount: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||||
|
export const giveDuckets = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await requireRconOk(
|
||||||
|
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
|
||||||
|
);
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||||
|
export const giveDiamonds = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await requireRconOk(
|
||||||
|
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
|
||||||
|
);
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const giveBadgeSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
badge: z.string().trim().min(1).max(32),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Give a badge to a user (rcon: givebadge). */
|
||||||
|
export const giveBadge = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const badge = ctx.data.badge.normalize("NFC");
|
||||||
|
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMottoSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
motto: z.string().trim().min(1).max(127),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Set a user's motto (rcon: setmotto). */
|
||||||
|
export const setMotto = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const motto = ctx.data.motto.normalize("NFC");
|
||||||
|
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const setRankSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
rank: z.coerce.number().int().min(1).max(9999),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Set a user's rank (rcon: setrank). */
|
||||||
|
export const setRank = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const staffRank = Number(ctx.session.user.rank);
|
||||||
|
const isSuper = ctx.permissions.isSuperAdmin;
|
||||||
|
const [target] = await db
|
||||||
|
.select({ rank: User.rank })
|
||||||
|
.from(User)
|
||||||
|
.where(eq(User.id, ctx.data.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (!target) throw new ActionError("User not found");
|
||||||
|
|
||||||
|
let rankExists: { id: number }[] = [];
|
||||||
|
try {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
|
||||||
|
);
|
||||||
|
rankExists = rows as unknown as { id: number }[];
|
||||||
|
} catch {
|
||||||
|
rankExists = [];
|
||||||
|
}
|
||||||
|
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
|
||||||
|
|
||||||
|
if (!isSuper) {
|
||||||
|
if (target.rank >= staffRank) {
|
||||||
|
throw new ActionError(
|
||||||
|
"Cannot change rank of a user at or above your rank",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.data.rank >= staffRank) {
|
||||||
|
throw new ActionError("Cannot set a rank equal to or above your own");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||||
|
await db
|
||||||
|
.update(User)
|
||||||
|
.set({ rank: ctx.data.rank })
|
||||||
|
.where(eq(User.id, ctx.data.userId));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeCommandSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
command: z.string().trim().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Execute a command as a user (rcon: executecommand). */
|
||||||
|
export const executeCommand = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const command = ctx.data.command.normalize("NFC");
|
||||||
|
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendGiftSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
itemId: z.coerce.number().int().positive(),
|
||||||
|
message: z.string().trim().max(255).optional().default("Here is a gift."),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Send a gift to a user (rcon: sendgift). */
|
||||||
|
export const sendGift = adminAction(
|
||||||
|
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
|
||||||
|
await requireRconOk(
|
||||||
|
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
|
||||||
|
);
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
|
||||||
import { requirePermission, requireStaff } from "@/lib/admin/guard";
|
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
|
||||||
import { createAd } from "./admin-ads";
|
|
||||||
import { createArticle } from "./admin-articles";
|
|
||||||
import { uploadMedia } from "./admin-media";
|
|
||||||
import { deleteFavicon, saveFavicon } from "./save-favicon";
|
|
||||||
import { saveLogo } from "./save-logo";
|
|
||||||
|
|
||||||
const { execute, auditedBrandExecute } = vi.hoisted(() => ({
|
|
||||||
execute: vi.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
|
||||||
correlationId: "legacy",
|
|
||||||
})),
|
|
||||||
auditedBrandExecute: vi.fn(async () => ({
|
|
||||||
before: null,
|
|
||||||
after: { value: "/api/media/x" },
|
|
||||||
output: { url: "/api/media/x" },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
const { executeLegacyBrandAssetMutation } = vi.hoisted(() => ({
|
|
||||||
executeLegacyBrandAssetMutation: vi.fn(async () => ({
|
|
||||||
before: null,
|
|
||||||
after: { value: "/api/media/x" },
|
|
||||||
output: { url: "/api/media/x" },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
|
||||||
contentMutationService: { execute },
|
|
||||||
createContentMutationInvocation: (actor, correlationId) => ({
|
|
||||||
expectedActorId: actor.id,
|
|
||||||
correlationId,
|
|
||||||
legacy: true,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock(
|
|
||||||
"@/features/housekeeping/domains/content/services/mutations-production",
|
|
||||||
() => ({
|
|
||||||
contentProductionMutationAdapter: { execute: auditedBrandExecute },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
|
||||||
getHousekeepingCapabilityContext: vi.fn(async () => ({
|
|
||||||
actor: { id: 42, username: "operator", rank: 7 },
|
|
||||||
isSuperAdmin: false,
|
|
||||||
has: () => false,
|
|
||||||
hasAny: () => false,
|
|
||||||
hasAll: () => false,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
vi.mock(
|
|
||||||
"@/features/housekeeping/domains/content/services/mutation-runtime-external",
|
|
||||||
() => ({
|
|
||||||
executeLegacyBrandAssetMutation,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({
|
|
||||||
requirePermission: vi.fn(),
|
|
||||||
requireStaff: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
|
||||||
adminAction: (_options: unknown, handler: unknown) => handler,
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({
|
|
||||||
ActionError: class ActionError extends Error {},
|
|
||||||
actionOk: (data: unknown = {}) => ({ ok: true, data }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/logger", () => ({
|
|
||||||
logger: { error: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
PERMS: {
|
|
||||||
NEWS_EDIT: "news.edit",
|
|
||||||
PAGES_EDIT: "pages.edit",
|
|
||||||
SETTINGS_EDIT: "settings.edit",
|
|
||||||
SETTINGS_VIEW: "settings.view",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
select: vi.fn(() => ({
|
|
||||||
from: vi.fn(() => ({
|
|
||||||
where: vi.fn(() => ({ limit: vi.fn(async () => []) })),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
insert: vi.fn(() => ({
|
|
||||||
values: vi.fn(async () => [{ insertId: 1 }]),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
WebsiteArticles: { id: "id", slug: "slug" },
|
|
||||||
WebsiteAds: { id: "id" },
|
|
||||||
WebsiteSetting: { key: "key" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({
|
|
||||||
logStaffActivity: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/site-settings", () => ({
|
|
||||||
siteSettings: { get: vi.fn(), reload: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/media-storage", () => ({
|
|
||||||
MEDIA_ROOT: "C:\\media",
|
|
||||||
resolveMediaPath: vi.fn((name: string) => `C:\\media\\${name}`),
|
|
||||||
}));
|
|
||||||
vi.mock("node:fs/promises", () => ({
|
|
||||||
mkdir: vi.fn(),
|
|
||||||
writeFile: vi.fn(),
|
|
||||||
unlink: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
|
||||||
|
|
||||||
const staff = { id: 42, rank: 7, username: "operator" };
|
|
||||||
const form = (data: Record<string, FormDataEntryValue>) => ({
|
|
||||||
get: (key: string) => data[key] ?? null,
|
|
||||||
has: (key: string) => key in data,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
|
||||||
vi.mocked(requireStaff).mockResolvedValue(staff as never);
|
|
||||||
execute.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
|
||||||
correlationId: "legacy",
|
|
||||||
});
|
|
||||||
auditedBrandExecute.mockResolvedValue({
|
|
||||||
before: null,
|
|
||||||
after: { value: "/api/media/x" },
|
|
||||||
output: { url: "/api/media/x" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Content compatibility wrappers", () => {
|
|
||||||
it("delegates article creation and preserves redirect ordering", async () => {
|
|
||||||
await createArticle(
|
|
||||||
form({
|
|
||||||
title: "Launch",
|
|
||||||
shortStory: "Summary",
|
|
||||||
fullStory: "Body",
|
|
||||||
image: "/image.png",
|
|
||||||
}) as FormData,
|
|
||||||
);
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
|
||||||
"article.change",
|
|
||||||
expect.objectContaining({ action: "create", title: "Launch" }),
|
|
||||||
);
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/content/editorial/articles");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
|
||||||
expect(
|
|
||||||
await createAd(
|
|
||||||
form({ image: "https://example.test/ad.png" }) as FormData,
|
|
||||||
),
|
|
||||||
).toBeUndefined();
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
|
||||||
"ad.change",
|
|
||||||
expect.objectContaining({ action: "create" }),
|
|
||||||
);
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
|
||||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
|
||||||
const media = await uploadMedia(form({ file }) as FormData);
|
|
||||||
const favicon = await saveFavicon(form({ file }) as FormData);
|
|
||||||
expect(media).toEqual({ ok: true });
|
|
||||||
expect(favicon).toEqual({ success: true, url: "/api/media/x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
"media.upload",
|
|
||||||
expect.objectContaining({ file }),
|
|
||||||
);
|
|
||||||
expect(auditedBrandExecute).toHaveBeenCalledWith(
|
|
||||||
"favicon.save",
|
|
||||||
{ file },
|
|
||||||
expect.objectContaining({
|
|
||||||
capability: expect.objectContaining({
|
|
||||||
actor: expect.objectContaining({ id: 42 }),
|
|
||||||
}),
|
|
||||||
legacy: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the favicon page gate and requires settings edit for logo mutation", async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
|
||||||
await saveFavicon(form({ file }) as FormData);
|
|
||||||
await deleteFavicon();
|
|
||||||
await saveLogo(form({ file }) as FormData);
|
|
||||||
expect(requirePermission).toHaveBeenNthCalledWith(1, "settings.view");
|
|
||||||
expect(requirePermission).toHaveBeenNthCalledWith(2, "settings.view");
|
|
||||||
expect(requirePermission).toHaveBeenNthCalledWith(3, PERMS.SETTINGS_EDIT);
|
|
||||||
expect(requireStaff).not.toHaveBeenCalled();
|
|
||||||
expect(
|
|
||||||
auditedBrandExecute.mock.calls.map(([operation]) => operation),
|
|
||||||
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
|
|
||||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate brand assets when either legacy guard denies access", async () => {
|
|
||||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
|
||||||
vi.mocked(requirePermission).mockRejectedValueOnce(
|
|
||||||
new Error("favicon denied"),
|
|
||||||
);
|
|
||||||
await expect(saveFavicon(form({ file }) as FormData)).rejects.toThrow(
|
|
||||||
"favicon denied",
|
|
||||||
);
|
|
||||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
|
||||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
vi.mocked(requirePermission).mockRejectedValueOnce(
|
|
||||||
new Error("logo denied"),
|
|
||||||
);
|
|
||||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
|
||||||
"logo denied",
|
|
||||||
);
|
|
||||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
|
||||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not let a staff-only actor bypass the logo settings ACL", async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(requirePermission).mockRejectedValueOnce(
|
|
||||||
new Error("settings edit denied"),
|
|
||||||
);
|
|
||||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
|
||||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
|
||||||
"settings edit denied",
|
|
||||||
);
|
|
||||||
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
|
|
||||||
expect(requireStaff).not.toHaveBeenCalled();
|
|
||||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a brand mutation when the rehydrated actor changes after the legacy guard", async () => {
|
|
||||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
|
|
||||||
actor: { id: 99, username: "other", rank: 7 },
|
|
||||||
isSuperAdmin: false,
|
|
||||||
has: () => false,
|
|
||||||
hasAny: () => false,
|
|
||||||
hasAll: () => false,
|
|
||||||
} as never);
|
|
||||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
|
||||||
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
|
||||||
success: false,
|
|
||||||
error: "Authenticated staff changed during logo mutation",
|
|
||||||
});
|
|
||||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps a favicon audit partial to the truthful legacy result shape", async () => {
|
|
||||||
auditedBrandExecute.mockResolvedValueOnce({
|
|
||||||
before: { value: "/old.ico" },
|
|
||||||
after: { value: "/api/media/favicon/new.ico" },
|
|
||||||
output: { url: "/api/media/favicon/new.ico" },
|
|
||||||
completion: {
|
|
||||||
status: "partial",
|
|
||||||
external: "completed",
|
|
||||||
audit: "unavailable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const file = new File(["bytes"], "favicon.png", { type: "image/png" });
|
|
||||||
await expect(saveFavicon(form({ file }) as FormData)).resolves.toEqual({
|
|
||||||
success: false,
|
|
||||||
url: "/api/media/favicon/new.ico",
|
|
||||||
error:
|
|
||||||
"Favicon change completed partially; verify storage and audit state",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps a logo audit partial to the truthful legacy result shape", async () => {
|
|
||||||
auditedBrandExecute.mockResolvedValueOnce({
|
|
||||||
before: { value: "/old.png" },
|
|
||||||
after: { value: "/api/media/logo/new.png" },
|
|
||||||
output: { url: "/api/media/logo/new.png" },
|
|
||||||
completion: {
|
|
||||||
status: "partial",
|
|
||||||
external: "completed",
|
|
||||||
audit: "unavailable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
|
||||||
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
|
||||||
success: false,
|
|
||||||
url: "/api/media/logo/new.png",
|
|
||||||
error: "Logo change completed partially; verify storage and audit state",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
|
||||||
for (const path of [
|
|
||||||
"src/actions/admin-ads.ts",
|
|
||||||
"src/actions/admin-articles.ts",
|
|
||||||
"src/actions/admin-help.ts",
|
|
||||||
"src/actions/admin-media.ts",
|
|
||||||
"src/actions/admin-photos.ts",
|
|
||||||
"src/actions/admin-tags.ts",
|
|
||||||
"src/actions/events.ts",
|
|
||||||
"src/actions/polls.ts",
|
|
||||||
"src/actions/save-favicon.ts",
|
|
||||||
"src/actions/save-logo.ts",
|
|
||||||
"src/actions/emulator.ts",
|
|
||||||
]) {
|
|
||||||
const source = readFileSync(path, "utf8");
|
|
||||||
expect(
|
|
||||||
source.includes("contentMutationService") ||
|
|
||||||
source.includes("contentProductionMutationAdapter") ||
|
|
||||||
source.includes('from "./banners"'),
|
|
||||||
path,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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
@@ -3,16 +3,18 @@
|
|||||||
import { and, count, eq } from "drizzle-orm";
|
import { and, count, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
WebsiteEvent,
|
WebsiteEvent,
|
||||||
|
WebsiteEventPrize,
|
||||||
WebsiteEventRegistration,
|
WebsiteEventRegistration,
|
||||||
WebsiteEventType,
|
WebsiteEventType,
|
||||||
|
WebsiteEventWinner,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
createEventSchema,
|
createEventSchema,
|
||||||
eventPrizeSchema,
|
eventPrizeSchema,
|
||||||
@@ -27,17 +29,16 @@ import {
|
|||||||
export const createEventType = adminAction(
|
export const createEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
|
||||||
{
|
const eventTypeId = Number(result.insertId);
|
||||||
correlationId: String(ctx.requestId),
|
logAudit({
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
userId: ctx.session.user.id,
|
||||||
legacy: true,
|
action: "event_type_create",
|
||||||
},
|
target: "WebsiteEventType",
|
||||||
"event-type.change",
|
targetId: eventTypeId,
|
||||||
{ action: "create", ...ctx.data },
|
after: { name: ctx.data.name },
|
||||||
);
|
});
|
||||||
if (!result.ok) throw new ActionError("Event type creation failed");
|
return actionOk({ id: eventTypeId });
|
||||||
return actionOk({ id: Number(result.data.output?.id) });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,17 +49,27 @@ const updateEventTypeInput = eventTypeSchema.partial().extend({
|
|||||||
export const updateEventType = adminAction(
|
export const updateEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const { id, ...data } = ctx.data;
|
||||||
{
|
const [existing] = await db
|
||||||
correlationId: String(ctx.requestId),
|
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
.from(WebsiteEventType)
|
||||||
legacy: true,
|
.where(eq(WebsiteEventType.id, id))
|
||||||
},
|
.limit(1);
|
||||||
"event-type.change",
|
if (!existing) throw new ActionError("Event type not found");
|
||||||
{ action: "update", ...ctx.data },
|
|
||||||
);
|
await db
|
||||||
if (!result.ok) throw new ActionError("Event type not found");
|
.update(WebsiteEventType)
|
||||||
return actionOk({ id: ctx.data.id });
|
.set(data)
|
||||||
|
.where(eq(WebsiteEventType.id, id));
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "event_type_update",
|
||||||
|
target: "WebsiteEventType",
|
||||||
|
targetId: id,
|
||||||
|
before: { name: existing.name },
|
||||||
|
after: data,
|
||||||
|
});
|
||||||
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,16 +80,23 @@ const deleteEventTypeInput = z.object({
|
|||||||
export const deleteEventType = adminAction(
|
export const deleteEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [existing] = await db
|
||||||
{
|
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
||||||
correlationId: String(ctx.requestId),
|
.from(WebsiteEventType)
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
.where(eq(WebsiteEventType.id, ctx.data.id))
|
||||||
legacy: true,
|
.limit(1);
|
||||||
},
|
if (!existing) throw new ActionError("Event type not found");
|
||||||
"event-type.change",
|
|
||||||
{ action: "delete", ...ctx.data },
|
await db
|
||||||
);
|
.delete(WebsiteEventType)
|
||||||
if (!result.ok) throw new ActionError("Event type not found");
|
.where(eq(WebsiteEventType.id, ctx.data.id));
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "event_type_delete",
|
||||||
|
target: "WebsiteEventType",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
before: { name: existing.name },
|
||||||
|
});
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -88,17 +106,21 @@ export const deleteEventType = adminAction(
|
|||||||
export const createEvent = adminAction(
|
export const createEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const now = new Date();
|
||||||
{
|
const [result] = await db.insert(WebsiteEvent).values({
|
||||||
correlationId: String(ctx.requestId),
|
...ctx.data,
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
hostUserId: Number(ctx.session.user.id),
|
||||||
legacy: true,
|
updatedAt: now,
|
||||||
},
|
});
|
||||||
"event.change",
|
const eventId = Number(result.insertId);
|
||||||
{ action: "create", ...ctx.data },
|
logAudit({
|
||||||
);
|
userId: ctx.session.user.id,
|
||||||
if (!result.ok) throw new ActionError("Event creation failed");
|
action: "event_create",
|
||||||
return actionOk({ id: Number(result.data.output?.id) });
|
target: "WebsiteEvent",
|
||||||
|
targetId: eventId,
|
||||||
|
after: { title: ctx.data.title },
|
||||||
|
});
|
||||||
|
return actionOk({ id: eventId });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,17 +131,31 @@ const updateEventInput = updateEventSchema.extend({
|
|||||||
export const updateEvent = adminAction(
|
export const updateEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const { id, ...data } = ctx.data;
|
||||||
{
|
const [existing] = await db
|
||||||
correlationId: String(ctx.requestId),
|
.select({
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
id: WebsiteEvent.id,
|
||||||
legacy: true,
|
title: WebsiteEvent.title,
|
||||||
},
|
status: WebsiteEvent.status,
|
||||||
"event.change",
|
})
|
||||||
{ action: "update", ...ctx.data },
|
.from(WebsiteEvent)
|
||||||
);
|
.where(eq(WebsiteEvent.id, id))
|
||||||
if (!result.ok) throw new ActionError("Event not found");
|
.limit(1);
|
||||||
return actionOk({ id: ctx.data.id });
|
if (!existing) throw new ActionError("Event not found");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(WebsiteEvent)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(WebsiteEvent.id, id));
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "event_update",
|
||||||
|
target: "WebsiteEvent",
|
||||||
|
targetId: id,
|
||||||
|
before: { title: existing.title, status: existing.status },
|
||||||
|
after: data,
|
||||||
|
});
|
||||||
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,16 +166,21 @@ const deleteEventInput = z.object({
|
|||||||
export const deleteEvent = adminAction(
|
export const deleteEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [existing] = await db
|
||||||
{
|
.select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
|
||||||
correlationId: String(ctx.requestId),
|
.from(WebsiteEvent)
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
.where(eq(WebsiteEvent.id, ctx.data.id))
|
||||||
legacy: true,
|
.limit(1);
|
||||||
},
|
if (!existing) throw new ActionError("Event not found");
|
||||||
"event.change",
|
|
||||||
{ action: "delete", ...ctx.data },
|
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
|
||||||
);
|
logAudit({
|
||||||
if (!result.ok) throw new ActionError("Event not found");
|
userId: ctx.session.user.id,
|
||||||
|
action: "event_delete",
|
||||||
|
target: "WebsiteEvent",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
before: { title: existing.title },
|
||||||
|
});
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -149,17 +190,8 @@ export const deleteEvent = adminAction(
|
|||||||
export const addEventPrize = adminAction(
|
export const addEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
|
||||||
{
|
return actionOk({ id: Number(result.insertId) });
|
||||||
correlationId: String(ctx.requestId),
|
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
|
||||||
legacy: true,
|
|
||||||
},
|
|
||||||
"event-prize.change",
|
|
||||||
{ action: "create", ...ctx.data },
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError("Event prize creation failed");
|
|
||||||
return actionOk({ id: Number(result.data.output?.id) });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,16 +200,9 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteEventPrize = adminAction(
|
export const deleteEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
await db
|
||||||
{
|
.delete(WebsiteEventPrize)
|
||||||
correlationId: String(ctx.requestId),
|
.where(eq(WebsiteEventPrize.id, ctx.data.id));
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
|
||||||
legacy: true,
|
|
||||||
},
|
|
||||||
"event-prize.change",
|
|
||||||
{ action: "delete", ...ctx.data },
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError("Event prize deletion failed");
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -187,17 +212,20 @@ export const deleteEventPrize = adminAction(
|
|||||||
export const addEventWinner = adminAction(
|
export const addEventWinner = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
|
||||||
{
|
const winnerId = Number(result.insertId);
|
||||||
correlationId: String(ctx.requestId),
|
logAudit({
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
userId: ctx.session.user.id,
|
||||||
legacy: true,
|
action: "event_winner_add",
|
||||||
|
target: "WebsiteEventWinner",
|
||||||
|
targetId: winnerId,
|
||||||
|
after: {
|
||||||
|
eventId: ctx.data.eventId,
|
||||||
|
userId: ctx.data.userId,
|
||||||
|
position: ctx.data.position,
|
||||||
},
|
},
|
||||||
"event-winner.add",
|
});
|
||||||
ctx.data,
|
return actionOk({ id: winnerId });
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError("Event winner creation failed");
|
|
||||||
return actionOk({ id: Number(result.data.output?.id) });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
type ActionResult,
|
||||||
|
actionOk,
|
||||||
|
handleActionError,
|
||||||
|
} from "@/lib/safe-action-shared";
|
||||||
|
import type {
|
||||||
|
AlignResult,
|
||||||
|
DedupResult,
|
||||||
|
FixOfferResult,
|
||||||
|
FixSpriteResult,
|
||||||
|
FurniHealth,
|
||||||
|
ReconcileResult,
|
||||||
|
} from "@/lib/services/furni-maintenance";
|
||||||
|
import * as maintenance from "@/lib/services/furni-maintenance";
|
||||||
|
|
||||||
|
async function guard() {
|
||||||
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFurniHealthAction(): Promise<
|
||||||
|
ActionResult<{ health: FurniHealth }>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
const health = await maintenance.getFurniHealth();
|
||||||
|
return actionOk({ health });
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fixSpriteIdsAction(): Promise<
|
||||||
|
ActionResult<FixSpriteResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.fixSpriteIds());
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fixCatalogOffersAction(): Promise<
|
||||||
|
ActionResult<FixOfferResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.fixCatalogOffers());
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileIdsAction(): Promise<
|
||||||
|
ActionResult<ReconcileResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.reconcileIds());
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeDuplicateItemsBaseAction(): Promise<
|
||||||
|
ActionResult<DedupResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.removeDuplicates());
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewAlignIdsAction(): Promise<
|
||||||
|
ActionResult<AlignResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(false));
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAlignIdsAction(): Promise<
|
||||||
|
ActionResult<AlignResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fixEverythingAction(input?: {
|
||||||
|
dedupePages?: boolean;
|
||||||
|
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return actionOk(await maintenance.fixEverything(input ?? {}));
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
|
||||||
|
|
||||||
const { executeLegacyHotelMutation, staff } = vi.hoisted(() => ({
|
|
||||||
executeLegacyHotelMutation: vi.fn(
|
|
||||||
async (
|
|
||||||
_actor: { readonly id: number },
|
|
||||||
_operation: string,
|
|
||||||
_input: unknown,
|
|
||||||
) => ({ before: null, after: {} }),
|
|
||||||
),
|
|
||||||
staff: { id: 42, rank: 7, username: "operator" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/hotel/services/mutations", () => ({
|
|
||||||
executeLegacyHotelMutation,
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
PERMS: {
|
|
||||||
ROOMS_EDIT: "admin.room.edit",
|
|
||||||
ROOMS_DELETE: "admin.room.delete",
|
|
||||||
RADIO_EDIT: "admin.radio.edit",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/site-settings", () => ({
|
|
||||||
siteSettings: { reload: vi.fn() },
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
|
||||||
|
|
||||||
import {
|
|
||||||
createApiKey,
|
|
||||||
deleteApiKey,
|
|
||||||
toggleApiKey,
|
|
||||||
} from "./admin-radio-api-keys";
|
|
||||||
import { createTrack, deleteTrack, toggleTrack } from "./admin-radio-autodj";
|
|
||||||
import {
|
|
||||||
createRadioBanner,
|
|
||||||
createRadioRank,
|
|
||||||
deleteRadioBanner,
|
|
||||||
deleteRadioRank,
|
|
||||||
saveRadioSetting,
|
|
||||||
saveRadioSettings,
|
|
||||||
updateRadioBanner,
|
|
||||||
updateRadioRank,
|
|
||||||
} from "./admin-radio-extra";
|
|
||||||
import { deleteShout } from "./admin-radio-moderation";
|
|
||||||
import { savePoints } from "./admin-radio-points";
|
|
||||||
import {
|
|
||||||
bulkDeleteRoomItems,
|
|
||||||
deleteRoom,
|
|
||||||
deleteRoomItem,
|
|
||||||
roomRconAction,
|
|
||||||
updateRoom,
|
|
||||||
updateRoomItem,
|
|
||||||
} from "./rooms";
|
|
||||||
|
|
||||||
function form(data: Readonly<Record<string, string>>): FormData {
|
|
||||||
return {
|
|
||||||
get: (key: string) => data[key] ?? null,
|
|
||||||
} as FormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Hotel legacy wrappers", () => {
|
|
||||||
it("delegates every room operation through the actor-bound Hotel service", async () => {
|
|
||||||
await updateRoom({ id: 7, name: "Lobby" });
|
|
||||||
await deleteRoom({ id: 7 });
|
|
||||||
await updateRoomItem({ roomId: 7, itemId: 8, x: 1 });
|
|
||||||
await deleteRoomItem({ roomId: 7, itemId: 8 });
|
|
||||||
await bulkDeleteRoomItems({ roomId: 7, itemIds: [8, 9] });
|
|
||||||
await roomRconAction({ roomId: 7, action: "reload" });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
executeLegacyHotelMutation.mock.calls.map((call) => call[1]),
|
|
||||||
).toEqual([
|
|
||||||
"room.update",
|
|
||||||
"room.delete",
|
|
||||||
"room-item.update",
|
|
||||||
"room-item.delete",
|
|
||||||
"room-item.bulk-delete",
|
|
||||||
"room.runtime",
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
executeLegacyHotelMutation.mock.calls.every(([actor]) => actor === staff),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates all radio mutations without accepting a client API-key secret", async () => {
|
|
||||||
await saveRadioSetting(form({ key: "radio_name", value: "Epic" }));
|
|
||||||
await saveRadioSettings(
|
|
||||||
form({
|
|
||||||
__keys: "radio_name,auto_dj_enabled",
|
|
||||||
radio_name: "Epic",
|
|
||||||
auto_dj_enabled: "1",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await deleteShout(form({ id: "1" }));
|
|
||||||
await createApiKey(form({ name: "Bridge", allowedIps: "127.0.0.1" }));
|
|
||||||
await toggleApiKey(form({ id: "2" }));
|
|
||||||
await deleteApiKey(form({ id: "2" }));
|
|
||||||
await createTrack(form({ title: "Song", isActive: "on" }));
|
|
||||||
await toggleTrack(form({ id: "3", isActive: "on" }));
|
|
||||||
await deleteTrack(form({ id: "3" }));
|
|
||||||
await createRadioBanner(form({ imagePath: "/banner.png" }));
|
|
||||||
await updateRadioBanner(form({ id: "4", imagePath: "/banner.png" }));
|
|
||||||
await deleteRadioBanner(form({ id: "4" }));
|
|
||||||
await createRadioRank(form({ name: "DJ" }));
|
|
||||||
await updateRadioRank(form({ id: "5", name: "DJ" }));
|
|
||||||
await deleteRadioRank(form({ id: "5" }));
|
|
||||||
await savePoints(
|
|
||||||
form({
|
|
||||||
radio_points_enabled: "on",
|
|
||||||
radio_points_per_minute: "1",
|
|
||||||
radio_points_currency: "credits",
|
|
||||||
radio_points_max_per_day: "100",
|
|
||||||
radio_points_min_listeners: "2",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
executeLegacyHotelMutation.mock.calls.map((call) => call[1]),
|
|
||||||
).toEqual([
|
|
||||||
"radio.settings.save-one",
|
|
||||||
"radio.settings.save-many",
|
|
||||||
"radio.shout.delete",
|
|
||||||
"radio.api-key.create",
|
|
||||||
"radio.api-key.toggle",
|
|
||||||
"radio.api-key.delete",
|
|
||||||
"radio.autodj.create",
|
|
||||||
"radio.autodj.toggle",
|
|
||||||
"radio.autodj.delete",
|
|
||||||
"radio.banner.create",
|
|
||||||
"radio.banner.update",
|
|
||||||
"radio.banner.delete",
|
|
||||||
"radio.rank.create",
|
|
||||||
"radio.rank.update",
|
|
||||||
"radio.rank.delete",
|
|
||||||
"radio.points.save",
|
|
||||||
]);
|
|
||||||
const createInput = executeLegacyHotelMutation.mock.calls.find(
|
|
||||||
([, operation]) => operation === "radio.api-key.create",
|
|
||||||
)?.[2];
|
|
||||||
expect(createInput).not.toHaveProperty("key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the six legacy modules as thin shared-service wrappers", () => {
|
|
||||||
for (const path of [
|
|
||||||
"src/actions/rooms.ts",
|
|
||||||
"src/actions/admin-radio-api-keys.ts",
|
|
||||||
"src/actions/admin-radio-autodj.ts",
|
|
||||||
"src/actions/admin-radio-extra.ts",
|
|
||||||
"src/actions/admin-radio-moderation.ts",
|
|
||||||
"src/actions/admin-radio-points.ts",
|
|
||||||
]) {
|
|
||||||
const source = readFileSync(path, "utf8");
|
|
||||||
expect(source, path).toContain("executeLegacyHotelMutation");
|
|
||||||
expect(source, path).not.toContain('from "@/lib/db"');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
|
||||||
|
|
||||||
vi.mock(
|
|
||||||
"@/features/housekeeping/foundation/commands/registry",
|
|
||||||
async (importOriginal) => ({
|
|
||||||
...(await importOriginal<
|
|
||||||
typeof import("@/features/housekeeping/foundation/commands/registry")
|
|
||||||
>()),
|
|
||||||
sealHousekeepingCommandRegistry: sealRegistryMock,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/commands", () => ({
|
|
||||||
housekeepingCommandRegistryReady: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
anyCapability,
|
|
||||||
ok,
|
|
||||||
} from "@/features/housekeeping/foundation/contracts";
|
|
||||||
import type { AuditEntry } from "@/lib/services/audit";
|
|
||||||
|
|
||||||
const {
|
|
||||||
auditEntries,
|
|
||||||
auditWriteMock,
|
|
||||||
commandExecutions,
|
|
||||||
context,
|
|
||||||
getContextMock,
|
|
||||||
getIpMock,
|
|
||||||
rateLimitCalls,
|
|
||||||
sealRegistryMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
auditEntries: [] as AuditEntry[],
|
|
||||||
auditWriteMock: vi.fn(),
|
|
||||||
commandExecutions: [] as string[],
|
|
||||||
context: {
|
|
||||||
actor: { id: 71, username: "server-operator", rank: 4 },
|
|
||||||
isSuperAdmin: false,
|
|
||||||
has: (slug: string) => slug === "admin.settings.edit",
|
|
||||||
hasAny: (...slugs: string[]) => slugs.includes("admin.settings.edit"),
|
|
||||||
hasAll: (...slugs: string[]) =>
|
|
||||||
slugs.every((slug) => slug === "admin.settings.edit"),
|
|
||||||
},
|
|
||||||
getContextMock: vi.fn(),
|
|
||||||
getIpMock: vi.fn(),
|
|
||||||
rateLimitCalls: [] as Array<[string, number, number]>,
|
|
||||||
sealRegistryMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
|
||||||
getHousekeepingCapabilityContext: getContextMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/services/audit", () => ({
|
|
||||||
housekeepingAuditWriter: { write: auditWriteMock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({
|
|
||||||
clientIp: getIpMock,
|
|
||||||
rateLimit: async (key: string, attempts: number, windowMs: number) => {
|
|
||||||
rateLimitCalls.push([key, attempts, windowMs]);
|
|
||||||
return { ok: true, retryAfter: 0 };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { executeHousekeepingCommand } from "./housekeeping-command";
|
|
||||||
|
|
||||||
registerHousekeepingCommand({
|
|
||||||
id: "system.server-action.serializable",
|
|
||||||
owner: "system",
|
|
||||||
risk: "safe",
|
|
||||||
capability: anyCapability("admin.settings.edit"),
|
|
||||||
input: z.object({ value: z.string() }),
|
|
||||||
requiresReason: false,
|
|
||||||
rateLimit: { attempts: 5, windowMs: 120_000 },
|
|
||||||
execute: async (commandContext, input) => {
|
|
||||||
commandExecutions.push(input.value);
|
|
||||||
return ok(
|
|
||||||
{
|
|
||||||
value: input.value,
|
|
||||||
actorId: commandContext.capability.actor.id,
|
|
||||||
ipAddress: commandContext.ipAddress,
|
|
||||||
},
|
|
||||||
commandContext.correlationId,
|
|
||||||
input.value === "partial"
|
|
||||||
? {
|
|
||||||
status: "partial",
|
|
||||||
external: "failed",
|
|
||||||
audit: "persisted",
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
auditEntries.length = 0;
|
|
||||||
commandExecutions.length = 0;
|
|
||||||
rateLimitCalls.length = 0;
|
|
||||||
getContextMock.mockReset().mockResolvedValue(context);
|
|
||||||
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
|
|
||||||
sealRegistryMock.mockReset();
|
|
||||||
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
|
|
||||||
auditEntries.push({ ...entry });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("executeHousekeepingCommand", () => {
|
|
||||||
it("accepts a plain request and derives all policy metadata server-side", async () => {
|
|
||||||
const result = await executeHousekeepingCommand({
|
|
||||||
commandId: "system.server-action.serializable",
|
|
||||||
input: { value: "saved" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
value: "saved",
|
|
||||||
actorId: 71,
|
|
||||||
ipAddress: "203.0.113.7",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(rateLimitCalls).toEqual([
|
|
||||||
[
|
|
||||||
"housekeeping-command:71:203.0.113.7:system.server-action.serializable",
|
|
||||||
5,
|
|
||||||
120_000,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
expect(auditEntries).toMatchObject([
|
|
||||||
{
|
|
||||||
userId: 71,
|
|
||||||
action: "system.server-action.serializable",
|
|
||||||
target: "system",
|
|
||||||
domain: "system",
|
|
||||||
ipAddress: "203.0.113.7",
|
|
||||||
outcome: "success",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
|
||||||
expect(sealRegistryMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves one returned partial completion and its correlation through the real action dispatcher", async () => {
|
|
||||||
const result = await executeHousekeepingCommand({
|
|
||||||
commandId: "system.server-action.serializable",
|
|
||||||
input: { value: "partial" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
data: { value: "partial" },
|
|
||||||
completion: {
|
|
||||||
status: "partial",
|
|
||||||
external: "failed",
|
|
||||||
audit: "persisted",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(auditEntries).toHaveLength(1);
|
|
||||||
expect(auditEntries[0]).toMatchObject({
|
|
||||||
outcome: "partial",
|
|
||||||
correlationId: result.correlationId,
|
|
||||||
});
|
|
||||||
expect(() => JSON.stringify(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
it("strictly rejects spoofed server-owned metadata before execution", async () => {
|
|
||||||
const result = await executeHousekeepingCommand({
|
|
||||||
commandId: "system.server-action.serializable",
|
|
||||||
input: { value: "forged" },
|
|
||||||
risk: "sensitive",
|
|
||||||
owner: "people",
|
|
||||||
capability: { mode: "any", slugs: ["forged.permission"] },
|
|
||||||
actor: { id: 999 },
|
|
||||||
ipAddress: "198.51.100.9",
|
|
||||||
rateLimit: { attempts: 999, windowMs: 1 },
|
|
||||||
audit: { action: "forged.action", target: "forged-target" },
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
error: { code: "VALIDATION" },
|
|
||||||
});
|
|
||||||
expect(commandExecutions).toEqual([]);
|
|
||||||
expect(rateLimitCalls).toEqual([]);
|
|
||||||
expect(auditEntries).toMatchObject([
|
|
||||||
{
|
|
||||||
userId: 71,
|
|
||||||
action: "housekeeping.command.dispatch",
|
|
||||||
target: "request-envelope",
|
|
||||||
ipAddress: "203.0.113.7",
|
|
||||||
outcome: "denied",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(JSON.stringify(auditEntries)).not.toContain("forged");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes server context acquisition failures into typed results", async () => {
|
|
||||||
getContextMock.mockRejectedValue(
|
|
||||||
new Error("session database secret exposed"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await executeHousekeepingCommand({
|
|
||||||
commandId: "system.server-action.serializable",
|
|
||||||
input: { value: "blocked" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" },
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(result)).not.toContain("secret exposed");
|
|
||||||
expect(commandExecutions).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns one truthful partial completion when outcome audit persistence fails", async () => {
|
|
||||||
auditWriteMock.mockImplementation(async (entry: AuditEntry) => {
|
|
||||||
if (entry.outcome === "success") {
|
|
||||||
throw new Error("success audit unavailable");
|
|
||||||
}
|
|
||||||
auditEntries.push({ ...entry });
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await executeHousekeepingCommand({
|
|
||||||
commandId: "system.server-action.serializable",
|
|
||||||
input: { value: "changed" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(commandExecutions).toEqual(["changed"]);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
data: { value: "changed" },
|
|
||||||
completion: {
|
|
||||||
status: "partial",
|
|
||||||
external: "not-required",
|
|
||||||
audit: "persisted",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(auditEntries.map((entry) => entry.outcome)).toEqual(["partial"]);
|
|
||||||
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
|
||||||
expect(() => JSON.stringify(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
const ITEMS_BASE_FIELDS = [
|
||||||
|
"publicName",
|
||||||
|
"itemName",
|
||||||
|
"type",
|
||||||
|
"spriteId",
|
||||||
|
"width",
|
||||||
|
"length",
|
||||||
|
"stackHeight",
|
||||||
|
"allowStack",
|
||||||
|
"allowSit",
|
||||||
|
"allowLay",
|
||||||
|
"allowWalk",
|
||||||
|
"allowGift",
|
||||||
|
"allowTrade",
|
||||||
|
"allowRecycle",
|
||||||
|
"allowMarketplaceSell",
|
||||||
|
"allowInventoryStack",
|
||||||
|
"interactionType",
|
||||||
|
"interactionModesCount",
|
||||||
|
"vendingIds",
|
||||||
|
"multiheight",
|
||||||
|
"customparams",
|
||||||
|
"effectIdMale",
|
||||||
|
"effectIdFemale",
|
||||||
|
"clothingOnWalk",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
fields: z.record(z.string(), z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
function pickAllowed(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
allowed: readonly string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||||
|
out[key] = fields[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateItemsBase = adminAction(
|
||||||
|
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const { id, fields } = ctx.data;
|
||||||
|
const safe = pickAllowed(fields, ITEMS_BASE_FIELDS);
|
||||||
|
if (Object.keys(safe).length === 0) {
|
||||||
|
throw new ActionError("No valid fields to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: ItemsBase.id })
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(eq(ItemsBase.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) throw new ActionError("Item not found");
|
||||||
|
|
||||||
|
// Coerce common numeric / decimal fields from form strings.
|
||||||
|
const data: Record<string, unknown> = { ...safe };
|
||||||
|
for (const key of [
|
||||||
|
"spriteId",
|
||||||
|
"width",
|
||||||
|
"length",
|
||||||
|
"allowStack",
|
||||||
|
"allowSit",
|
||||||
|
"allowLay",
|
||||||
|
"allowWalk",
|
||||||
|
"allowGift",
|
||||||
|
"allowTrade",
|
||||||
|
"allowRecycle",
|
||||||
|
"allowMarketplaceSell",
|
||||||
|
"allowInventoryStack",
|
||||||
|
"interactionModesCount",
|
||||||
|
"effectIdMale",
|
||||||
|
"effectIdFemale",
|
||||||
|
]) {
|
||||||
|
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||||
|
}
|
||||||
|
if (data.stackHeight !== undefined) {
|
||||||
|
data.stackHeight = Number(data.stackHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(ItemsBase)
|
||||||
|
.set(data as Partial<typeof ItemsBase.$inferInsert>)
|
||||||
|
.where(eq(ItemsBase.id, id));
|
||||||
|
await rcon.updateCatalog().catch(() => false);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: Number(ctx.session.user.id),
|
||||||
|
action: "items_base_update",
|
||||||
|
description: `Updated items_base #${id}`,
|
||||||
|
targetType: "items_base",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/items");
|
||||||
|
revalidatePath(`/admin/items/${id}`);
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return actionOk({ id });
|
||||||
|
},
|
||||||
|
);
|
||||||
+135
-80
@@ -1,14 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { db, SupportTickets } from "@/lib/db";
|
||||||
createPeopleMutationInvocation,
|
|
||||||
peopleMutationService,
|
|
||||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
|
||||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
|
||||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
import { NotFoundError } from "@/lib/foundation/errors";
|
import { NotFoundError } from "@/lib/foundation/errors";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -17,42 +16,28 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
|||||||
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
|
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
|
||||||
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
|
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
|
||||||
|
|
||||||
async function execute(
|
|
||||||
staff: { readonly id: number },
|
|
||||||
operation: "cfh.resolve" | "moderation.action",
|
|
||||||
input: unknown,
|
|
||||||
) {
|
|
||||||
return peopleMutationService.execute(
|
|
||||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
|
||||||
operation,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeLegacyModerationAction(
|
|
||||||
staff: { readonly id: number },
|
|
||||||
input: unknown,
|
|
||||||
) {
|
|
||||||
const result = await execute(staff, "moderation.action", input);
|
|
||||||
if (!result.ok) {
|
|
||||||
throw new Error("Could not execute moderation action");
|
|
||||||
}
|
|
||||||
return actionOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const assignCfhTicket = adminAction(
|
export const assignCfhTicket = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await execute(ctx.session.user, "cfh.resolve", {
|
const [ticket] = await db
|
||||||
ticketId: ctx.data.ticketId,
|
.select({ id: SupportTickets.id })
|
||||||
state: 1,
|
.from(SupportTickets)
|
||||||
|
.where(eq(SupportTickets.id, ctx.data.ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(SupportTickets)
|
||||||
|
.set({ modId: ctx.session.user.id, state: 1 })
|
||||||
|
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "cfh_assign",
|
||||||
|
target: "support_tickets",
|
||||||
|
targetId: ctx.data.ticketId,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
|
||||||
if (result.error.code === "NOT_FOUND") {
|
|
||||||
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
|
||||||
}
|
|
||||||
throw new Error("Could not assign support ticket");
|
|
||||||
}
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -65,13 +50,30 @@ const cfhStateSchema = z.object({
|
|||||||
export const updateCfhState = adminAction(
|
export const updateCfhState = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhStateSchema },
|
{ permission: CFH_PERM, schema: cfhStateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await execute(ctx.session.user, "cfh.resolve", ctx.data);
|
const [ticket] = await db
|
||||||
if (!result.ok) {
|
.select({
|
||||||
if (result.error.code === "NOT_FOUND") {
|
id: SupportTickets.id,
|
||||||
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
state: SupportTickets.state,
|
||||||
}
|
})
|
||||||
throw new Error("Could not update support ticket");
|
.from(SupportTickets)
|
||||||
}
|
.where(eq(SupportTickets.id, ctx.data.ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(SupportTickets)
|
||||||
|
.set({ state: ctx.data.state, modId: ctx.session.user.id })
|
||||||
|
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "cfh_state_change",
|
||||||
|
target: "support_tickets",
|
||||||
|
targetId: ctx.data.ticketId,
|
||||||
|
before: { state: ticket.state },
|
||||||
|
after: { state: ctx.data.state },
|
||||||
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -79,13 +81,18 @@ export const updateCfhState = adminAction(
|
|||||||
export const closeCfhTicket = adminAction(
|
export const closeCfhTicket = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await execute(ctx.session.user, "cfh.resolve", {
|
await db
|
||||||
ticketId: ctx.data.ticketId,
|
.update(SupportTickets)
|
||||||
state: 2,
|
.set({ state: 2, modId: ctx.session.user.id })
|
||||||
|
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "cfh_close",
|
||||||
|
target: "support_tickets",
|
||||||
|
targetId: ctx.data.ticketId,
|
||||||
});
|
});
|
||||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
|
||||||
throw new Error("Could not close support ticket");
|
|
||||||
}
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -96,11 +103,18 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
|||||||
|
|
||||||
export const quickKick = adminAction(
|
export const quickKick = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
(ctx) =>
|
async (ctx) => {
|
||||||
executeLegacyModerationAction(ctx.session.user, {
|
await rcon.disconnectUser(ctx.data.userId);
|
||||||
action: "kick",
|
|
||||||
userId: ctx.data.userId,
|
logAudit({
|
||||||
}),
|
userId: ctx.session.user.id,
|
||||||
|
action: "mod_kick",
|
||||||
|
target: "User",
|
||||||
|
targetId: ctx.data.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const muteSchema = z.object({
|
const muteSchema = z.object({
|
||||||
@@ -110,20 +124,35 @@ const muteSchema = z.object({
|
|||||||
|
|
||||||
export const quickMute = adminAction(
|
export const quickMute = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||||
(ctx) =>
|
async (ctx) => {
|
||||||
executeLegacyModerationAction(ctx.session.user, {
|
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
action: "mute",
|
|
||||||
...ctx.data,
|
logAudit({
|
||||||
}),
|
userId: ctx.session.user.id,
|
||||||
|
action: "mod_mute",
|
||||||
|
target: "User",
|
||||||
|
targetId: ctx.data.userId,
|
||||||
|
after: { duration: ctx.data.duration },
|
||||||
|
});
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const quickUnmute = adminAction(
|
export const quickUnmute = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
(ctx) =>
|
async (ctx) => {
|
||||||
executeLegacyModerationAction(ctx.session.user, {
|
await rcon.unmuteUser(ctx.data.userId);
|
||||||
action: "unmute",
|
|
||||||
userId: ctx.data.userId,
|
logAudit({
|
||||||
}),
|
userId: ctx.session.user.id,
|
||||||
|
action: "mod_unmute",
|
||||||
|
target: "User",
|
||||||
|
targetId: ctx.data.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const alertSchema = z.object({
|
const alertSchema = z.object({
|
||||||
@@ -133,22 +162,37 @@ const alertSchema = z.object({
|
|||||||
|
|
||||||
export const quickAlert = adminAction(
|
export const quickAlert = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||||
(ctx) =>
|
async (ctx) => {
|
||||||
executeLegacyModerationAction(ctx.session.user, {
|
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||||
action: "alert",
|
|
||||||
...ctx.data,
|
logAudit({
|
||||||
}),
|
userId: ctx.session.user.id,
|
||||||
|
action: "mod_alert",
|
||||||
|
target: "User",
|
||||||
|
targetId: ctx.data.userId,
|
||||||
|
after: { message: ctx.data.message },
|
||||||
|
});
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickRoomKick = adminAction(
|
export const quickRoomKick = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||||
(ctx) =>
|
async (ctx) => {
|
||||||
executeLegacyModerationAction(ctx.session.user, {
|
await rcon.kickAll(ctx.data.roomId);
|
||||||
action: "room-kick",
|
|
||||||
roomId: ctx.data.roomId,
|
logAudit({
|
||||||
}),
|
userId: ctx.session.user.id,
|
||||||
|
action: "mod_room_kick",
|
||||||
|
target: "Room",
|
||||||
|
targetId: ctx.data.roomId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const broadcastSchema = z.object({
|
const broadcastSchema = z.object({
|
||||||
@@ -158,9 +202,20 @@ const broadcastSchema = z.object({
|
|||||||
|
|
||||||
export const broadcastAlert = adminAction(
|
export const broadcastAlert = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||||
(ctx) =>
|
async (ctx) => {
|
||||||
executeLegacyModerationAction(ctx.session.user, {
|
if (ctx.data.type === "hotel") {
|
||||||
action: "broadcast",
|
await rcon.hotelAlert(ctx.data.message);
|
||||||
...ctx.data,
|
} else {
|
||||||
}),
|
await rcon.staffAlert(ctx.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: `mod_broadcast_${ctx.data.type}`,
|
||||||
|
target: "broadcast",
|
||||||
|
after: { message: ctx.data.message },
|
||||||
|
});
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
@@ -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 } };
|
||||||
|
}
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
|
||||||
|
|
||||||
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() }));
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
PERMS: {
|
|
||||||
USERS_EDIT: "admin.users.edit",
|
|
||||||
USERS_BAN: "admin.users.ban",
|
|
||||||
USERS_RESET_PASSWORD: "admin.users.reset_password",
|
|
||||||
WORDFILTER_EDIT: "admin.wordfilter.edit",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
|
||||||
adminAction:
|
|
||||||
(_options: unknown, handler: (context: unknown) => unknown) =>
|
|
||||||
(data: unknown) =>
|
|
||||||
handler({ data, session: { user: staff } }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({
|
|
||||||
ActionError: class ActionError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ActionError";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
|
||||||
actionError: (error: string) => ({ ok: false, error }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {},
|
|
||||||
User: {},
|
|
||||||
UsersBadges: {},
|
|
||||||
UsersCurrency: {},
|
|
||||||
UsersSettings: {},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
|
||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: {} }));
|
|
||||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
||||||
|
|
||||||
import { dismissApplication } from "./admin-applications";
|
|
||||||
import { addWord, deleteWord } from "./admin-wordfilter";
|
|
||||||
import { banUser, resetPassword, updateUser } from "./users";
|
|
||||||
|
|
||||||
const form = (data: Record<string, string>) =>
|
|
||||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff);
|
|
||||||
execute.mockImplementation(async (context, operation) => ({
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
before: {},
|
|
||||||
after: operation === "word-filter.update" ? { id: 12 } : {},
|
|
||||||
output:
|
|
||||||
operation === "user.reset-password"
|
|
||||||
? { newPassword: "temporary-password" }
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
correlationId: context.correlationId,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy user safe-action wrappers", () => {
|
|
||||||
it("preserves exact ACL-specific service delegation", async () => {
|
|
||||||
await (updateUser as never as (input: unknown) => Promise<unknown>)({
|
|
||||||
id: 7,
|
|
||||||
motto: "Ready",
|
|
||||||
});
|
|
||||||
await (banUser as never as (input: unknown) => Promise<unknown>)({
|
|
||||||
userId: 7,
|
|
||||||
reason: "abuse",
|
|
||||||
duration: 0,
|
|
||||||
type: "account",
|
|
||||||
});
|
|
||||||
const reset = await (
|
|
||||||
resetPassword as never as (input: unknown) => Promise<{
|
|
||||||
ok: boolean;
|
|
||||||
data: { newPassword: string };
|
|
||||||
}>
|
|
||||||
)({ userId: 7 });
|
|
||||||
|
|
||||||
expect(execute.mock.calls.map((call) => call[1])).toEqual([
|
|
||||||
"user.update",
|
|
||||||
"user.ban",
|
|
||||||
"user.reset-password",
|
|
||||||
]);
|
|
||||||
expect(execute.mock.calls.map((call) => call[0].expectedActorId)).toEqual([
|
|
||||||
1, 1, 1,
|
|
||||||
]);
|
|
||||||
expect(reset.data.newPassword).toBe("temporary-password");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy application and word-filter wrappers", () => {
|
|
||||||
it("keeps tolerant application dismissal and ASE revalidation", async () => {
|
|
||||||
await dismissApplication(form({ id: "9" }));
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ expectedActorId: 1 }),
|
|
||||||
"application.decide",
|
|
||||||
{ applicationId: "9", decision: "dismiss" },
|
|
||||||
);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
|
||||||
"/ase/people/staff/applications",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves application and wordfilter IDs above Number.MAX_SAFE_INTEGER", async () => {
|
|
||||||
await dismissApplication(form({ id: "9007199254740993" }));
|
|
||||||
await expect(deleteWord({ id: "9007199254740993" })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
|
|
||||||
{ applicationId: "9007199254740993", decision: "dismiss" },
|
|
||||||
{ action: "delete", id: "9007199254740993" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("preserves word-filter ActionResult shapes and ASE revalidation", async () => {
|
|
||||||
await expect(addWord({ word: "spam" })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: { id: "12" },
|
|
||||||
});
|
|
||||||
await expect(deleteWord({ id: "12" })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
|
|
||||||
{ action: "add", word: "spam" },
|
|
||||||
{ action: "delete", id: "12" },
|
|
||||||
]);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
|
||||||
"/ase/people/moderation/word-filter",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const { execute, registrations, staff } = vi.hoisted(() => ({
|
|
||||||
execute: vi.fn(),
|
|
||||||
registrations: [] as Array<{ permission: string | readonly string[] }>,
|
|
||||||
staff: { id: 42, rank: 4, username: "moderator" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
function wrapper(
|
|
||||||
options: {
|
|
||||||
permission: string | readonly string[];
|
|
||||||
schema?: {
|
|
||||||
safeParse(
|
|
||||||
value: unknown,
|
|
||||||
): { success: true; data: unknown } | { success: false; error: unknown };
|
|
||||||
};
|
|
||||||
},
|
|
||||||
handler: (context: {
|
|
||||||
data: unknown;
|
|
||||||
session: { user: typeof staff };
|
|
||||||
}) => unknown,
|
|
||||||
) {
|
|
||||||
registrations.push(options);
|
|
||||||
return async (data: unknown) => {
|
|
||||||
const parsed = options.schema?.safeParse(data);
|
|
||||||
if (parsed && !parsed.success) {
|
|
||||||
return { ok: false, error: "Validation failed" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await handler({
|
|
||||||
data: parsed?.data ?? data,
|
|
||||||
session: { user: staff },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : "Internal server error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
|
||||||
createPeopleMutationInvocation: vi.fn((actor, correlationId) => ({
|
|
||||||
expectedActorId: actor.id,
|
|
||||||
correlationId,
|
|
||||||
legacy: true,
|
|
||||||
})),
|
|
||||||
peopleMutationService: { execute },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action", () => ({ adminAction: wrapper }));
|
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({
|
|
||||||
ActionError: class ActionError extends Error {},
|
|
||||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/foundation/action", () => ({
|
|
||||||
adminAction: wrapper,
|
|
||||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
PERMS: {
|
|
||||||
TICKETS_EDIT: "admin.tickets.edit",
|
|
||||||
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
|
||||||
USERS_BAN: "admin.users.ban",
|
|
||||||
MODERATION_EDIT: "admin.moderation.edit",
|
|
||||||
MOD_CFH_EDIT: "mod.cfh.edit",
|
|
||||||
MOD_ACTIONS: "mod.actions",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
||||||
|
|
||||||
import {
|
|
||||||
closeHelpCenterTicket,
|
|
||||||
liftBanFromHelpTicket,
|
|
||||||
reopenHelpCenterTicket,
|
|
||||||
replyHelpCenterTicket,
|
|
||||||
} from "./admin-help-tickets";
|
|
||||||
import {
|
|
||||||
assignCfhTicket,
|
|
||||||
broadcastAlert,
|
|
||||||
closeCfhTicket,
|
|
||||||
quickAlert,
|
|
||||||
quickKick,
|
|
||||||
quickMute,
|
|
||||||
quickRoomKick,
|
|
||||||
quickUnmute,
|
|
||||||
updateCfhState,
|
|
||||||
} from "./moderation";
|
|
||||||
import {
|
|
||||||
createTemplate,
|
|
||||||
deleteTemplate,
|
|
||||||
updateTemplate,
|
|
||||||
} from "./ticket-templates";
|
|
||||||
import {
|
|
||||||
adminReplyTicket,
|
|
||||||
assignTicket,
|
|
||||||
updateTicketPriority,
|
|
||||||
updateTicketStatus,
|
|
||||||
} from "./tickets";
|
|
||||||
|
|
||||||
type LegacyAction = (input: unknown) => Promise<unknown>;
|
|
||||||
const call = (action: unknown, input: unknown) =>
|
|
||||||
(action as LegacyAction)(input);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
execute.mockImplementation(async (invocation, operation) => ({
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
before: null,
|
|
||||||
after: operation === "ticket-template.change" ? { id: "88" } : {},
|
|
||||||
output:
|
|
||||||
operation === "help-ticket.unban"
|
|
||||||
? { removed: 2, userId: 7 }
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
correlationId: invocation.correlationId,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy People support and moderation wrappers", () => {
|
|
||||||
it("keeps mid-rank ACL alternatives without an admin.dashboard dependency", () => {
|
|
||||||
const permissions = registrations.flatMap((entry) =>
|
|
||||||
typeof entry.permission === "string"
|
|
||||||
? [entry.permission]
|
|
||||||
: entry.permission,
|
|
||||||
);
|
|
||||||
expect(permissions).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
"admin.tickets.edit",
|
|
||||||
"mod.tickets.edit",
|
|
||||||
"admin.moderation.edit",
|
|
||||||
"mod.cfh.edit",
|
|
||||||
"mod.actions",
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(permissions).not.toContain("admin.dashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates tickets and templates with their established result shapes", async () => {
|
|
||||||
await expect(
|
|
||||||
call(adminReplyTicket, { ticketId: 7, message: "Handled" }),
|
|
||||||
).resolves.toEqual({ ok: true, data: {} });
|
|
||||||
await call(assignTicket, { ticketId: 7, assigneeId: 42 });
|
|
||||||
await call(updateTicketStatus, { ticketId: 7, status: "closed" });
|
|
||||||
await call(updateTicketPriority, { ticketId: 7, priority: "urgent" });
|
|
||||||
await expect(
|
|
||||||
call(createTemplate, {
|
|
||||||
title: "Greeting",
|
|
||||||
content: "Hello",
|
|
||||||
category: "general",
|
|
||||||
sortOrder: 0,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ ok: true, data: { id: 88 } });
|
|
||||||
await expect(
|
|
||||||
call(updateTemplate, { id: 88, title: "Updated" }),
|
|
||||||
).resolves.toEqual({ ok: true, data: { id: 88 } });
|
|
||||||
await expect(call(deleteTemplate, { id: 88 })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
|
|
||||||
"ticket.reply",
|
|
||||||
"ticket.assign",
|
|
||||||
"ticket.status",
|
|
||||||
"ticket.priority",
|
|
||||||
"ticket-template.change",
|
|
||||||
"ticket-template.change",
|
|
||||||
"ticket-template.change",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves BIGINT help-ticket IDs, outputs, and every legacy refresh", async () => {
|
|
||||||
const ticketId = 9_007_199_254_740_993n;
|
|
||||||
await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
|
|
||||||
await call(closeHelpCenterTicket, { ticketId });
|
|
||||||
await call(reopenHelpCenterTicket, { ticketId });
|
|
||||||
await expect(call(liftBanFromHelpTicket, { ticketId })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: { removed: 2, userId: 7 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
|
|
||||||
{ ticketId: "9007199254740993", content: "Handled" },
|
|
||||||
{ ticketId: "9007199254740993", status: "close" },
|
|
||||||
{ ticketId: "9007199254740993", status: "reopen" },
|
|
||||||
{ ticketId: "9007199254740993" },
|
|
||||||
]);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
|
||||||
"/ase/people/support/help-tickets",
|
|
||||||
);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
|
||||||
"/ase/people/support/help-tickets/9007199254740993",
|
|
||||||
);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith(
|
|
||||||
"/help/tickets/9007199254740993",
|
|
||||||
);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates every CFH and moderation transport action", async () => {
|
|
||||||
await call(assignCfhTicket, { ticketId: 9 });
|
|
||||||
await call(updateCfhState, { ticketId: 9, state: 3 });
|
|
||||||
await call(closeCfhTicket, { ticketId: 9 });
|
|
||||||
await call(quickKick, { userId: 7 });
|
|
||||||
await call(quickMute, { userId: 7, duration: 60 });
|
|
||||||
await call(quickUnmute, { userId: 7 });
|
|
||||||
await call(quickAlert, { userId: 7, message: "Stop" });
|
|
||||||
await call(quickRoomKick, { roomId: 12 });
|
|
||||||
await call(broadcastAlert, { message: "Notice", type: "staff" });
|
|
||||||
|
|
||||||
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
|
|
||||||
"cfh.resolve",
|
|
||||||
"cfh.resolve",
|
|
||||||
"cfh.resolve",
|
|
||||||
"moderation.action",
|
|
||||||
"moderation.action",
|
|
||||||
"moderation.action",
|
|
||||||
"moderation.action",
|
|
||||||
"moderation.action",
|
|
||||||
"moderation.action",
|
|
||||||
]);
|
|
||||||
expect(execute.mock.calls.map((entry) => entry[0].expectedActorId)).toEqual(
|
|
||||||
Array(9).fill(42),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["kick", quickKick, { userId: 7 }],
|
|
||||||
["mute", quickMute, { userId: 7, duration: 60 }],
|
|
||||||
["unmute", quickUnmute, { userId: 7 }],
|
|
||||||
["alert", quickAlert, { userId: 7, message: "Stop" }],
|
|
||||||
["room kick", quickRoomKick, { roomId: 12 }],
|
|
||||||
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
|
|
||||||
] as const)(
|
|
||||||
"maps a non-ok %s service result to the historical legacy failure boundary",
|
|
||||||
async (_label, action, input) => {
|
|
||||||
execute.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "DEPENDENCY_UNAVAILABLE",
|
|
||||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
||||||
},
|
|
||||||
correlationId: "quick-action-failure",
|
|
||||||
});
|
|
||||||
await expect(call(action, input)).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Could not execute moderation action",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("maps a thrown moderation service failure instead of reporting success", async () => {
|
|
||||||
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
|
|
||||||
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "RCON unavailable",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
9_007_199_254_740_992,
|
|
||||||
"01",
|
|
||||||
"0",
|
|
||||||
0,
|
|
||||||
-1,
|
|
||||||
"18446744073709551616",
|
|
||||||
] as const)(
|
|
||||||
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
|
|
||||||
async (ticketId) => {
|
|
||||||
for (const [action, input] of [
|
|
||||||
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
|
|
||||||
[closeHelpCenterTicket, { ticketId }],
|
|
||||||
[reopenHelpCenterTicket, { ticketId }],
|
|
||||||
[liftBanFromHelpTicket, { ticketId }],
|
|
||||||
] as const) {
|
|
||||||
await expect(call(action, input)).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Validation failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
expect(execute).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
|
||||||
execute.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
|
|
||||||
correlationId: "missing-cfh",
|
|
||||||
});
|
|
||||||
await expect(call(closeCfhTicket, { ticketId: 404 })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const doubles = vi.hoisted(() => ({
|
|
||||||
canAccess: vi.fn(),
|
|
||||||
getApiAdminContext: vi.fn(),
|
|
||||||
mutationExecute: vi.fn(),
|
|
||||||
reportError: vi.fn(),
|
|
||||||
revalidateTag: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
|
|
||||||
systemMutationService: { execute: doubles.mutationExecute },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
canAccess: doubles.canAccess,
|
|
||||||
getApiAdminContext: doubles.getApiAdminContext,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/admin/authorization-events", () => ({
|
|
||||||
logAuthorizationEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: vi.fn() }));
|
|
||||||
vi.mock("@/lib/report-error", () => ({ reportError: doubles.reportError }));
|
|
||||||
vi.mock("@/lib/foundation/security", () => ({
|
|
||||||
extractClientIpAsync: vi.fn(async () => "198.51.100.8"),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/foundation/request-context", () => ({
|
|
||||||
createStore: vi.fn(() => ({})),
|
|
||||||
getRequestId: vi.fn(() => "legacy-permissions-request"),
|
|
||||||
runWithStore: vi.fn((_store: unknown, callback: () => Promise<unknown>) =>
|
|
||||||
callback(),
|
|
||||||
),
|
|
||||||
setContextUserId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidateTag: doubles.revalidateTag }));
|
|
||||||
|
|
||||||
import { deleteRank, setCmsPermissions } from "./permissions";
|
|
||||||
|
|
||||||
const permissions = {
|
|
||||||
has: () => true,
|
|
||||||
hasAny: () => true,
|
|
||||||
hasAll: () => true,
|
|
||||||
isSuperAdmin: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
doubles.canAccess.mockReturnValue(true);
|
|
||||||
doubles.getApiAdminContext.mockResolvedValue({
|
|
||||||
session: {
|
|
||||||
expires: "2099-01-01T00:00:00.000Z",
|
|
||||||
user: {
|
|
||||||
id: 42,
|
|
||||||
name: "operator",
|
|
||||||
username: "operator",
|
|
||||||
rank: 7,
|
|
||||||
look: "hd-180-1",
|
|
||||||
mail: "[email protected]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
permissions,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy permission action error parity", () => {
|
|
||||||
it("sanitizes typed infrastructure failure through the real adminAction boundary", async () => {
|
|
||||||
doubles.mutationExecute.mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "DEPENDENCY_UNAVAILABLE",
|
|
||||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
||||||
},
|
|
||||||
correlationId: "dependency-correlation",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(deleteRank({ id: 7 })).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Internal server error",
|
|
||||||
fieldErrors: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the established rank-in-use ActionError text", async () => {
|
|
||||||
doubles.mutationExecute.mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "CONFLICT",
|
|
||||||
messageKey: "errors.housekeeping.system.rankInUse",
|
|
||||||
fieldErrors: { rank: ["3"] },
|
|
||||||
},
|
|
||||||
correlationId: "rank-in-use-correlation",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(deleteRank({ id: 7 })).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Cannot delete: 3 users have this rank",
|
|
||||||
fieldErrors: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the established role-not-found ActionError text", async () => {
|
|
||||||
doubles.mutationExecute.mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
messageKey: "errors.housekeeping.system.roleNotFound",
|
|
||||||
},
|
|
||||||
correlationId: "role-not-found-correlation",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
setCmsPermissions({ roleId: 7, permissionSlugs: [] }),
|
|
||||||
).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Role not found",
|
|
||||||
fieldErrors: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+208
-59
@@ -1,56 +1,27 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type SystemMutationContext,
|
AclModelPermission,
|
||||||
type SystemMutationOperation,
|
AclModelRole,
|
||||||
systemMutationService,
|
AclPermission,
|
||||||
} from "@/features/housekeeping/domains/system/services/mutations";
|
AclRole,
|
||||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
db,
|
||||||
import type { AdminActionContext } from "@/lib/foundation/types";
|
User,
|
||||||
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import {
|
||||||
function mutationContext(
|
createEmulatorRank,
|
||||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
deleteEmulatorRank,
|
||||||
): SystemMutationContext {
|
updateEmulatorRank,
|
||||||
return {
|
} from "@/lib/services/permission-ranks";
|
||||||
capability: createHousekeepingCapabilityContext(
|
import { rcon } from "@/lib/services/rcon";
|
||||||
{
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
id: Number(ctx.session.user.id),
|
|
||||||
rank: Number(ctx.session.user.rank),
|
|
||||||
username: ctx.session.user.username,
|
|
||||||
},
|
|
||||||
ctx.permissions,
|
|
||||||
),
|
|
||||||
correlationId: String(ctx.requestId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAccessMutation(
|
|
||||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
|
||||||
operation: SystemMutationOperation,
|
|
||||||
input: unknown,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const result = await systemMutationService.execute(
|
|
||||||
mutationContext(ctx),
|
|
||||||
operation,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
if (result.ok) return result.data;
|
|
||||||
if (result.error.messageKey === "errors.housekeeping.system.rankInUse") {
|
|
||||||
const users = Number(result.error.fieldErrors?.rank?.[0]);
|
|
||||||
if (Number.isInteger(users) && users > 0) {
|
|
||||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result.error.messageKey === "errors.housekeeping.system.roleNotFound") {
|
|
||||||
throw new ActionError("Role not found");
|
|
||||||
}
|
|
||||||
throw new Error(result.error.messageKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRankSchema = z.object({
|
const createRankSchema = z.object({
|
||||||
rank_name: z.string().trim().min(1).max(25),
|
rank_name: z.string().trim().min(1).max(25),
|
||||||
@@ -60,12 +31,25 @@ const createRankSchema = z.object({
|
|||||||
export const createRank = adminAction(
|
export const createRank = adminAction(
|
||||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = (await runAccessMutation(ctx, "access.rank.create", {
|
const id = await createEmulatorRank(db, ctx.data);
|
||||||
name: ctx.data.rank_name,
|
await db
|
||||||
level: ctx.data.level,
|
.insert(AclRole)
|
||||||
})) as { id: number };
|
.values({
|
||||||
|
slug: `rank_${id}`,
|
||||||
|
title: ctx.data.rank_name,
|
||||||
|
description: "CMS role synchronized from permission_ranks",
|
||||||
|
})
|
||||||
|
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: ctx.session.user.id,
|
||||||
|
action: "rank_create",
|
||||||
|
description: `Created rank #${id}`,
|
||||||
|
targetType: "rank",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
await rcon.send("updatepermissions");
|
||||||
revalidateTag("permissions", { expire: 0 });
|
revalidateTag("permissions", { expire: 0 });
|
||||||
return actionOk({ id: result.id });
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,7 +58,41 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteRank = adminAction(
|
export const deleteRank = adminAction(
|
||||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await runAccessMutation(ctx, "access.rank.delete", ctx.data);
|
const [userCount] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(User)
|
||||||
|
.where(eq(User.rank, ctx.data.id));
|
||||||
|
const users = userCount?.total ?? 0;
|
||||||
|
if (users > 0)
|
||||||
|
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||||
|
const [role] = await db
|
||||||
|
.select({ id: AclRole.id })
|
||||||
|
.from(AclRole)
|
||||||
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||||
|
.limit(1);
|
||||||
|
await deleteEmulatorRank(db, ctx.data.id);
|
||||||
|
if (role) {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(AclModelPermission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(AclModelPermission.modelId, role.id),
|
||||||
|
eq(AclModelPermission.modelType, "Role"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
||||||
|
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: ctx.session.user.id,
|
||||||
|
action: "rank_delete",
|
||||||
|
description: `Deleted rank #${ctx.data.id}`,
|
||||||
|
targetType: "rank",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
});
|
||||||
|
await rcon.send("updatepermissions");
|
||||||
revalidateTag("permissions", { expire: 0 });
|
revalidateTag("permissions", { expire: 0 });
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -88,7 +106,21 @@ const saveRankSchema = z.object({
|
|||||||
export const saveRank = adminAction(
|
export const saveRank = adminAction(
|
||||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await runAccessMutation(ctx, "access.rank.update", ctx.data);
|
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
|
||||||
|
if (typeof ctx.data.fields.rank_name === "string") {
|
||||||
|
await db
|
||||||
|
.update(AclRole)
|
||||||
|
.set({ title: ctx.data.fields.rank_name })
|
||||||
|
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
||||||
|
}
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: ctx.session.user.id,
|
||||||
|
action: "rank_update",
|
||||||
|
description: `Updated rank #${ctx.data.id}`,
|
||||||
|
targetType: "rank",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
});
|
||||||
|
await rcon.send("updatepermissions");
|
||||||
revalidateTag("permissions", { expire: 0 });
|
revalidateTag("permissions", { expire: 0 });
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -102,21 +134,138 @@ const setCmsPermsSchema = z.object({
|
|||||||
export const setCmsPermissions = adminAction(
|
export const setCmsPermissions = adminAction(
|
||||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await runAccessMutation(ctx, "access.permissions.update", ctx.data);
|
const [role] = await db
|
||||||
|
.select({ id: AclRole.id, slug: AclRole.slug })
|
||||||
|
.from(AclRole)
|
||||||
|
.where(eq(AclRole.id, ctx.data.roleId))
|
||||||
|
.limit(1);
|
||||||
|
if (!role) throw new ActionError("Role not found");
|
||||||
|
const permissions = await db
|
||||||
|
.select({ id: AclPermission.id })
|
||||||
|
.from(AclPermission)
|
||||||
|
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(AclModelPermission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(AclModelPermission.modelId, role.id),
|
||||||
|
eq(AclModelPermission.modelType, "Role"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (permissions.length) {
|
||||||
|
await tx.insert(AclModelPermission).values(
|
||||||
|
permissions.map((permission) => ({
|
||||||
|
modelId: role.id,
|
||||||
|
modelType: "Role",
|
||||||
|
permissionId: permission.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: ctx.session.user.id,
|
||||||
|
action: "acl_role_permissions_update",
|
||||||
|
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
||||||
|
targetType: "acl_role",
|
||||||
|
targetId: role.id,
|
||||||
|
});
|
||||||
revalidateTag("permissions", { expire: 0 });
|
revalidateTag("permissions", { expire: 0 });
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply the same grant repair as migration 0018:
|
||||||
|
* - ranks with admin.dashboard get all admin.*
|
||||||
|
* - ranks >= 6 get admin.*.view + dashboard
|
||||||
|
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
|
||||||
|
*/
|
||||||
export const repairAdminNavAclGrants = adminAction(
|
export const repairAdminNavAclGrants = adminAction(
|
||||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = (await runAccessMutation(
|
const [dashboardFillResult] = await db.execute(sql`
|
||||||
ctx,
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
"access.permissions.repair",
|
SELECT 'Role', ar.id, ap.id
|
||||||
{},
|
FROM \`acl_roles\` ar
|
||||||
)) as { inserted: number };
|
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp
|
||||||
|
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND apdash.slug = 'admin.dashboard'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp2
|
||||||
|
WHERE amp2.model_type = 'Role'
|
||||||
|
AND amp2.model_id = ar.id
|
||||||
|
AND amp2.permission_id = ap.id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const [midRankViewsResult] = await db.execute(sql`
|
||||||
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM \`permission_ranks\` pr
|
||||||
|
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||||
|
JOIN \`acl_permissions\` ap ON (
|
||||||
|
ap.slug = 'admin.dashboard'
|
||||||
|
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
|
||||||
|
)
|
||||||
|
WHERE pr.id >= 6
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND amp.permission_id = ap.id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const [highRankToolsResult] = await db.execute(sql`
|
||||||
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM \`permission_ranks\` pr
|
||||||
|
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||||
|
JOIN \`acl_permissions\` ap ON (
|
||||||
|
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
|
||||||
|
OR ap.slug IN (
|
||||||
|
'admin.permissions.manage',
|
||||||
|
'admin.rcon.execute',
|
||||||
|
'admin.assets.import',
|
||||||
|
'admin.export',
|
||||||
|
'admin.analytics.export',
|
||||||
|
'admin.users.ban',
|
||||||
|
'admin.users.reset_password',
|
||||||
|
'admin.room.delete'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE pr.id >= 7
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND amp.permission_id = ap.id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const inserted =
|
||||||
|
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
|
||||||
|
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
|
||||||
|
Number((highRankToolsResult as ResultSetHeader).affectedRows);
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: ctx.session.user.id,
|
||||||
|
action: "acl_nav_grants_repair",
|
||||||
|
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
|
||||||
|
targetType: "acl",
|
||||||
|
targetId: 0,
|
||||||
|
});
|
||||||
revalidateTag("permissions", { expire: 0 });
|
revalidateTag("permissions", { expire: 0 });
|
||||||
return actionOk({ inserted: result.inserted });
|
return actionOk({ inserted });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
+66
-65
@@ -3,7 +3,6 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
WebsitePoll,
|
WebsitePoll,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
pollQuestionSchema,
|
pollQuestionSchema,
|
||||||
@@ -25,17 +25,20 @@ import {
|
|||||||
export const createPoll = adminAction(
|
export const createPoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const now = new Date();
|
||||||
{
|
const [result] = await db.insert(WebsitePoll).values({
|
||||||
correlationId: String(ctx.requestId),
|
...ctx.data,
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
updatedAt: now,
|
||||||
legacy: true,
|
});
|
||||||
},
|
const pollId = Number(result.insertId);
|
||||||
"poll.change",
|
logAudit({
|
||||||
{ action: "create", ...ctx.data },
|
userId: ctx.session.user.id,
|
||||||
);
|
action: "poll_create",
|
||||||
if (!result.ok) throw new ActionError("Poll creation failed");
|
target: "WebsitePoll",
|
||||||
return actionOk({ id: Number(result.data.output?.id) });
|
targetId: pollId,
|
||||||
|
after: { title: ctx.data.title },
|
||||||
|
});
|
||||||
|
return actionOk({ id: pollId });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,17 +49,31 @@ const updatePollInput = updatePollSchema.extend({
|
|||||||
export const updatePoll = adminAction(
|
export const updatePoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const { id, ...data } = ctx.data;
|
||||||
{
|
const [existing] = await db
|
||||||
correlationId: String(ctx.requestId),
|
.select({
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
id: WebsitePoll.id,
|
||||||
legacy: true,
|
title: WebsitePoll.title,
|
||||||
},
|
status: WebsitePoll.status,
|
||||||
"poll.change",
|
})
|
||||||
{ action: "update", ...ctx.data },
|
.from(WebsitePoll)
|
||||||
);
|
.where(eq(WebsitePoll.id, id))
|
||||||
if (!result.ok) throw new ActionError("Poll not found");
|
.limit(1);
|
||||||
return actionOk({ id: ctx.data.id });
|
if (!existing) throw new ActionError("Poll not found");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(WebsitePoll)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(WebsitePoll.id, id));
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "poll_update",
|
||||||
|
target: "WebsitePoll",
|
||||||
|
targetId: id,
|
||||||
|
before: { title: existing.title, status: existing.status },
|
||||||
|
after: data,
|
||||||
|
});
|
||||||
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,16 +84,21 @@ const deletePollInput = z.object({
|
|||||||
export const deletePoll = adminAction(
|
export const deletePoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [existing] = await db
|
||||||
{
|
.select({ id: WebsitePoll.id, title: WebsitePoll.title })
|
||||||
correlationId: String(ctx.requestId),
|
.from(WebsitePoll)
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
.where(eq(WebsitePoll.id, ctx.data.id))
|
||||||
legacy: true,
|
.limit(1);
|
||||||
},
|
if (!existing) throw new ActionError("Poll not found");
|
||||||
"poll.change",
|
|
||||||
{ action: "delete", ...ctx.data },
|
await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id));
|
||||||
);
|
logAudit({
|
||||||
if (!result.ok) throw new ActionError("Poll not found");
|
userId: ctx.session.user.id,
|
||||||
|
action: "poll_delete",
|
||||||
|
target: "WebsitePoll",
|
||||||
|
targetId: ctx.data.id,
|
||||||
|
before: { title: existing.title },
|
||||||
|
});
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -86,17 +108,8 @@ export const deletePoll = adminAction(
|
|||||||
export const addPollQuestion = adminAction(
|
export const addPollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
|
||||||
{
|
return actionOk({ id: Number(result.insertId) });
|
||||||
correlationId: String(ctx.requestId),
|
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
|
||||||
legacy: true,
|
|
||||||
},
|
|
||||||
"poll-question.change",
|
|
||||||
{ action: "create", ...ctx.data },
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError("Poll question creation failed");
|
|
||||||
return actionOk({ id: Number(result.data.output?.id) });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,17 +120,12 @@ const updateQuestionInput = pollQuestionSchema.partial().extend({
|
|||||||
export const updatePollQuestion = adminAction(
|
export const updatePollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
const { id, ...data } = ctx.data;
|
||||||
{
|
await db
|
||||||
correlationId: String(ctx.requestId),
|
.update(WebsitePollQuestion)
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
.set(data)
|
||||||
legacy: true,
|
.where(eq(WebsitePollQuestion.id, id));
|
||||||
},
|
return actionOk({ id });
|
||||||
"poll-question.change",
|
|
||||||
{ action: "update", ...ctx.data },
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError("Poll question update failed");
|
|
||||||
return actionOk({ id: ctx.data.id });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -128,16 +136,9 @@ const deleteQuestionInput = z.object({
|
|||||||
export const deletePollQuestion = adminAction(
|
export const deletePollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const result = await contentMutationService.execute(
|
await db
|
||||||
{
|
.delete(WebsitePollQuestion)
|
||||||
correlationId: String(ctx.requestId),
|
.where(eq(WebsitePollQuestion.id, ctx.data.id));
|
||||||
expectedActorId: Number(ctx.session.user.id),
|
|
||||||
legacy: true,
|
|
||||||
},
|
|
||||||
"poll-question.change",
|
|
||||||
{ action: "delete", ...ctx.data },
|
|
||||||
);
|
|
||||||
if (!result.ok) throw new ActionError("Poll question deletion failed");
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
|
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
|
||||||
|
// are not represented in src/db/schema.ts yet — we use parameterized raw SQL.
|
||||||
|
|
||||||
|
// ── Create prefix ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const createPrefixSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
text: z.string().min(1),
|
||||||
|
color: z.string().min(1),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
effect: z.string().optional(),
|
||||||
|
active: z.coerce.number().int().min(0).max(1).default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPrefix = adminAction(
|
||||||
|
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const { username, text, color, icon, effect, active } = ctx.data;
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select({ id: User.id })
|
||||||
|
.from(User)
|
||||||
|
.where(eq(User.username, username))
|
||||||
|
.limit(1);
|
||||||
|
if (!user) throw new ActionError("User not found");
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
|
||||||
|
VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
|
||||||
|
`);
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Update prefix ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const updatePrefixSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
text: z.string().min(1),
|
||||||
|
color: z.string().min(1),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
effect: z.string().optional(),
|
||||||
|
active: z.coerce.number().int().min(0).max(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePrefix = adminAction(
|
||||||
|
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const { id, text, color, icon, effect, active } = ctx.data;
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE custom_prefixes
|
||||||
|
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
|
||||||
|
WHERE id = ${id}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Delete prefix ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deletePrefixSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deletePrefix = adminAction(
|
||||||
|
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await db.execute(
|
||||||
|
sql`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`,
|
||||||
|
);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Add blacklist word ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addBlacklistWordSchema = z.object({
|
||||||
|
word: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addBlacklistWord = adminAction(
|
||||||
|
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
||||||
|
`);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Remove blacklist word ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const removeBlacklistWordSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeBlacklistWord = adminAction(
|
||||||
|
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
await db.execute(
|
||||||
|
sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`,
|
||||||
|
);
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Update prefix settings ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const SETTINGS_WHITELIST = new Set([
|
||||||
|
"enabled",
|
||||||
|
"max_length",
|
||||||
|
"min_rank",
|
||||||
|
"min_rank_to_buy",
|
||||||
|
"allow_colors",
|
||||||
|
"allow_bold",
|
||||||
|
"allow_italic",
|
||||||
|
"default_color",
|
||||||
|
"price_credits",
|
||||||
|
"price_points",
|
||||||
|
"points_type",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updatePrefixSettingsSchema = z.object({
|
||||||
|
settings: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePrefixSettings = adminAction(
|
||||||
|
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
for (const [key, value] of Object.entries(ctx.data.settings)) {
|
||||||
|
if (!SETTINGS_WHITELIST.has(key)) continue;
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
|
||||||
|
VALUES (${key}, ${value})
|
||||||
|
ON DUPLICATE KEY UPDATE \`value\` = ${value}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return actionOk();
|
||||||
|
},
|
||||||
|
);
|
||||||
+85
-19
@@ -1,50 +1,109 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, inArray } 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, Items, Rooms } 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";
|
||||||
|
|
||||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room-item.update", payload);
|
const { roomId, itemId, ...data } = payload as {
|
||||||
const roomId = Number(payload.roomId);
|
roomId: number;
|
||||||
revalidatePath(`/ase/hotel/rooms/${roomId}/furni`);
|
itemId: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
await db
|
||||||
|
.update(Items)
|
||||||
|
.set(data as Partial<typeof Items.$inferInsert>)
|
||||||
|
.where(eq(Items.id, itemId));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "room_item_update",
|
||||||
|
description: `Updated item #${itemId} in room #${roomId}`,
|
||||||
|
targetType: "room_item",
|
||||||
|
targetId: itemId,
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeleteRoomItems(input: {
|
export async function bulkDeleteRoomItems({
|
||||||
|
roomId,
|
||||||
|
itemIds,
|
||||||
|
}: {
|
||||||
roomId: number;
|
roomId: number;
|
||||||
itemIds: number[];
|
itemIds: number[];
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
|
await db
|
||||||
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
|
.delete(Items)
|
||||||
|
.where(and(inArray(Items.id, itemIds), eq(Items.roomId, roomId)));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "room_items_bulk_delete",
|
||||||
|
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
|
||||||
|
targetType: "room_item",
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRoomItem(input: {
|
export async function deleteRoomItem({
|
||||||
|
roomId,
|
||||||
|
itemId,
|
||||||
|
}: {
|
||||||
roomId: number;
|
roomId: number;
|
||||||
itemId: number;
|
itemId: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room-item.delete", input);
|
await db.delete(Items).where(eq(Items.id, itemId));
|
||||||
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "room_item_delete",
|
||||||
|
description: `Deleted item #${itemId} from room #${roomId}`,
|
||||||
|
targetType: "room_item",
|
||||||
|
targetId: itemId,
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function roomRconAction(input: {
|
export async function roomRconAction({
|
||||||
|
roomId,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
roomId: number;
|
roomId: number;
|
||||||
action: string;
|
action: string;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room.runtime", input);
|
if (action === "reload") {
|
||||||
|
await rcon.send("reloadroom", { room_id: roomId });
|
||||||
|
} else if (action === "kick") {
|
||||||
|
await rcon.send("kickall", { room_id: roomId });
|
||||||
|
} else if (action === "lock") {
|
||||||
|
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||||
|
} else if (action === "unlock") {
|
||||||
|
await rcon.send("updateroom", { room_id: roomId, state: "open" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRoom(input: { id: number }) {
|
export async function deleteRoom({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||||
await executeLegacyHotelMutation(staff, "room.delete", input);
|
await db.delete(Rooms).where(eq(Rooms.id, id));
|
||||||
revalidatePath("/ase/hotel/rooms");
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "room_delete",
|
||||||
|
description: `Deleted room #${id}`,
|
||||||
|
targetType: "room",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rooms");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRoom(input: {
|
export async function updateRoom({
|
||||||
|
id,
|
||||||
|
...data
|
||||||
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -52,6 +111,13 @@ export async function updateRoom(input: {
|
|||||||
usersMax?: number;
|
usersMax?: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await executeLegacyHotelMutation(staff, "room.update", input);
|
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
|
||||||
revalidatePath(`/ase/hotel/rooms/${input.id}`);
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "room_update",
|
||||||
|
description: `Updated room #${id}`,
|
||||||
|
targetType: "room",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/rooms/${id}`);
|
||||||
}
|
}
|
||||||
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user