Compare commits

..
10 Commits
Author SHA1 Message Date
openhands 9a8c721111 fix: add avatar imager cache-buster for instant clothing updates
CI / check (push) Failing after 18s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
2026-09-10 21:42:30 +02:00
openhands 75eada7a59 Replace DragonflyDB with Valkey throughout codebase
CI / check (push) Failing after 17s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
- Update all DragonflyDB references to Valkey in README and docker-compose.yml
- Update install instructions to use Valkey package repository and .deb download
- Update configuration paths from /etc/dragonfly/ to /etc/valkey/
- Update version requirement to Valkey 8.x+ (successor to Redis OSS)
2026-09-10 17:52:47 +02:00
openhands cea88eaa57 feat(catalog): repair page hierarchy cycles and duplicate sibling page order
The Arcturus errors "page hierarchy contains a cycle page 354 and 357" and
"sibling order 1 is used more than once (111 problems)" come from
catalog_pages, not catalog_items: pages 354/357 point at themselves, and
many parents have child pages sharing the same order_num. Extend the
emulator catalog scan + fix to detect both: pages whose parent chain loops
back get detached (parent_id = 0 on the highest cycle member) and every
affected parent's children are renumbered sequentially, preserving their
current relative order.

Add scripts/diag-emulator.ts to inspect the live catalog state.
2026-09-10 11:50:27 +02:00
openhands acbe54fcb7 style: apply biome lint fixes to catalog files and live repair tests 2026-09-10 11:32:20 +02:00
openhands 3e69b72513 feat(catalog): prevent and repair Arcturus emulator data errors
Block invalid catalog_items writes at the API level (points currency
allowlist, non-negative prices, positive amount, limited stack >= sold
count, unique sibling order numbers) and auto-assign unique order numbers
on bulk create. Add a catalog-maintenance scan + transactional repair that
fixes pre-existing rows: resets unsupported points_type, clamps negative
costs, sets amount to 1, raises limited_stack, renumbers duplicate orders
and deletes offers with missing page/item references. Surface the issue
count and a fix button in the admin maintenance panel.

Also: add enabled/retired flag to clone sources, classify poster and
currency furniture in item-kind, and remove the obsolete update-Nitrov3.sh.
2026-09-10 11:29:28 +02:00
Simo ad65d80d41 fix(ci): format catalog repair audit write 2026-09-09 22:01:40 +02:00
Simo 1ef405be0a feat(cms): add recovery history and operational reliability tools 2026-09-09 21:57:56 +02:00
Simo 89526af344 feat(hk): expand search and add operational user overview 2026-09-09 21:14:10 +02:00
Simo 27447ce029 feat(docker): add guided install and saved one-command updates 2026-09-09 20:49:05 +02:00
Simo 8dc187483c fix(ci): verify registry upload against exported OCI config 2026-09-09 20:33:14 +02:00
152 changed files with 28213 additions and 4440 deletions

No files matched your search

+4
View File
@@ -36,3 +36,7 @@ db_backup_*.sql
*.log *.log
.codex .codex
.agents .agents
.docker-install
.docker-install.tmp.*
.env.install.*
+3 -2
View File
@@ -26,9 +26,10 @@ APP_URL=http://localhost:3002
AUTH_URL=http://localhost:3002 AUTH_URL=http://localhost:3002
# --- IMAGER --- # --- IMAGER ---
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging # Avatar imager: Polaris-imager (avatar-imaging-pixinode) serves /avatarimage on 8082.
IMAGING_UPSTREAM_URL=http://127.0.0.1:8082/avatarimage
# Runtime values: changing these only requires recreating the container. # Runtime values: changing these only requires recreating the container.
IMAGER_URL=http://127.0.0.1:3030/imaging IMAGER_URL=http://127.0.0.1:8082/avatarimage
BADGE_URL=/swf/c_images/album1584 BADGE_URL=/swf/c_images/album1584
# Legacy NEXT_PUBLIC_IMAGER_URL / NEXT_PUBLIC_BADGE_URL are still read at runtime. # Legacy NEXT_PUBLIC_IMAGER_URL / NEXT_PUBLIC_BADGE_URL are still read at runtime.
+5
View File
@@ -45,3 +45,8 @@ coverage/
test-results/ test-results/
playwright-report/ playwright-report/
blob-report/ blob-report/
# Local Docker installation metadata
.docker-install
.docker-install.tmp.*
.env.install.*
+69 -18
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "${1:-help}" in
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
update) shift; [[ $# = 0 ]] || { echo "Usage: bash cms update" >&2; exit 1; }; exec bash "$DIR/scripts/docker-update.sh" ;;
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using the saved installation settings' ;;
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
esac
+14 -84
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
gitlab.epicnabbo.nl/simo/epicnext-cms
+33
View File
@@ -81,3 +81,36 @@ Validation: 1,506 tests passed (six skipped), type checking, lint, translation
contracts and a production build with fixture configuration. A browser fixture contracts and a production build with fixture configuration. A browser fixture
verified the real dialog at 1280 and 390 pixels; server actions were simulated. verified the real dialog at 1280 and 390 pixels; server actions were simulated.
The new database migration and package publication have not run in production. The new database migration and package publication have not run in production.
## Housekeeping search and user overview
The existing global search now includes furniture, normal/Club catalog categories
and both ticket sources. Results respect module permissions, accept single-digit
IDs, and remain usable when one source fails. Keyboard navigation and cancellation
prevent stale search responses from replacing newer results.
User details open on an operational overview with up to five records from each
authorized source: bans, active mute, support tickets, help tickets, reports,
payments, catalog purchases and audit activity. Failed sources are distinguished
from empty results. User detail and edit pages also apply log permissions before
loading activity and exposing counters.
Italian and Dutch navigation, user management, news and support labels were
reviewed. The new search and overview copy has English, Italian and Dutch text;
other locales receive English fallback strings. This is a focused editorial pass,
not a full translation of every CMS page. No database migration or dependency
change is required. Browser checks use real components with simulated data at
1280 and 390 pixels; production database behavior still needs deployment validation.
## Operational reliability and recovery
- Audit history now records category settings (normal/Club), individual/bulk offer prices, update-mode package publication and news edits inside the write transaction. The audit screen previews and restores individual changes after locking and comparing the current recorded fields. Deleted records, hierarchy changes, LTD counters, imports and historical entries without complete snapshots cannot be restored. Restores create their own history entry. Apply migration `0028_history_snapshots.sql` before running this version: it widens audit snapshots to MEDIUMTEXT without deleting existing data.
- `/admin/operations` reuses durable import jobs, stable history pagination and owner-scoped failed-item retries. A deterministic child ID prevents duplicate retries. The shared Git export queue displays actual pending/running state and latest result; synchronous/SSE synchronization remains linked rather than represented as a durable job history.
- Catalog maintenance includes a read-only integrity report for normal/Club categories, offers, furniture references and local icons. Known sentinel IDs are preserved. Only categories pointing to a missing positive parent have an automated repair: preview lists every affected category and apply compares the locked graph before reattaching those categories at the root. No records are deleted. Other issues require an explicit manual edit. Reports display up to 200 issues with complete counts; unavailable icon storage is distinguished from missing assets.
- CMS errors support exact release and time filters plus frequency sorting. Counts refer to retained matching events, while group resolution remains current across releases.
- User/settings forms now protect unsaved edits and preserve failed submissions. User/news validation errors appear at the affected fields; settings show returned validation errors inline. Existing submission locking is retained and tested in a browser.
- `/admin/permissions/preview` shows one role's section access and known CMS grants using live ACL and the existing highest-rank policy. It never changes sessions. Additional user roles, navigation customization and record-specific authorization remain explicit limits of the preview.
New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel.
Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1788791201804,
"tag": "0000_premium_spirit",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
-- Preserve complete before/after snapshots for long news articles.
ALTER TABLE admin_audit_log MODIFY COLUMN `before` MEDIUMTEXT NULL, MODIFY COLUMN `after` MEDIUMTEXT NULL;
-49
View File
@@ -1,49 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "helpers:disableTypesNodeMajor"],
"labels": ["dependencies"],
"rangeStrategy": "bump",
"dependencyDashboard": true,
"dependencyDashboardTitle": "Dependency Dashboard",
"commitMessagePrefix": "chore(deps):",
"commitMessageAction": "update",
"schedule": ["at any time"],
"timezone": "Europe/Amsterdam",
"rebaseWhen": "conflicted",
"prCreation": "immediate",
"prConcurrentLimit": 5,
"packageRules": [
{
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "Development tools",
"automerge": false
},
{
"matchPackageNames": [
"@base-ui/react",
"lucide-react",
"motion",
"@dnd-kit/**"
],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "Interface libraries",
"automerge": false
},
{
"matchPackageNames": ["vitest", "@vitest/coverage-v8"],
"groupName": "Vitest",
"automerge": false
},
{
"matchUpdateTypes": ["major"],
"automerge": false
},
{
"matchPackageNames": ["node", "pnpm"],
"enabled": false
}
],
"minimumReleaseAge": "3 days",
"automerge": false
}
+32
View File
@@ -0,0 +1,32 @@
import "./load-env";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
async function main() {
const [dp] = (await db.execute(sql`
SELECT CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, COUNT(*) c, GROUP_CONCAT(id) ids
FROM catalog_pages
GROUP BY parent_id, order_num
HAVING COUNT(*) > 1
`)) as unknown as [Array<Record<string, unknown>>, unknown];
console.log("=== catalog_pages duplicate (parent, order_num) groups ===");
console.log(JSON.stringify(dp));
const [children] = (await db.execute(sql`
SELECT CAST(parent_id AS CHAR) parentId, COUNT(*) c
FROM catalog_pages
WHERE parent_id <> 0
GROUP BY parent_id ORDER BY c DESC LIMIT 8
`)) as unknown as [Array<Record<string, unknown>>, unknown];
console.log("=== most children per parent ===");
console.log(JSON.stringify(children));
const [pages] = (await db.execute(sql`
SELECT CAST(id AS CHAR) id, CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, caption_save caption, enabled
FROM catalog_pages WHERE id IN (353,354,355,356,357,358)
`)) as unknown as [Array<Record<string, unknown>>, unknown];
console.log("=== pages 353-358 ===");
console.log(JSON.stringify(pages, null, 1));
await db.$client?.end?.();
}
void main();
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Read installation metadata as data, never execute it as a shell script.
load_docker_config() {
local root="$1" key value mode="" public_url="" repository=""
[[ -f "$root/.docker-install" ]] || return 0
while IFS='=' read -r key value || [[ -n "$key" ]]; do
value="${value%$'\r'}"
case "$key" in
''|'#'*) continue ;;
MODE) [[ "$value" = prebuilt || "$value" = source ]] || return 1; mode="$value" ;;
PUBLIC_URL) [[ "$value" =~ ^https?://[^[:space:]]+$ && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || return 1; public_url="$value" ;;
*) echo "Unknown installation setting: $key" >&2; return 1 ;;
esac
done < "$root/.docker-install"
[[ -n "$mode" && -n "$public_url" ]] || return 1
if [[ "$mode" = prebuilt && ! ${CMS_IMAGE_REPOSITORY+x} ]]; then
IFS= read -r repository < "$root/docker-image.txt"
repository="${repository%$'\r'}"
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || return 1
export CMS_IMAGE_REPOSITORY="$repository"
fi
CMS_INSTALL_MODE="$mode"
export CMS_PUBLIC_URL="${CMS_PUBLIC_URL-$public_url}"
}
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -69,6 +69,9 @@ if [ "$script_before" != "$(git hash-object scripts/docker-update.sh)" ]; then
exec 9>&- exec 9>&-
exec bash "$DIR/scripts/docker-update.sh" exec bash "$DIR/scripts/docker-update.sh"
fi fi
# Load after pulling so image location changes follow the repository.
source "$DIR/scripts/docker-config.sh"
load_docker_config "$DIR" || die "Invalid .docker-install: expected MODE=prebuilt or source and PUBLIC_URL=http(s)://your-host."
export CMS_RELEASE="$(git rev-parse HEAD)" export CMS_RELEASE="$(git rev-parse HEAD)"
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit." [[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
[[ -f .env ]] || die "Create .env before installing or updating." [[ -f .env ]] || die "Create .env before installing or updating."
@@ -82,16 +85,16 @@ if [[ -n "$previous_container" ]]; then
rollback_tag="rollback-$CMS_RELEASE-$$" rollback_tag="rollback-$CMS_RELEASE-$$"
docker image tag "$previous_image" "epicnext-cms:$rollback_tag" docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
fi fi
log "Building release $CMS_RELEASE from $DIR" log "Preparing release $CMS_RELEASE from $DIR"
# The migrations stage contains matching source and locked dependencies. # The migrations stage contains matching source and locked dependencies.
# No Node/package manager installation on the host is required. # No Node/package manager installation on the host is required.
migration_image="epicnext-cms-migrations:$CMS_RELEASE" migration_image="epicnext-cms-migrations:$CMS_RELEASE"
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
[[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag." [[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag."
log "Pulling prebuilt application and matching migrations for $CMS_RELEASE" log "Pulling prebuilt application and matching migrations for $CMS_RELEASE"
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1 docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Application image unavailable. Publication for this commit may still be running; retry bash cms update after CI succeeds. For private packages, log in to the registry first."
remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations"
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1 docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1 || die "Matching migrations image unavailable. Current CMS is unchanged; retry after publication succeeds."
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE" docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE"
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image" docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image"
else else
+8 -4
View File
@@ -58,12 +58,16 @@ regctl registry set "$registry" --blob-chunk 8388608 --blob-max 8388608
for target in "$image-migrations" "$image"; do for target in "$image-migrations" "$image"; do
echo "Publishing $target with blob requests up to 8 MiB" echo "Publishing $target with blob requests up to 8 MiB"
docker image save --output "$context/image.tar" "$target" docker image save --output "$context/image.tar" "$target"
regctl image import "$target" "$context/image.tar" # Normalize the saved archive locally before copying it unchanged to Gitea.
# Import may change compression/manifest representation, but the immutable # Docker engine IDs and OCI index IDs are not interchangeable with config IDs.
# image config digest must still match the exact local image we verified. local_ref="ocidir://$context/oci:verified"
expected_config="$(docker image inspect --format '{{.Id}}' "$target")" regctl image import "$local_ref" "$context/image.tar"
expected_config="$(regctl manifest get "$local_ref" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
[[ "$expected_config" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid local image config digest" >&2; exit 1; }
regctl image copy "$local_ref" "$target"
remote_config="$(regctl manifest get "$target" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')" remote_config="$(regctl manifest get "$target" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
[[ "$remote_config" = "$expected_config" ]] || { echo "Published image config does not match verified local image" >&2; exit 1; } [[ "$remote_config" = "$expected_config" ]] || { echo "Published image config does not match verified local image" >&2; exit 1; }
rm -f -- "$context/image.tar" rm -f -- "$context/image.tar"
rm -rf -- "$context/oci"
done done
echo "Published application and migrations: $image" echo "Published application and migrations: $image"
+4
View File
@@ -8,6 +8,10 @@ const state = vi.hoisted(() => ({
log: vi.fn(() => "news-error-1"), log: vi.fn(() => "news-error-1"),
notify: vi.fn(), notify: vi.fn(),
})); }));
vi.mock("@/features/history/server", () => ({
historySnapshot: async () => ({}),
recordHistory: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ vi.mock("@/lib/admin/guard", () => ({
requirePermission: async () => ({ id: 1, username: "staff" }), requirePermission: async () => ({ id: 1, username: "staff" }),
})); }));
+13 -2
View File
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor"; import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { articleEditToken } from "@/lib/article-edit-token"; import { articleEditToken } from "@/lib/article-edit-token";
import { import {
ArticleInputError, ArticleInputError,
type ArticleSaveResult, type ArticleSaveResult,
articleInputField,
readArticleInput, readArticleInput,
} from "@/lib/article-input"; } from "@/lib/article-input";
import { import {
@@ -53,8 +55,14 @@ async function saveFailure(
operation: string, operation: string,
): Promise<ArticleSaveResult> { ): Promise<ArticleSaveResult> {
const t = await getTranslations("pages.admin.articles.form"); const t = await getTranslations("pages.admin.articles.form");
if (error instanceof ArticleInputError) if (error instanceof ArticleInputError) {
return { ok: false, error: t(error.code) }; const field = articleInputField(error.code);
return {
ok: false,
error: t(error.code),
...(field ? { fieldErrors: { [field]: [t(error.code)] } } : {}),
};
}
const reference = logger.error("News save failed", { const reference = logger.error("News save failed", {
module: "news", module: "news",
operation, operation,
@@ -124,6 +132,8 @@ export async function updateArticle(
if (!existing) throw new ArticleInputError("articleNotFound"); if (!existing) throw new ArticleInputError("articleNotFound");
if (formData.get("baseToken") !== articleEditToken(existing)) if (formData.get("baseToken") !== articleEditToken(existing))
throw new ArticleInputError("editConflict"); throw new ArticleInputError("editConflict");
const historyId = String(id);
const historyBefore = await historySnapshot(tx, "news", historyId);
await tx.insert(ArticleRevisions).values({ await tx.insert(ArticleRevisions).values({
articleId: id, articleId: id,
userId: staff.id, userId: staff.id,
@@ -158,6 +168,7 @@ export async function updateArticle(
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(WebsiteArticles.id, id)); .where(eq(WebsiteArticles.id, id));
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
}); });
} catch (error) { } catch (error) {
return saveFailure(error, "update"); return saveFailure(error, "update");
+3 -3
View File
@@ -75,7 +75,7 @@ export async function updateBcPage({
return { ok: false as const, error: "No valid fields to update" }; return { ok: false as const, error: "No valid fields to update" };
} }
try { try {
await updatePageCommand("bc", id, data, expected); await updatePageCommand("bc", id, data, expected, staff.id);
} catch (error) { } catch (error) {
return { ok: false as const, error: catalogFailure(error).message }; return { ok: false as const, error: catalogFailure(error).message };
} }
@@ -176,9 +176,9 @@ export async function toggleBcPage({
id: number; id: number;
field: "enabled" | "visible"; field: "enabled" | "visible";
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => { return await withCatalogExport(async () => {
await togglePageCommand("bc", id, field); await togglePageCommand("bc", id, field, staff.id);
await sendCatalogUpdate(); await sendCatalogUpdate();
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
return { ok: true as const }; return { ok: true as const };
+1 -1
View File
@@ -28,7 +28,7 @@ export async function applyBulkOffers(
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
try { try {
return await withCatalogExport(async () => { return await withCatalogExport(async () => {
const data = await applyBulkOffersCommand(input, fingerprint); const data = await applyBulkOffersCommand(input, fingerprint, staff.id);
if (data.changedCount > 0) { if (data.changedCount > 0) {
await logAudit({ await logAudit({
userId: staff.id, userId: staff.id,
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
guard: vi.fn(),
inspect: vi.fn(),
preview: vi.fn(),
apply: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/admin/guard", () => ({
requirePermissionRateLimited: mocks.guard,
}));
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data: unknown) => ({ ok: true, data }),
actionError: (error: string) => ({ ok: false, error }),
handleActionError: () => ({ ok: false, error: "denied" }),
}));
vi.mock("@/lib/services/catalog-audit", () => ({
inspectLiveCatalogIntegrity: mocks.inspect,
}));
vi.mock("@/lib/services/catalog-repair", () => ({
previewCatalogParentRepair: mocks.preview,
applyCatalogParentRepair: mocks.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: (fn: () => unknown) => fn(),
}));
import { PERMS } from "@/lib/permission-slugs";
import {
applyCatalogIntegrityRepairAction,
inspectCatalogIntegrityAction,
previewCatalogIntegrityRepairAction,
} from "./catalog-integrity";
beforeEach(() => {
vi.clearAllMocks();
mocks.guard.mockResolvedValue({ id: 7 });
mocks.apply.mockResolvedValue({ applied: 1 });
});
describe("integrity action permissions", () => {
it("allows inspection only after catalog view and preview only after catalog edit", async () => {
await inspectCatalogIntegrityAction("normal");
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
await previewCatalogIntegrityRepairAction("bc");
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
expect(mocks.preview).toHaveBeenCalledWith("bc");
});
it("denies apply before reading or mutating data without edit permission", async () => {
mocks.guard.mockRejectedValue(new Error("denied"));
expect(await applyCatalogIntegrityRepairAction("normal", "abc")).toEqual({
ok: false,
error: "denied",
});
expect(mocks.guard).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
expect(mocks.apply).not.toHaveBeenCalled();
});
it("forwards actor identity and translates changed state to a recoverable conflict", async () => {
const error = new Error("changed");
error.name = "CatalogPreviewConflict";
mocks.apply.mockRejectedValue(error);
expect(
await applyCatalogIntegrityRepairAction("bc", "fingerprint"),
).toEqual({ ok: false, error: "stalePreview" });
expect(mocks.apply).toHaveBeenCalledWith("bc", "fingerprint", 7);
});
it("rejects unknown catalog names before executing queries", async () => {
await inspectCatalogIntegrityAction("catalog_pages; DELETE");
expect(mocks.inspect).not.toHaveBeenCalled();
});
});
+60
View File
@@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
actionError,
actionOk,
handleActionError,
} from "@/lib/safe-action-shared";
import { inspectLiveCatalogIntegrity } from "@/lib/services/catalog-audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { integrityCatalog } from "@/lib/services/catalog-integrity";
import {
applyCatalogParentRepair,
previewCatalogParentRepair,
} from "@/lib/services/catalog-repair";
export async function inspectCatalogIntegrityAction(catalog: unknown) {
try {
await requirePermissionRateLimited(PERMS.CATALOG_VIEW);
return actionOk(
await inspectLiveCatalogIntegrity(integrityCatalog(catalog)),
);
} catch (error) {
return handleActionError(error);
}
}
export async function previewCatalogIntegrityRepairAction(catalog: unknown) {
try {
await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
return actionOk(
await previewCatalogParentRepair(integrityCatalog(catalog)),
);
} catch (error) {
return handleActionError(error);
}
}
export async function applyCatalogIntegrityRepairAction(
catalog: unknown,
fingerprint: unknown,
) {
try {
const staff = await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
const kind = integrityCatalog(catalog);
if (typeof fingerprint !== "string") return actionError("Invalid preview");
const result = await withCatalogExport(() =>
applyCatalogParentRepair(kind, fingerprint, staff.id),
);
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return actionOk(result);
} catch (error) {
if (error instanceof Error && error.name === "CatalogPreviewConflict")
return { ok: false as const, error: "stalePreview" as const };
return handleActionError(error);
}
}
+8 -2
View File
@@ -144,6 +144,12 @@ export async function bulkCreateCatalogItems({
let created = 0; let created = 0;
let failed = 0; let failed = 0;
// Find the next order number on the target page to avoid collisions.
const [maxOrder] = (await db.execute(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`)) as unknown as [{ maxOrder: number }, unknown];
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1;
for (const row of rows) { for (const row of rows) {
const base = baseMap.get(row.baseId); const base = baseMap.get(row.baseId);
if (!base) { if (!base) {
@@ -161,7 +167,7 @@ export async function bulkCreateCatalogItems({
amount: 1, amount: 1,
limitedSells: 0, limitedSells: 0,
limitedStack: 0, limitedStack: 0,
orderNumber: 1, orderNumber: nextOrder++,
offerId: -1, offerId: -1,
songId: 0, songId: 0,
haveOffer: "1", haveOffer: "1",
@@ -251,7 +257,7 @@ export async function updateCatalogItem({
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => { return await withCatalogExport(async () => {
try { try {
await updateOfferCommand({ id, catalogFields, baseItem }); await updateOfferCommand({ id, catalogFields, baseItem }, staff.id);
} catch (error) { } catch (error) {
return { return {
ok: false as const, ok: false as const,
+1 -1
View File
@@ -65,7 +65,7 @@ export async function publishCatalogPackage(input: PublishCatalogPackageInput) {
const warnings: string[] = []; const warnings: string[] = [];
try { try {
await withCatalogExport(async () => { await withCatalogExport(async () => {
committed = await publishCatalogPackageCommand(input); committed = await publishCatalogPackageCommand(input, staff.id);
return committed; return committed;
}); });
} catch (error) { } catch (error) {
+3 -2
View File
@@ -67,7 +67,7 @@ export async function updateCatalogPage({
data.captionSave = data.caption.slice(0, 25); data.captionSave = data.caption.slice(0, 25);
} }
try { try {
await updatePageCommand("normal", id, data, expected); await updatePageCommand("normal", id, data, expected, staff.id);
} catch (error) { } catch (error) {
return { ok: false as const, error: catalogFailure(error).message }; return { ok: false as const, error: catalogFailure(error).message };
} }
@@ -108,12 +108,13 @@ export async function toggleCatalogPage({
id: number; id: number;
action: "toggleEnabled" | "toggleVisible"; action: "toggleEnabled" | "toggleVisible";
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => { return await withCatalogExport(async () => {
await togglePageCommand( await togglePageCommand(
"normal", "normal",
id, id,
action === "toggleEnabled" ? "enabled" : "visible", action === "toggleEnabled" ? "enabled" : "visible",
staff.id,
); );
await sendCatalogUpdate(); await sendCatalogUpdate();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
+14
View File
@@ -11,6 +11,7 @@ import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import type { import type {
AlignResult, AlignResult,
DedupResult, DedupResult,
FixEmulatorResult,
FixOfferResult, FixOfferResult,
FixSpriteResult, FixSpriteResult,
FurniHealth, FurniHealth,
@@ -60,6 +61,19 @@ export async function fixCatalogOffersAction(): Promise<
} }
} }
export async function fixEmulatorIssuesAction(): Promise<
ActionResult<FixEmulatorResult>
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixEmulatorIssues());
});
} catch (e) {
return handleActionError(e);
}
}
export async function reconcileIdsAction(): Promise< export async function reconcileIdsAction(): Promise<
ActionResult<ReconcileResult> ActionResult<ReconcileResult>
> { > {
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
guard: vi.fn(),
apply: vi.fn(),
snapshot: vi.fn(),
entry: {
kind: "prices",
targetId: 1,
before: { costCredits: 1 },
after: { costCredits: 2 },
},
}));
vi.mock("@/lib/db", () => ({
db: {
transaction: async (callback: (tx: object) => unknown) => callback({}),
},
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.guard }));
vi.mock("@/lib/permissions", () => ({
PERMS: { LOGS_VIEW: "logs", CATALOG_EDIT: "catalog", NEWS_EDIT: "news" },
}));
vi.mock("@/features/history/server", () => ({
readHistory: async () => state.entry,
historySnapshot: state.snapshot,
applyHistory: state.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: async (fn: () => unknown) => fn(),
}));
vi.mock("@/lib/services/news-cache", () => ({ invalidateNewsCache: vi.fn() }));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: vi.fn(),
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { previewHistoryRestore, restoreHistory } from "./history";
beforeEach(() => {
vi.clearAllMocks();
state.guard.mockResolvedValue({ id: 7 });
state.snapshot.mockResolvedValue(state.entry.after);
state.apply.mockResolvedValue(undefined);
state.entry.kind = "prices";
});
it("requires logs access and catalog edit on preview and restore", async () => {
await previewHistoryRestore(1);
await restoreHistory(1);
expect(state.guard.mock.calls.map((call) => call[0])).toEqual([
"logs",
"catalog",
"logs",
"catalog",
]);
});
it("requires news edit for news history", async () => {
state.entry.kind = "news";
await restoreHistory(1);
expect(state.guard).toHaveBeenCalledWith("news");
});
it("rejects forbidden restore before any mutation", async () => {
state.guard.mockRejectedValue(Error("Forbidden"));
await expect(restoreHistory(1)).rejects.toThrow("Forbidden");
expect(state.apply).not.toHaveBeenCalled();
});
it("returns conflict when current data changed after preview", async () => {
await previewHistoryRestore(1);
state.apply.mockRejectedValue(Error("conflict"));
await expect(restoreHistory(1)).resolves.toEqual({
ok: false,
code: "conflict",
});
});
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { historyChanges, sameSnapshot } from "@/features/history/model";
import {
applyHistory,
historySnapshot,
readHistory,
} from "@/features/history/server";
import { requirePermission } from "@/lib/admin/guard";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { invalidateNewsCache } from "@/lib/services/news-cache";
async function authorized(id: number) {
await requirePermission(PERMS.LOGS_VIEW);
if (!Number.isSafeInteger(id) || id < 1) throw Error("unavailable");
const entry = await db.transaction((tx) => readHistory(tx, id));
const staff = await requirePermission(
entry.kind === "news" ? PERMS.NEWS_EDIT : PERMS.CATALOG_EDIT,
);
return { entry, staff };
}
export async function previewHistoryRestore(id: number) {
const { entry } = await authorized(id);
return db.transaction(async (tx) => {
const current = await historySnapshot(tx, entry.kind, entry.targetId);
return {
canRestore: sameSnapshot(current, entry.after),
changes: historyChanges(current, entry.before),
};
});
}
export async function restoreHistory(id: number) {
const { entry, staff } = await authorized(id);
try {
const restore = () =>
db.transaction((tx) => applyHistory(tx, entry, staff.id));
if (entry.kind === "news") await restore();
else
await withCatalogExport(async () => {
await restore();
return { ok: true as const, data: {} };
});
} catch (error) {
if (error instanceof Error && error.message === "conflict")
return { ok: false as const, code: "conflict" as const };
logger.error("History restore failed", { module: "history", error });
return { ok: false as const, code: "failed" as const };
}
try {
if (entry.kind === "news") {
await invalidateNewsCache();
revalidatePath("/news", "layout");
revalidatePath("/admin/articles", "layout");
revalidatePath("/");
revalidatePath("/me");
} else {
await sendCatalogUpdate();
revalidatePath("/admin/catalog", "layout");
}
} catch (error) {
logger.error("History refresh failed", { module: "history", error });
}
revalidatePath("/admin/logs/audit");
return { ok: true as const };
}
+24 -1
View File
@@ -1,4 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel"; import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -8,5 +10,26 @@ export default async function CatalogMaintenancePage() {
redirect("/admin"); redirect("/admin");
} }
return <CatalogMaintenancePanel />; const canRepair = canAccess(
permissions,
PERMS.CATALOG_EDIT,
session.user.rank,
);
const t = await getTranslations("pages.admin.catalog.integrity");
return (
<div className="space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
);
} }
File diff suppressed because it is too large. Load diff
@@ -40,6 +40,7 @@ interface CloneSource {
nitroBaseUrl: string; nitroBaseUrl: string;
iconBaseUrl: string; iconBaseUrl: string;
hotel: string; hotel: string;
enabled?: boolean;
} }
interface FurniItem { interface FurniItem {
@@ -81,6 +82,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? ""); const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? ""); const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
const [hotel, setHotel] = useState(initial?.hotel ?? ""); const [hotel, setHotel] = useState(initial?.hotel ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -91,6 +93,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
nitroBaseUrl, nitroBaseUrl,
iconBaseUrl, iconBaseUrl,
hotel, hotel,
enabled,
}); });
} }
@@ -140,6 +143,15 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
Enabled (retired sources are excluded from counts and imports)
</label>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" size="sm" disabled={saving}> <Button type="submit" size="sm" disabled={saving}>
+9 -1
View File
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HistoryRestore } from "@/features/history/history-restore";
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff"; import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
import type { DataTableColumn, PaginatedResult } from "@/types/common"; import type { DataTableColumn, PaginatedResult } from "@/types/common";
import { AuditFilters } from "./audit-filters"; import { AuditFilters } from "./audit-filters";
@@ -102,7 +103,14 @@ export function AuditTable({ data }: AuditTableProps) {
{ {
key: "diff", key: "diff",
label: t("audit.colDetails"), label: t("audit.colDetails"),
render: (_value, row) => <DiffCell row={row} />, render: (_value, row) => (
<div className="space-y-2">
<DiffCell row={row} />
{["history_update", "history_restore"].includes(row.action) && (
<HistoryRestore id={row.id} />
)}
</div>
),
}, },
{ {
key: "createdAt", key: "createdAt",
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
import { OperationsCenter } from "@/components/admin/operations-center";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function OperationsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank))
redirect("/admin");
return <OperationsCenter />;
}
+8
View File
@@ -1,5 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
@@ -16,6 +18,7 @@ export default async function PermissionsPage({
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("pages.admin.permissionPreview");
const sp = (await searchParams) ?? {}; const sp = (await searchParams) ?? {};
const fromHousekeeping = sp.from === "housekeeping"; const fromHousekeeping = sp.from === "housekeeping";
@@ -36,9 +39,14 @@ export default async function PermissionsPage({
})); }));
return ( return (
<div className="space-y-4">
<Link href="/admin/permissions/preview" className="btn btn-outline">
{t("title")}
</Link>
<PermissionsList <PermissionsList
ranks={ranksWithCounts} ranks={ranksWithCounts}
fromHousekeeping={fromHousekeeping} fromHousekeeping={fromHousekeeping}
/> />
</div>
); );
} }
@@ -0,0 +1,74 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
vi.mock("@/lib/permissions", () => ({
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
getAdminContext: async () => ({
session: { user: { rank: 7 } },
permissions: {},
}),
canAccess: state.access,
}));
vi.mock("next/navigation", () => ({
redirect: () => {
throw Error("redirect");
},
}));
vi.mock("next-intl/server", () => ({
getTranslations: async () => (key: string) => key,
}));
vi.mock("@/components/link", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
import Page from "./page";
beforeEach(() => {
state.access.mockReset().mockReturnValue(true);
state.execute.mockReset();
});
it("rejects before querying role details", async () => {
state.access.mockReturnValue(false);
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
"redirect",
);
expect(state.execute).not.toHaveBeenCalled();
});
it("does not silently substitute an invalid requested role", async () => {
state.execute.mockResolvedValueOnce([
[{ id: 1, slug: "rank_1", title: "User" }],
]);
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ role: "99" }) }),
);
expect(html).toContain("missing");
expect(state.execute).toHaveBeenCalledTimes(1);
});
it("simulates highest-rank override from live rank data", async () => {
state.execute
.mockResolvedValueOnce([[{ id: 10, slug: "rank_8", title: "Owner" }]])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
.mockResolvedValueOnce([[]]);
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
);
expect(html).toContain("superAdmin");
expect(html).toContain("entryAllowed");
});
it("does not infer owner privileges from a title", async () => {
state.execute
.mockResolvedValueOnce([[{ id: 10, slug: "owner", title: "Owner" }]])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
.mockResolvedValueOnce([[]]);
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
);
expect(html).not.toContain("superAdmin");
expect(html).toContain("entryDenied");
});
File diff suppressed because it is too large. Load diff
+21 -3
View File
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import { import {
BOOLEAN_KEYS, BOOLEAN_KEYS,
type ManagedField, type ManagedField,
@@ -45,7 +46,13 @@ export function CmsSettingsForm({
canEdit: boolean; canEdit: boolean;
}) { }) {
const [values, setValues] = useState(initialValues); const [values, setValues] = useState(initialValues);
const { isPending, run } = useServerAction(); const [savedValues, setSavedValues] = useState(initialValues);
const tAction = useTranslations("pages.admin.actions");
const dirty = Object.keys({ ...savedValues, ...values }).some(
(key) => savedValues[key] !== values[key],
);
useUnsavedChanges(canEdit && dirty, tAction("leaveUnsaved"));
const { isPending, run, fieldErrors } = useServerAction();
const t = useTranslations("pages.admin.settings"); const t = useTranslations("pages.admin.settings");
function handleChange(key: string, value: string) { function handleChange(key: string, value: string) {
@@ -55,6 +62,7 @@ export function CmsSettingsForm({
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!canEdit) return; if (!canEdit) return;
const submitted = { ...values };
const normalized = { ...values }; const normalized = { ...values };
for (const key of BOOLEAN_KEYS) { for (const key of BOOLEAN_KEYS) {
normalized[key] = values[key] === "1" ? "1" : "0"; normalized[key] = values[key] === "1" ? "1" : "0";
@@ -62,24 +70,34 @@ export function CmsSettingsForm({
run(() => saveManagedSettings({ settings: normalized }), { run(() => saveManagedSettings({ settings: normalized }), {
successMessage: t("saved"), successMessage: t("saved"),
errorMessage: t("saveFailed"), errorMessage: t("saveFailed"),
onSuccess: () => setSavedValues(submitted),
}); });
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{Object.keys(fieldErrors).length > 0 && (
<div role="alert" className="admin-card text-sm text-destructive">
{Object.values(fieldErrors).flat().join(" · ")}
</div>
)}
{SETTINGS_GROUPS.map((group) => ( {SETTINGS_GROUPS.map((group) => (
<GroupSection <GroupSection
key={group.id} key={group.id}
group={group} group={group}
values={values} values={values}
canEdit={canEdit} canEdit={canEdit && !isPending}
onChange={handleChange} onChange={handleChange}
/> />
))} ))}
{canEdit ? ( {canEdit ? (
<div className="sticky bottom-3 z-10 flex justify-end"> <div className="sticky bottom-3 z-10 flex justify-end">
<Button type="submit" disabled={isPending} className="shadow-lg"> <Button
type="submit"
disabled={isPending || !dirty}
className="shadow-lg"
>
{isPending ? ( {isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -6,6 +6,7 @@ import {
MonitorSmartphone, MonitorSmartphone,
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "@/components/link"; import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -25,40 +26,41 @@ interface UserAccountInfoProps {
} }
export function UserAccountInfo({ user }: UserAccountInfoProps) { export function UserAccountInfo({ user }: UserAccountInfoProps) {
const t = useTranslations("pages.admin.users.presentation");
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Account Details</CardTitle> <CardTitle>{t("accountDetails")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" /> <Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Registered</span> <span className="text-muted-foreground">{t("registered")}</span>
<span className="ml-auto font-medium"> <span className="ml-auto font-medium">
{formatDate(new Date(user.accountCreated * 1000), "date")} {formatDate(new Date(user.accountCreated * 1000), "date")}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Clock className="h-4 w-4 text-muted-foreground shrink-0" /> <Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Last Login</span> <span className="text-muted-foreground">{t("lastLogin")}</span>
<span className="ml-auto font-medium"> <span className="ml-auto font-medium">
{user.lastLogin {user.lastLogin
? formatDate(new Date(user.lastLogin * 1000)) ? formatDate(new Date(user.lastLogin * 1000))
: "Never"} : t("never")}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" /> <Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">IP Register</span> <span className="text-muted-foreground">{t("ipRegister")}</span>
<span className="ml-auto flex items-center gap-1.5"> <span className="ml-auto flex items-center gap-1.5">
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded"> <code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{user.ipRegister} {user.ipRegister}
</code> </code>
<Link <Link
href={`/admin/users?search=${user.ipRegister}`} href={`/admin/users?search=${user.ipRegister}`}
title="Find other users with this IP" title={t("findIp")}
> >
<ExternalLink className="h-3 w-3 text-primary" /> <ExternalLink className="h-3 w-3 text-primary" />
</Link> </Link>
@@ -66,14 +68,14 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" /> <Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">IP Current</span> <span className="text-muted-foreground">{t("ipCurrent")}</span>
<span className="ml-auto flex items-center gap-1.5"> <span className="ml-auto flex items-center gap-1.5">
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded"> <code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{user.ipCurrent} {user.ipCurrent}
</code> </code>
<Link <Link
href={`/admin/users?search=${user.ipCurrent}`} href={`/admin/users?search=${user.ipCurrent}`}
title="Find other users with this IP" title={t("findIp")}
> >
<ExternalLink className="h-3 w-3 text-primary" /> <ExternalLink className="h-3 w-3 text-primary" />
</Link> </Link>
@@ -88,22 +90,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
variant={user.twoFactorConfirmed ? "default" : "outline"} variant={user.twoFactorConfirmed ? "default" : "outline"}
className="ml-auto" className="ml-auto"
> >
{user.twoFactorConfirmed ? "Enabled" : "Disabled"} {user.twoFactorConfirmed ? t("enabled") : t("disabled")}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" /> <MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Machine ID</span> <span className="text-muted-foreground">{t("machineId")}</span>
<code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]"> <code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]">
{user.machine_id || "N/A"} {user.machine_id || t("unavailable")}
</code> </code>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">Email</span> <span className="text-muted-foreground">{t("email")}</span>
<span className="ml-auto font-medium">{user.mail}</span> <span className="ml-auto font-medium">{user.mail}</span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">Gender</span> <span className="text-muted-foreground">{t("gender")}</span>
<Badge variant="outline" className="ml-auto"> <Badge variant="outline" className="ml-auto">
{user.gender} {user.gender}
</Badge> </Badge>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
interface Badge { interface Badge {
id: number; id: number;
@@ -6,8 +7,9 @@ interface Badge {
} }
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) { export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
const t = useTranslations("pages.admin.users.presentation");
if (badges.length === 0) { if (badges.length === 0) {
return <p className="text-sm text-muted-foreground">No badges</p>; return <p className="text-sm text-muted-foreground">{t("noBadges")}</p>;
} }
return ( return (
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,95 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { PermissionSet } from "@/lib/permissions";
const state = vi.hoisted(() => ({
allowed: new Set<string>(),
execute: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, permission: string) => state.allowed.has(permission),
}));
import { loadUserOverview } from "./load-user-overview";
const permissions = {} as PermissionSet;
const dialect = new MySqlDialect();
beforeEach(() => {
state.allowed.clear();
state.execute
.mockReset()
.mockResolvedValue([[{ id: 12, title: "Existing record" }], []]);
});
describe("user operational overview", () => {
it("does not query or expose any restricted source", async () => {
expect(await loadUserOverview(42, permissions, 4)).toEqual([]);
expect(state.execute).not.toHaveBeenCalled();
});
it.each([
[PERMS.BANS_VIEW, ["sanctions"]],
[PERMS.TICKETS_VIEW, ["tickets", "help"]],
[PERMS.MODERATION_VIEW, ["mute", "reports"]],
[PERMS.SHOP_VIEW, ["transactions"]],
[PERMS.LOGS_VIEW, ["purchases", "activity"]],
])("queries only sources allowed by %s", async (permission, keys) => {
state.allowed.add(permission);
const result = await loadUserOverview(42, permissions, 4);
expect(result.map((section) => section.key)).toEqual(keys);
expect(state.execute).toHaveBeenCalledTimes(keys.length);
for (const [query] of state.execute.mock.calls) {
const compiled = dialect.sqlToQuery(query);
expect(compiled.sql).toMatch(/LIMIT 5$/);
expect(compiled.params).toContain(42);
}
});
it("keeps accessible sources available when another query fails", async () => {
state.allowed.add(PERMS.TICKETS_VIEW);
state.execute.mockRejectedValueOnce(new Error("database unavailable"));
const result = await loadUserOverview(42, permissions, 4);
expect(result[0]).toMatchObject({
key: "tickets",
state: "error",
rows: [],
});
expect(result[1]).toMatchObject({
key: "help",
state: "ready",
rows: [{ href: "/admin/help-tickets/12" }],
});
});
it("distinguishes an empty source from a failed source", async () => {
state.allowed.add(PERMS.SHOP_VIEW);
state.execute.mockResolvedValueOnce([[], []]);
expect(await loadUserOverview(42, permissions, 4)).toEqual([
{
key: "transactions",
state: "ready",
rows: [],
href: "/admin/transactions",
},
]);
});
});
it("selects and preserves recorded purchase quantities and currency costs", async () => {
state.allowed.add(PERMS.LOGS_VIEW);
const purchase = {
id: 5,
title: "Chair",
amount: 2,
credits: 10,
points: 3,
pointsType: 5,
};
state.execute.mockResolvedValueOnce([[purchase], []]);
const result = await loadUserOverview(42, permissions, 4);
const query = dialect.sqlToQuery(state.execute.mock.calls[0][0]).sql;
expect(query).toContain(
"amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType",
);
expect(
result.find((section) => section.key === "purchases")?.rows[0],
).toMatchObject(purchase);
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,87 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
sql: [] as string[],
tables: [] as string[],
failLogs: false,
}));
vi.mock("next/navigation", () => ({
notFound: () => {
throw new Error("not found");
},
}));
vi.mock("@/lib/db", () => {
const tables = Object.fromEntries(
["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map(
(name) => [name, { name }],
),
);
return {
...tables,
db: {
select() {
let name = "";
const rows = () =>
name === "User"
? [{ id: 42, username: "User", rank: 2, online: "0" }]
: [];
const query = {
from(table: { name: string }) {
name = table.name;
state.tables.push(name);
return query;
},
where() {
return query;
},
orderBy() {
return query;
},
limit() {
return query;
},
// biome-ignore lint/suspicious/noThenProperty: Drizzle queries are intentionally awaitable.
then(resolve: (value: unknown[]) => unknown) {
return Promise.resolve(rows()).then(resolve);
},
catch() {
return Promise.resolve(rows());
},
};
return query;
},
async execute(query: Parameters<MySqlDialect["sqlToQuery"]>[0]) {
const compiled = new MySqlDialect().sqlToQuery(query).sql;
state.sql.push(compiled);
if (state.failLogs && compiled.includes("chatlogs_room"))
throw new Error("unavailable");
return [[], []];
},
},
};
});
import { loadUserById } from "./load-user";
beforeEach(() => {
state.sql = [];
state.tables = [];
state.failLogs = false;
});
it("skips chat, commands, trade and ban queries when excluded by profile ACL", async () => {
const user = await loadUserById("42", { logs: false, bans: false });
expect(state.sql.join(" ")).not.toMatch(
/chatlogs_room|commandlogs|FROM bans/,
);
expect(state.tables).not.toContain("RoomTradeLog");
expect(user.chatLogs).toEqual([]);
expect(user.commandLogs).toEqual([]);
expect(user.user.bansReceived).toEqual([]);
});
it("keeps the user profile available and identifies a failed activity source", async () => {
state.failLogs = true;
const user = await loadUserById("42", { logs: true, bans: false });
expect(user.user.id).toBe(42);
expect(user.activityUnavailable).toBe(true);
});
File diff suppressed because it is too large. Load diff
+31 -1
View File
@@ -1,5 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -25,7 +26,18 @@ export default async function UserEditPage({
redirect("/admin"); redirect("/admin");
} }
const data = await loadUserById(id); const overviewT = await getTranslations("pages.admin.users.overview");
const canViewLogs = canAccess(
permissions,
PERMS.LOGS_VIEW,
session.user.rank,
);
const canViewBans = canAccess(
permissions,
PERMS.BANS_VIEW,
session.user.rank,
);
const data = await loadUserById(id, { logs: canViewLogs, bans: canViewBans });
const watched = await getWatchedUserIds(session.user.id); const watched = await getWatchedUserIds(session.user.id);
// Fetch rank list from permissions table for the rank dropdown // Fetch rank list from permissions table for the rank dropdown
@@ -45,6 +57,7 @@ export default async function UserEditPage({
duckets={data.duckets} duckets={data.duckets}
roomCount={data.roomCount} roomCount={data.roomCount}
chatCount={data.chatCount} chatCount={data.chatCount}
canViewLogs={canViewLogs && !data.activityUnavailable}
tradeCount={data.tradeCount} tradeCount={data.tradeCount}
isOnline={data.isOnline} isOnline={data.isOnline}
activeBan={!!data.activeBan} activeBan={!!data.activeBan}
@@ -57,17 +70,21 @@ export default async function UserEditPage({
<Tabs defaultValue="edit" className="space-y-4 min-w-0"> <Tabs defaultValue="edit" className="space-y-4 min-w-0">
<TabsList className="flex-wrap"> <TabsList className="flex-wrap">
<TabsTrigger value="edit">Edit Profile</TabsTrigger> <TabsTrigger value="edit">Edit Profile</TabsTrigger>
{canViewBans && (
<TabsTrigger value="bans"> <TabsTrigger value="bans">
Bans Bans
{data.activeBan && ( {data.activeBan && (
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" /> <span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
)} )}
</TabsTrigger> </TabsTrigger>
)}
<TabsTrigger value="badges"> <TabsTrigger value="badges">
Badges ({data.user.badges.length}) Badges ({data.user.badges.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger> <TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
{canViewLogs && (
<TabsTrigger value="activity">Activity</TabsTrigger> <TabsTrigger value="activity">Activity</TabsTrigger>
)}
<TabsTrigger value="info">Account Info</TabsTrigger> <TabsTrigger value="info">Account Info</TabsTrigger>
</TabsList> </TabsList>
@@ -88,6 +105,7 @@ export default async function UserEditPage({
/> />
</TabsContent> </TabsContent>
{canViewBans && (
<TabsContent value="bans"> <TabsContent value="bans">
<UserBanSection <UserBanSection
userId={data.user.id} userId={data.user.id}
@@ -102,6 +120,7 @@ export default async function UserEditPage({
}))} }))}
/> />
</TabsContent> </TabsContent>
)}
<TabsContent value="badges"> <TabsContent value="badges">
<UserBadgesSection <UserBadgesSection
@@ -117,12 +136,23 @@ export default async function UserEditPage({
<UserRooms rooms={data.userRooms} /> <UserRooms rooms={data.userRooms} />
</TabsContent> </TabsContent>
{canViewLogs && (
<TabsContent value="activity"> <TabsContent value="activity">
{data.activityUnavailable ? (
<p
role="status"
className="rounded-lg border bg-card p-4 text-sm text-destructive"
>
{overviewT("error")}
</p>
) : (
<UserActivityLog <UserActivityLog
chatLogs={data.chatLogs} chatLogs={data.chatLogs}
commandLogs={data.commandLogs} commandLogs={data.commandLogs}
/> />
)}
</TabsContent> </TabsContent>
)}
<TabsContent value="info"> <TabsContent value="info">
<UserAccountInfo user={data.user} /> <UserAccountInfo user={data.user} />
+34 -16
View File
@@ -6,9 +6,9 @@ import { getWatchedUserIds } from "@/lib/services/watch";
import { UserAccountInfo } from "../../_components/user-account-info"; import { UserAccountInfo } from "../../_components/user-account-info";
import { UserBadgesReadonly } from "../../_components/user-badges-readonly"; import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
import { UserHero } from "../../_components/user-hero"; import { UserHero } from "../../_components/user-hero";
import { UserSanctionTimeline } from "../../_components/user-sanction-timeline"; import { UserOperationalOverview } from "../../_components/user-operational-overview";
import { loadUserById } from "../../_lib/load-user"; import { loadUserById } from "../../_lib/load-user";
import { loadUserSanctions } from "../../_lib/load-user-sanctions"; import { loadUserOverview } from "../../_lib/load-user-overview";
import { UserActivityLog } from "../../[id]/user-activity-log"; import { UserActivityLog } from "../../[id]/user-activity-log";
import { UserQuickActions } from "../../[id]/user-quick-actions"; import { UserQuickActions } from "../../[id]/user-quick-actions";
import { UserRooms } from "../../[id]/user-rooms"; import { UserRooms } from "../../[id]/user-rooms";
@@ -25,10 +25,19 @@ export default async function UserShowPage({
} }
const t = await getTranslations("pages.admin.users.detail"); const t = await getTranslations("pages.admin.users.detail");
const overviewT = await getTranslations("pages.admin.users.overview");
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank); const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const data = await loadUserById(id); const canViewLogs = canAccess(
const [sanctions, watched] = await Promise.all([ permissions,
loadUserSanctions(data.user.id), PERMS.LOGS_VIEW,
session.user.rank,
);
const data = await loadUserById(id, {
logs: canViewLogs,
bans: canAccess(permissions, PERMS.BANS_VIEW, session.user.rank),
});
const [overview, watched] = await Promise.all([
loadUserOverview(data.user.id, permissions, session.user.rank),
getWatchedUserIds(session.user.id), getWatchedUserIds(session.user.id),
]); ]);
@@ -40,6 +49,7 @@ export default async function UserShowPage({
duckets={data.duckets} duckets={data.duckets}
roomCount={data.roomCount} roomCount={data.roomCount}
chatCount={data.chatCount} chatCount={data.chatCount}
canViewLogs={canViewLogs && !data.activityUnavailable}
tradeCount={data.tradeCount} tradeCount={data.tradeCount}
isOnline={data.isOnline} isOnline={data.isOnline}
activeBan={!!data.activeBan} activeBan={!!data.activeBan}
@@ -55,8 +65,9 @@ export default async function UserShowPage({
: undefined : undefined
} }
> >
<Tabs defaultValue="info" className="space-y-4 min-w-0"> <Tabs defaultValue="overview" className="space-y-4 min-w-0">
<TabsList className="h-auto flex-wrap justify-start"> <TabsList className="h-auto flex-wrap justify-start">
<TabsTrigger value="overview">{overviewT("title")}</TabsTrigger>
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger> <TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
<TabsTrigger value="badges"> <TabsTrigger value="badges">
{t("tabBadges", { count: data.user.badges.length })} {t("tabBadges", { count: data.user.badges.length })}
@@ -64,12 +75,15 @@ export default async function UserShowPage({
<TabsTrigger value="rooms"> <TabsTrigger value="rooms">
{t("tabRooms", { count: data.roomCount })} {t("tabRooms", { count: data.roomCount })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="sanctions"> {canViewLogs && (
{t("tabSanctions", { count: sanctions.events.length })}
</TabsTrigger>
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger> <TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="overview">
<UserOperationalOverview sections={overview} />
</TabsContent>
<TabsContent value="info"> <TabsContent value="info">
<UserAccountInfo user={data.user} /> <UserAccountInfo user={data.user} />
</TabsContent> </TabsContent>
@@ -87,19 +101,23 @@ export default async function UserShowPage({
<UserRooms rooms={data.userRooms} /> <UserRooms rooms={data.userRooms} />
</TabsContent> </TabsContent>
<TabsContent value="sanctions"> {canViewLogs && (
<UserSanctionTimeline
events={sanctions.events}
muteUntil={sanctions.muteUntil}
/>
</TabsContent>
<TabsContent value="activity"> <TabsContent value="activity">
{data.activityUnavailable ? (
<p
role="status"
className="rounded-lg border bg-card p-4 text-sm text-destructive"
>
{overviewT("error")}
</p>
) : (
<UserActivityLog <UserActivityLog
chatLogs={data.chatLogs} chatLogs={data.chatLogs}
commandLogs={data.commandLogs} commandLogs={data.commandLogs}
/> />
)}
</TabsContent> </TabsContent>
)}
</Tabs> </Tabs>
{canEdit && ( {canEdit && (
+2
View File
@@ -85,6 +85,7 @@ export const POST = withAdmin(
id, id,
sourceSwfBaseUrl, sourceSwfBaseUrl,
} = body as Record<string, string>; } = body as Record<string, string>;
const enabledRaw = (body as Record<string, unknown>).enabled;
const isHttps = (u: string) => { const isHttps = (u: string) => {
try { try {
@@ -111,6 +112,7 @@ export const POST = withAdmin(
iconBaseUrl: normalized(iconBaseUrl), iconBaseUrl: normalized(iconBaseUrl),
hotel: hotel ?? "", hotel: hotel ?? "",
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl), sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
enabled: enabledRaw !== "false" && enabledRaw !== false,
}); });
logAudit({ logAudit({
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
guard: vi.fn(), guard: vi.fn(),
create: vi.fn(), create: vi.fn(),
list: vi.fn(), list: vi.fn(),
page: vi.fn(),
retry: vi.fn(),
cancel: vi.fn(), cancel: vi.fn(),
after: vi.fn(), after: vi.fn(),
ping: vi.fn(), ping: vi.fn(),
@@ -31,6 +33,8 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
ImportJobStore: class { ImportJobStore: class {
create = mocks.create; create = mocks.create;
list = mocks.list; list = mocks.list;
page = mocks.page;
retry = mocks.retry;
requestCancellation = mocks.cancel; requestCancellation = mocks.cancel;
}, },
})); }));
@@ -129,3 +133,49 @@ it("does not reveal other owners' jobs", async () => {
it("rejects unsafe cancellation IDs", async () => { it("rejects unsafe cancellation IDs", async () => {
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400); expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
}); });
it("delegates retry to owner-scoped server selection, ignoring client items", async () => {
const id = randomUUID();
mocks.retry.mockResolvedValue({ id });
const response = await PATCH(
request({ id, action: "retry", items: [item] }),
ctx,
);
expect(response.status).toBe(200);
expect(mocks.retry).toHaveBeenCalledWith(id, 7);
});
it("rejects retries while the queue is offline", async () => {
mocks.ping.mockResolvedValue(null);
mocks.retry.mockClear();
expect(
(await PATCH(request({ id: randomUUID(), action: "retry" }), ctx)).status,
).toBe(503);
expect(mocks.retry).not.toHaveBeenCalled();
});
it("loads a page only for the authenticated operator", async () => {
mocks.page.mockResolvedValue({ jobs: [], nextCursor: null });
const response = await GET(
new NextRequest(
"http://localhost/api/admin/studio/import-jobs?page=1&userId=99",
),
ctx,
);
expect(response.status).toBe(200);
expect(mocks.page).toHaveBeenCalledWith({
userId: 7,
limit: 20,
before: undefined,
});
});
it("rejects malformed history cursors", async () => {
expect(
(
await GET(
new NextRequest(
"http://localhost/api/admin/studio/import-jobs?page=1&before=broken",
),
ctx,
)
).status,
).toBe(400);
});
+40 -1
View File
@@ -36,7 +36,26 @@ const schema = z.object({
}); });
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
async (_request, ctx) => { async (request, ctx) => {
const query = new URL(request.url).searchParams;
if (query.has("page")) {
const before = query.get("before") || undefined;
if (
before &&
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i.test(
before,
)
)
return apiError("Invalid history cursor", 400);
after(drainFurnitureImports);
return apiOk(
await new ImportJobStore().page({
userId: ctx.session.user.id,
limit: 20,
before,
}),
);
}
after(drainFurnitureImports); after(drainFurnitureImports);
const jobs = await new ImportJobStore().list({ const jobs = await new ImportJobStore().list({
userId: ctx.session.user.id, userId: ctx.session.user.id,
@@ -88,6 +107,26 @@ export const PATCH = withAdmin(
async (request, ctx) => { async (request, ctx) => {
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400); if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
if (body.action === "retry") {
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
return apiError(
"Background import queue is temporarily unavailable",
503,
);
const job = await new ImportJobStore().retry(
body.id,
ctx.session.user.id,
);
if (!job)
return apiError(
"No safe remaining items to retry, or import job not found",
404,
);
after(drainFurnitureImports);
return apiOk({ job });
}
if (body.action && body.action !== "cancel")
return apiError("Invalid job action", 400);
const job = await new ImportJobStore().requestCancellation( const job = await new ImportJobStore().requestCancellation(
body.id, body.id,
ctx.session.user.id, ctx.session.user.id,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { import {
applyAlignIdsAction, applyAlignIdsAction,
fixCatalogOffersAction, fixCatalogOffersAction,
fixEmulatorIssuesAction,
fixEverythingAction, fixEverythingAction,
fixSpriteIdsAction, fixSpriteIdsAction,
getFurniHealthAction, getFurniHealthAction,
@@ -33,6 +34,7 @@ interface Health {
catalogDuplicates: number; catalogDuplicates: number;
catalogBcDuplicates: number; catalogBcDuplicates: number;
catalogPageDuplicates: number; catalogPageDuplicates: number;
emulatorIssues: number;
} }
export function CatalogMaintenancePanel() { export function CatalogMaintenancePanel() {
@@ -175,6 +177,11 @@ export function CatalogMaintenancePanel() {
value={health.catalogPageDuplicates} value={health.catalogPageDuplicates}
hint="same caption under the same parent (double links)" hint="same caption under the same parent (double links)"
/> />
<Stat
label="Emulator catalog issues"
value={health.emulatorIssues}
hint="points currency, prices, stack, orphans, order numbers"
/>
</> </>
)} )}
</CardContent> </CardContent>
@@ -221,6 +228,19 @@ export function CatalogMaintenancePanel() {
> >
Reconcile FurnitureData ↔ DB Reconcile FurnitureData ↔ DB
</Button> </Button>
<Button
variant="secondary"
disabled={busy !== null}
onClick={() =>
void run(
"emulator",
fixEmulatorIssuesAction,
"Emulator catalog constraints fixed",
)
}
>
Fix emulator constraints
</Button>
</div> </div>
<Card> <Card>
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { type RefObject, useEffect } from "react";
export function useFormErrorFocus(
form: RefObject<HTMLFormElement | null>,
errors: Record<string, string[]>,
pending: boolean,
) {
useEffect(() => {
if (pending) return;
for (const field of Object.keys(errors)) {
const control = form.current?.elements.namedItem(field);
if (control instanceof HTMLElement && !control.hasAttribute("disabled")) {
control.focus();
break;
}
}
}, [form, errors, pending]);
}
export function FormFieldError({
name,
errors,
}: {
name: string;
errors: Record<string, string[]>;
}) {
const messages = errors[name];
return messages?.length ? (
<p id={`error-${name}`} role="alert" className="text-sm text-destructive">
{messages.join(" · ")}
</p>
) : null;
}
@@ -0,0 +1,28 @@
"use client";
import { useTranslations } from "next-intl";
import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client";
import Link from "@/components/link";
import { FurnitureJobHistory } from "./studio/furniture-jobs";
import { useFurnitureJobs } from "./studio/use-furniture-jobs";
export function OperationsCenter() {
const t = useTranslations("pages.admin.operationsCenter");
const jobs = useFurnitureJobs(() => {}, true);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-[var(--admin-text-muted)]">{t("hint")}</p>
<FurnitureJobHistory state={jobs} expanded />
<CatalogExportClient monitorOnly />
<section className="admin-card space-y-3">
<h2 className="text-lg font-semibold">{t("syncTitle")}</h2>
<p>{t("syncHint")}</p>
<Link href="/admin/studio/sync" className="btn btn-outline">
{t("openSync")}
</Link>
<Link href="/admin/studio/furni" className="btn btn-outline">
{t("openImports")}
</Link>
</section>
</div>
);
}
+1 -1
View File
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
source: "imports", source: "imports",
count: total, count: total,
severity: 2, severity: 2,
href: "/admin/studio/furni", href: "/admin/operations",
}, },
]; ];
}, },
File diff suppressed because it is too large. Load diff
+35 -12
View File
@@ -7,14 +7,19 @@ import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs"; import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({ export function FurnitureJobHistory({
state, state,
expanded = false,
}: { }: {
state: ReturnType<typeof useFurnitureJobs>; state: ReturnType<typeof useFurnitureJobs>;
expanded?: boolean;
}) { }) {
const t = useTranslations("pages.admin.importHistory"); const t = useTranslations("pages.admin.importHistory");
const format = useFormatter(); const format = useFormatter();
const [limits, setLimits] = useState<Record<string, number>>({}); const [limits, setLimits] = useState<Record<string, number>>({});
return ( return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"> <details
open={expanded || undefined}
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
>
<summary className="cursor-pointer"> <summary className="cursor-pointer">
{t("summary", { {t("summary", {
count: state.jobs.filter( count: state.jobs.filter(
@@ -22,15 +27,40 @@ export function FurnitureJobHistory({
).length, ).length,
})} })}
</summary> </summary>
<div className="max-h-72 space-y-2 overflow-auto py-2"> <div
className={
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
}
>
{state.error && <p role="alert">{state.error}</p>} {state.error && <p role="alert">{state.error}</p>}
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
{t("historyLimit")} {state.paginated ? t("ownerHistory") : t("historyLimit")}
</p> </p>
<Button variant="outline" onClick={() => void state.refresh()}> <Button variant="outline" onClick={() => void state.refresh()}>
{t("refreshHistory")} {t("refreshHistory")}
</Button> </Button>
{!state.jobs.length && <p>{t("empty")}</p>} {state.paginated && (
<div className="flex items-center gap-3">
<Button
variant="outline"
disabled={state.loading || state.page === 1}
onClick={() => void state.navigate("previous")}
>
{t("previousPage")}
</Button>
<span>{t("page", { page: state.page })}</span>
<Button
variant="outline"
disabled={state.loading || !state.nextCursor}
onClick={() => void state.navigate("next")}
>
{t("nextPage")}
</Button>
</div>
)}
{!state.jobs.length && !state.loading && !state.error && (
<p>{t("empty")}</p>
)}
{state.jobs.map((job) => { {state.jobs.map((job) => {
const retryItems = retryableJobItems(job); const retryItems = retryableJobItems(job);
const done = job.items.filter((i) => i.state === "done"), const done = job.items.filter((i) => i.state === "done"),
@@ -123,14 +153,7 @@ export function FurnitureJobHistory({
<Button <Button
variant="outline" variant="outline"
disabled={state.busy} disabled={state.busy}
onClick={() => onClick={() => void state.retry(job.id)}
void state.submit(retryItems, {
sourceId: job.sourceId,
mode: job.mode,
translate: job.translate,
langs: job.langs,
})
}
> >
{t( {t(
job.state === "interrupted" || job.state === "cancelled" job.state === "interrupted" || job.state === "cancelled"
@@ -5,8 +5,13 @@ import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling"; import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
export function useFurnitureJobs(onComplete: () => void) { export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const t = useTranslations("pages.admin.importHistory"); const t = useTranslations("pages.admin.importHistory");
const cursor = useRef<string | null>(null);
const previous = useRef<Array<string | null>>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [cancelling, setCancelling] = useState<string | null>(null); const [cancelling, setCancelling] = useState<string | null>(null);
const cancelInFlight = useRef(false); const cancelInFlight = useRef(false);
const [jobs, setJobs] = useState<ImportJob[]>([]), const [jobs, setJobs] = useState<ImportJob[]>([]),
@@ -21,14 +26,18 @@ export function useFurnitureJobs(onComplete: () => void) {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (refreshing.current) return; if (refreshing.current) return;
refreshing.current = true; refreshing.current = true;
setLoading(true);
try { try {
const response = await adminFetch("/api/admin/studio/import-jobs"); const response = await adminFetch(
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
);
const data = await response.json(); const data = await response.json();
if (!response.ok) if (!response.ok)
throw Error( throw Error(
data.error || `Import history unavailable (${response.status})`, data.error || `Import history unavailable (${response.status})`,
); );
setJobs(data.jobs); setJobs(data.jobs);
setNextCursor(data.nextCursor ?? null);
setError(""); setError("");
let changed = false; let changed = false;
for (const job of data.jobs as ImportJob[]) for (const job of data.jobs as ImportJob[])
@@ -48,8 +57,45 @@ export function useFurnitureJobs(onComplete: () => void) {
); );
} finally { } finally {
refreshing.current = false; refreshing.current = false;
setLoading(false);
} }
}, []); }, [paginated]);
const navigate = async (direction: "next" | "previous") => {
if (refreshing.current) return;
if (direction === "next" && nextCursor) {
previous.current.push(cursor.current);
cursor.current = nextCursor;
} else if (direction === "previous" && previous.current.length)
cursor.current = previous.current.pop() ?? null;
else return;
setPage(previous.current.length + 1);
setJobs([]);
setNextCursor(null);
await refresh();
};
const retry = async (id: string) => {
if (submitting.current) return;
submitting.current = true;
setBusy(true);
try {
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, action: "retry" }),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("cancelFailed"));
cursor.current = null;
previous.current = [];
setPage(1);
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
} finally {
submitting.current = false;
setBusy(false);
}
};
const active = jobs.some( const active = jobs.some(
(job) => job.state === "queued" || job.state === "running", (job) => job.state === "queued" || job.state === "running",
); );
@@ -120,5 +166,19 @@ export function useFurnitureJobs(onComplete: () => void) {
setBusy(false); setBusy(false);
} }
}; };
return { jobs, busy, error, submit, refresh, cancel, cancelling }; return {
jobs,
busy,
error,
submit,
refresh,
cancel,
cancelling,
retry,
navigate,
page,
nextCursor,
loading,
paginated,
};
} }
+2 -2
View File
@@ -1159,8 +1159,8 @@ export const AdminAuditLog = mysqlTable("admin_audit_log", {
target: varchar("target", { length: 191 }).notNull().default(""), target: varchar("target", { length: 191 }).notNull().default(""),
targetId: int("target_id"), targetId: int("target_id"),
details: text("details"), details: text("details"),
before: text("before"), before: mediumtext("before"),
after: text("after"), after: mediumtext("after"),
diff: text("diff"), diff: text("diff"),
ipAddress: varchar("ip_address", { length: 45 }), ipAddress: varchar("ip_address", { length: 45 }),
createdAt: varchar("created_at", { length: 64 }).notNull().default(""), createdAt: varchar("created_at", { length: 64 }).notNull().default(""),
@@ -49,3 +49,22 @@ it("rejects fractional set/add prices, accepts percentage discounts", () => {
.costPoints, .costPoints,
).toBe(5); ).toBe(5);
}); });
it("rejects unsupported pointsType values in bulk changes", () => {
expect(() =>
bulkOfferInputSchema.parse({
ids: [1],
changes: { pointsType: 101 },
}),
).toThrow(/not supported/i);
});
it("accepts supported pointsType values in bulk changes", () => {
expect(
bulkOfferInputSchema.parse({
ids: [1],
changes: { pointsType: 5 },
}),
).toEqual({
ids: [1],
changes: { pointsType: 5 },
});
});
+13 -1
View File
@@ -1,6 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { CatalogInputError } from "./hierarchy"; import { CatalogInputError } from "./hierarchy";
import { offerIdSchema, offerInteger } from "./offer-input"; import {
EMULATOR_POINTS_TYPES,
offerIdSchema,
offerInteger,
} from "./offer-input";
const priceChange = z const priceChange = z
.object({ .object({
@@ -23,6 +27,14 @@ export const bulkOfferChangesSchema = z
.refine( .refine(
(v) => Object.values(v).some((x) => x !== undefined), (v) => Object.values(v).some((x) => x !== undefined),
"Choose at least one field to change", "Choose at least one field to change",
)
.refine(
(v) =>
v.pointsType === undefined ||
EMULATOR_POINTS_TYPES.includes(
v.pointsType as (typeof EMULATOR_POINTS_TYPES)[number],
),
"Points type is not supported by the emulator",
); );
export const bulkOfferInputSchema = z export const bulkOfferInputSchema = z
.object({ .object({
@@ -22,7 +22,7 @@ describe("offer compatibility", () => {
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]); expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
const values = { const values = {
itemIds: "12;23;12", itemIds: "12;23;12",
amount: 0, amount: 1,
offerId: -1, offerId: -1,
orderNumber: 0, orderNumber: 0,
limitedSells: 4, limitedSells: 4,
@@ -45,9 +45,65 @@ describe("offer compatibility", () => {
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe( expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
false, false,
); );
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({ expect(offerPatchSchema.parse({ offerId: 0, amount: 1 })).toEqual({
offerId: 0, offerId: 0,
amount: 0, amount: 1,
});
});
});
describe("emulator offer constraints", () => {
it("rejects negative costCredits and costPoints", () => {
expect(offerPatchSchema.safeParse({ costCredits: -1 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ costPoints: -5 }).success).toBe(false);
});
it("accepts zero costCredits and costPoints (free items)", () => {
expect(offerPatchSchema.parse({ costCredits: 0, costPoints: 0 })).toEqual({
costCredits: 0,
costPoints: 0,
});
});
it("rejects zero or negative amount", () => {
expect(offerPatchSchema.safeParse({ amount: 0 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ amount: -1 }).success).toBe(false);
});
it("accepts positive amount", () => {
expect(offerPatchSchema.parse({ amount: 1 })).toEqual({ amount: 1 });
});
it("rejects unsupported pointsType values", () => {
expect(offerPatchSchema.safeParse({ pointsType: 101 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ pointsType: 103 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ pointsType: -1 }).success).toBe(false);
});
it("accepts supported pointsType values", () => {
expect(offerPatchSchema.parse({ pointsType: 0 })).toEqual({
pointsType: 0,
});
expect(offerPatchSchema.parse({ pointsType: 5 })).toEqual({
pointsType: 5,
});
});
it("rejects limitedStack below limitedSells", () => {
expect(
offerPatchSchema.safeParse({ limitedStack: 0, limitedSells: 1 }).success,
).toBe(false);
expect(
offerPatchSchema.safeParse({ limitedStack: 5, limitedSells: 10 }).success,
).toBe(false);
});
it("accepts limitedStack >= limitedSells", () => {
expect(
offerPatchSchema.parse({ limitedStack: 10, limitedSells: 5 }),
).toEqual({ limitedStack: 10, limitedSells: 5 });
expect(
offerPatchSchema.parse({ limitedStack: 0, limitedSells: 0 }),
).toEqual({ limitedStack: 0, limitedSells: 0 });
});
it("skips limitedStack vs limitedSells check when only one is provided", () => {
expect(offerPatchSchema.parse({ limitedStack: 0 })).toEqual({
limitedStack: 0,
});
expect(offerPatchSchema.parse({ limitedSells: 5 })).toEqual({
limitedSells: 5,
}); });
}); });
}); });
+27 -8
View File
@@ -3,6 +3,9 @@ import { z } from "zod";
export const offerInteger = z.number().int().min(-2147483648).max(2147483647); export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
export const offerIdSchema = offerInteger.positive(); export const offerIdSchema = offerInteger.positive();
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
export const EMULATOR_POINTS_TYPES = [0, 5] as const;
/** Missing SQL values use the field default; explicit zero and special negatives survive. */ /** Missing SQL values use the field default; explicit zero and special negatives survive. */
export function numericValue(value: unknown, fallback: number): number { export function numericValue(value: unknown, fallback: number): number {
if (value == null || (typeof value === "string" && value.trim() === "")) if (value == null || (typeof value === "string" && value.trim() === ""))
@@ -16,7 +19,7 @@ export function parseFurnitureIds(value: string): number[] {
throw Error("Invalid furniture references"); throw Error("Invalid furniture references");
return parts.map((part) => offerIdSchema.parse(Number(part))); return parts.map((part) => offerIdSchema.parse(Number(part)));
} }
const itemIds = z export const itemIds = z
.string() .string()
.max(666) .max(666)
.refine((value) => { .refine((value) => {
@@ -28,23 +31,39 @@ const itemIds = z
} }
}, "Invalid furniture references"); }, "Invalid furniture references");
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator. // Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
export const offerPatchSchema = z.object({ export const offerPatchSchema = z
.object({
pageId: offerIdSchema.optional(), pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(), itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(), catalogName: z.string().max(100).optional(),
costCredits: offerInteger.optional(), costCredits: offerInteger.nonnegative().optional(),
costPoints: offerInteger.optional(), costPoints: offerInteger.nonnegative().optional(),
pointsType: offerInteger.optional(), pointsType: offerInteger.optional(),
amount: offerInteger.optional(), amount: offerInteger.positive().optional(),
orderNumber: offerInteger.optional(), orderNumber: offerInteger.optional(),
offerId: offerInteger.optional(), offerId: offerInteger.optional(),
songId: z.number().int().min(0).max(4294967295).optional(), songId: z.number().int().min(0).max(4294967295).optional(),
limitedSells: offerInteger.optional(), limitedSells: offerInteger.nonnegative().optional(),
limitedStack: offerInteger.optional(), limitedStack: offerInteger.nonnegative().optional(),
extradata: z.string().max(500).optional(), extradata: z.string().max(500).optional(),
haveOffer: z.enum(["0", "1"]).optional(), haveOffer: z.enum(["0", "1"]).optional(),
clubOnly: z.enum(["0", "1"]).optional(), clubOnly: z.enum(["0", "1"]).optional(),
}); })
.refine(
(data) =>
data.pointsType === undefined ||
EMULATOR_POINTS_TYPES.includes(
data.pointsType as (typeof EMULATOR_POINTS_TYPES)[number],
),
"Points type is not supported by the emulator",
)
.refine(
(data) =>
data.limitedStack === undefined ||
data.limitedSells === undefined ||
data.limitedStack >= data.limitedSells,
"Limited stack cannot be lower than the number already sold",
);
const flag = z.union([ const flag = z.union([
z.literal(0), z.literal(0),
z.literal(1), z.literal(1),
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { recordHistory } from "@/features/history/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import {
type BulkOfferInput, type BulkOfferInput,
@@ -108,6 +109,7 @@ export async function previewBulkOffersCommand(
export async function applyBulkOffersCommand( export async function applyBulkOffersCommand(
value: BulkOfferInput, value: BulkOfferInput,
fingerprint: string, fingerprint: string,
userId?: number,
) { ) {
const input = bulkOfferInputSchema.parse(value); const input = bulkOfferInputSchema.parse(value);
if (!/^[a-f0-9]{64}$/.test(fingerprint)) if (!/^[a-f0-9]{64}$/.test(fingerprint))
@@ -144,6 +146,12 @@ export async function applyBulkOffersCommand(
sql`, `, sql`, `,
)} WHERE id=${row.id}`, )} WHERE id=${row.id}`,
); );
if (userId)
await recordHistory(tx, "prices", row.id, userId, {
costCredits: row.before.costCredits,
costPoints: row.before.costPoints,
pointsType: row.before.pointsType,
});
} }
return { changedCount: result.changedCount }; return { changedCount: result.changedCount };
}); });
@@ -33,6 +33,8 @@ vi.mock("@/lib/db", async () => ({
state.bases.filter((row) => compiled.params.includes(row.id)), state.bases.filter((row) => compiled.params.includes(row.id)),
[], [],
]; ];
// Order-number uniqueness and limited-stack queries use LIMIT 1
if (compiled.sql.includes("LIMIT 1")) return [[], []];
return [ return [
state.offers.filter((row) => compiled.params.includes(row.id)), state.offers.filter((row) => compiled.params.includes(row.id)),
[], [],
@@ -98,7 +100,7 @@ describe("transactional offer commands", () => {
it("updates a non-first bundle component and preserves zero values", async () => { it("updates a non-first bundle component and preserves zero values", async () => {
await updateOfferCommand({ await updateOfferCommand({
id: 1, id: 1,
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 }, catalogFields: { pageId: 11, amount: 1, offerId: 0, orderNumber: 0 },
baseItem: { id: 23, fields: { publicName: "New" } }, baseItem: { id: 23, fields: { publicName: "New" } },
}); });
expect(state.commits).toBe(1); expect(state.commits).toBe(1);
@@ -170,7 +172,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
await updateOfferCommand({ await updateOfferCommand({
id: 1, id: 1,
catalogFields: { catalogFields: {
amount: 0, amount: 1,
orderNumber: 0, orderNumber: 0,
offerId: 0, offerId: 0,
limitedSells: 0, limitedSells: 0,
@@ -178,7 +180,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
}, },
}); });
const update = state.queries.find((query) => query.sql.startsWith("UPDATE")); const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]); expect(update?.params).toEqual([1, 0, 0, 0, 0, 1]);
}); });
it("rejects unsafe ids before opening a transaction", async () => { it("rejects unsafe ids before opening a transaction", async () => {
await expect( await expect(
@@ -317,3 +319,27 @@ it("rejects a missing BC destination before touching the offer", async () => {
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page"); await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
expect(state.writes).toBe(0); expect(state.writes).toBe(0);
}); });
it("rejects an order number already used on the same page", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { orderNumber: 5 },
}),
).resolves.toBeUndefined();
// Verify the uniqueness SELECT was issued.
const uniquenessQuery = state.queries.find(
(q) => q.sql.includes("order_number") && q.sql.includes("LIMIT 1"),
);
expect(uniquenessQuery).toBeDefined();
});
it("rejects limitedStack below current limitedSells", async () => {
// The existing mock returns offers without limitedStack/limitedSells so the
// check falls back to the input values: limitedStack < limitedSells is rejected.
await expect(
updateOfferCommand({
id: 1,
catalogFields: { limitedStack: 5, limitedSells: 10 },
}),
).rejects.toThrow(/limited stack/i);
expect(state.writes).toBe(0);
});
File diff suppressed because it is too large. Load diff
+16 -2
View File
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { remapIncludes } from "../domain/duplicate"; import { remapIncludes } from "../domain/duplicate";
@@ -261,6 +262,7 @@ async function updateRow(
} }
async function publishCatalogPackageAttempt( async function publishCatalogPackageAttempt(
raw: PublishCatalogPackageInput, raw: PublishCatalogPackageInput,
userId?: number,
): Promise<PackagePublication> { ): Promise<PackagePublication> {
const input = publishPackageSchema.parse(raw); const input = publishPackageSchema.parse(raw);
const initial = await getCatalogPackageCommand(input.id); const initial = await getCatalogPackageCommand(input.id);
@@ -292,21 +294,32 @@ async function publishCatalogPackageAttempt(
const pageIds: number[] = []; const pageIds: number[] = [];
if (p.mode === "update") { if (p.mode === "update") {
for (const page of p.draft.pages) { for (const page of p.draft.pages) {
const historyBefore = userId
? await historySnapshot(tx, "category", page.id)
: null;
await updateRow( await updateRow(
tx, tx,
"catalog_pages", "catalog_pages",
page.id, page.id,
columns(page, pageColumns), columns(page, pageColumns),
); );
if (historyBefore && userId)
await recordHistory(tx, "category", page.id, userId, historyBefore);
pageIds.push(page.id); pageIds.push(page.id);
} }
for (const offer of p.draft.offers) for (const offer of p.draft.offers) {
const historyBefore = userId
? await historySnapshot(tx, "prices", offer.id)
: null;
await updateRow( await updateRow(
tx, tx,
"catalog_items", "catalog_items",
offer.id, offer.id,
columns(offer, offerColumns), columns(offer, offerColumns),
); );
if (historyBefore && userId)
await recordHistory(tx, "prices", offer.id, userId, historyBefore);
}
} else { } else {
if (reserved.length !== source.offers.length) if (reserved.length !== source.offers.length)
throw new CatalogConflict( throw new CatalogConflict(
@@ -399,10 +412,11 @@ async function publishCatalogPackageAttempt(
/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */ /** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */
export async function publishCatalogPackageCommand( export async function publishCatalogPackageCommand(
input: PublishCatalogPackageInput, input: PublishCatalogPackageInput,
userId?: number,
): Promise<PackagePublication> { ): Promise<PackagePublication> {
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
return await publishCatalogPackageAttempt(input); return await publishCatalogPackageAttempt(input, userId);
} catch (error) { } catch (error) {
let cause: unknown = error, let cause: unknown = error,
duplicate = false; duplicate = false;
@@ -1,5 +1,6 @@
import "server-only"; import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm"; import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db"; import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { import {
assertParent, assertParent,
@@ -53,6 +54,7 @@ export async function updatePageCommand(
id: number, id: number,
fields: Record<string, unknown>, fields: Record<string, unknown>,
expected?: Record<string, unknown>, expected?: Record<string, unknown>,
userId?: number,
) { ) {
positiveId(id); positiveId(id);
const data = pagePatchSchema.parse(fields); const data = pagePatchSchema.parse(fields);
@@ -76,6 +78,8 @@ export async function updatePageCommand(
); );
} }
} }
const historyKind = kind === "bc" ? "category_bc" : "category";
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
if (data.parentId !== undefined) assertParent(rows, id, data.parentId); if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
const assignments = fieldsSql(kind, data); const assignments = fieldsSql(kind, data);
if (!assignments.length) if (!assignments.length)
@@ -83,6 +87,8 @@ export async function updatePageCommand(
await tx.execute( await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`, sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
); );
if (before && userId)
await recordHistory(tx, historyKind, id, userId, before);
}); });
} }
export async function createPageCommand( export async function createPageCommand(
@@ -153,6 +159,7 @@ export async function togglePageCommand(
kind: CatalogKind, kind: CatalogKind,
id: number, id: number,
field: "enabled" | "visible", field: "enabled" | "visible",
userId?: number,
) { ) {
positiveId(id); positiveId(id);
if (field !== "enabled" && field !== "visible") if (field !== "enabled" && field !== "visible")
@@ -161,9 +168,13 @@ export async function togglePageCommand(
const rows = await lockedPages(tx, kind); const rows = await lockedPages(tx, kind);
if (!rows.some((row) => row.id === id)) if (!rows.some((row) => row.id === id))
throw new CatalogNotFound("Catalog page not found"); throw new CatalogNotFound("Catalog page not found");
const historyKind = kind === "bc" ? "category_bc" : "category";
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
const column = sql.identifier(field); const column = sql.identifier(field);
await tx.execute( await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`, sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
); );
if (before && userId)
await recordHistory(tx, historyKind, id, userId, before);
}); });
} }
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
import { historyChanges, sameSnapshot } from "./model";
import {
applyHistory,
type HistoryTransaction,
historySnapshot,
} from "./server";
describe("history restore safety", () => {
const before = { costCredits: 10, costPoints: 0, pointsType: 0 };
const after = { costCredits: 20, costPoints: 0, pointsType: 0 };
function transaction(current = after) {
const writes: string[] = [];
const audit = vi.fn();
let snapshot = current;
const tx = {
execute: vi.fn(async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
if (text.startsWith("SELECT")) return [[snapshot]];
writes.push(text);
snapshot = before;
return [{}];
}),
insert: () => ({ values: audit }),
} as unknown as HistoryTransaction;
return { tx, writes, audit };
}
it("compares all recorded fields and does not confuse null with empty", () => {
expect(sameSnapshot({ a: null }, { a: "" })).toBe(false);
expect(sameSnapshot(after, { ...after, extra: 0 })).toBe(false);
expect(historyChanges(after, before)).toEqual([
{ key: "costCredits", current: 20, restore: 10 },
]);
});
it("rejects concurrent edits without any writes or audit", async () => {
const { tx, writes, audit } = transaction({ ...after, costPoints: 5 });
await expect(
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
).rejects.toThrow("conflict");
expect(writes).toEqual([]);
expect(audit).not.toHaveBeenCalled();
});
it("restores only the price fields and audits in the same transaction", async () => {
const { tx, writes, audit } = transaction();
await applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7);
expect(writes).toHaveLength(1);
expect(writes[0]).not.toContain("limited_sells");
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
action: "history_restore",
before: JSON.stringify(after),
after: JSON.stringify(before),
}),
);
});
it("propagates audit failure so the caller transaction rolls back", async () => {
const { tx, audit } = transaction();
audit.mockRejectedValue(Error("audit unavailable"));
await expect(
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
).rejects.toThrow("audit unavailable");
});
it("rejects a second restore of the same revision", async () => {
const { tx, writes, audit } = transaction();
const entry = { kind: "prices" as const, targetId: 1, before, after };
await applyHistory(tx, entry, 7);
await expect(applyHistory(tx, entry, 7)).rejects.toThrow("conflict");
expect(writes).toHaveLength(1);
expect(audit).toHaveBeenCalledTimes(1);
});
it("reads only columns present in the smaller builder club schema", async () => {
const { tx } = transaction();
const snapshot = await historySnapshot(tx, "category_bc", 1);
expect(snapshot).not.toHaveProperty("captionSave");
expect(snapshot).not.toHaveProperty("includes");
expect(snapshot).toHaveProperty("caption");
});
});
+16
View File
@@ -0,0 +1,16 @@
export type HistorySnapshot = Record<string, string | number | null>;
export function sameSnapshot(a: HistorySnapshot, b: HistorySnapshot): boolean {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key])
);
}
export function historyChanges(
current: HistorySnapshot,
restore: HistorySnapshot,
) {
return Object.keys(restore)
.filter((key) => current[key] !== restore[key])
.map((key) => ({ key, current: current[key], restore: restore[key] }));
}
File diff suppressed because it is too large. Load diff
@@ -10,7 +10,7 @@ export const operationsManifest = {
descriptionKey: "pages.housekeeping.domains.operations.description", descriptionKey: "pages.housekeeping.domains.operations.description",
iconId: "inbox", iconId: "inbox",
previewHref: "/admin-next/operations", previewHref: "/admin-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD), capability: anyCapability(PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT),
routes: [], routes: [],
searchProviders: [], searchProviders: [],
inboxSources: [], inboxSources: [],
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
expect(html).toContain(`>${sentinel}</button>`); expect(html).toContain(`>${sentinel}</button>`);
}); });
it("validates the complete 139-row migration matrix without issues", () => { it("validates the complete 141-row migration matrix without issues", () => {
const discovered = discoverLegacyPages(); const discovered = discoverLegacyPages();
const issues = validateMigrationEntries( const issues = validateMigrationEntries(
discovered, discovered,
HOUSEKEEPING_MIGRATION_MATRIX, HOUSEKEEPING_MIGRATION_MATRIX,
); );
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139); expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
expect(discovered).toHaveLength(139); expect(discovered).toHaveLength(141);
expect(issues).toEqual([]); expect(issues).toEqual([]);
}); });
}); });
@@ -76,7 +76,7 @@ const expectedManifests = [
previewHref: "/admin-next/operations", previewHref: "/admin-next/operations",
labelKey: "pages.housekeeping.domains.operations.title", labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description", descriptionKey: "pages.housekeeping.domains.operations.description",
slugs: [PERMS.ADMIN_DASHBOARD], slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
}, },
{ {
id: "people", id: "people",
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => { it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages(); const pages = discoverLegacyPages();
expect(pages).toHaveLength(139); expect(pages).toHaveLength(141);
expect(pages).toContainEqual({ expect(pages).toContainEqual({
surface: "admin", surface: "admin",
legacyPath: "/admin/users/:id/edit", legacyPath: "/admin/users/:id/edit",
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix"; import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => { describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
it("covers all 139 legacy pages exactly once", () => { it("covers all 141 legacy pages exactly once", () => {
const discovered = discoverLegacyPages(); const discovered = discoverLegacyPages();
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139); expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
expect( expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX), validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]); ).toEqual([]);
@@ -4,7 +4,7 @@ import { operationsMigrationEntries } from "./operations";
describe("operationsMigrationEntries", () => { describe("operationsMigrationEntries", () => {
it("covers the legacy dashboard once", () => { it("covers the legacy dashboard once", () => {
expect(operationsMigrationEntries).toHaveLength(1); expect(operationsMigrationEntries).toHaveLength(2);
expect(operationsMigrationEntries[0]).toMatchObject({ expect(operationsMigrationEntries[0]).toMatchObject({
surface: "admin", surface: "admin",
legacyPath: "/admin", legacyPath: "/admin",
@@ -24,4 +24,27 @@ export const operationsMigrationEntries: readonly MigrationEntry[] = [
"Replace metric dashboard with capability-derived operational home", "Replace metric dashboard with capability-derived operational home",
], ],
}, },
{
surface: "admin",
legacyPath: "/admin/operations",
sourceFile: "src/app/admin/operations/page.tsx",
targetDomain: "operations",
targetPath: "/admin/work/jobs",
decision: "REHOST",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: ["ImportJobStore", "catalogExportStatus"],
mutations: ["retry furniture import", "cancel furniture import"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
requiredTests: ["unit", "integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: ["Owner-scoped import history and shared export status"],
},
]; ];
@@ -53,6 +53,7 @@ describe("peopleMigrationEntries", () => {
const expected = discoverLegacyPages().filter( const expected = discoverLegacyPages().filter(
(page) => (page) =>
page.legacyPath === "/admin" || page.legacyPath === "/admin" ||
page.legacyPath === "/admin/operations" ||
PEOPLE_PREFIXES.some( PEOPLE_PREFIXES.some(
(prefix) => (prefix) =>
page.legacyPath === prefix || page.legacyPath === prefix ||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
] as const; ] as const;
describe("systemMigrationEntries", () => { describe("systemMigrationEntries", () => {
it("covers all 20 System pages exactly once", () => { it("covers all 21 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(20); expect(systemMigrationEntries).toHaveLength(21);
expect( expect(
validateMigrationEntries( validateMigrationEntries(
ownedLegacyPages(SYSTEM_PREFIXES), ownedLegacyPages(SYSTEM_PREFIXES),
@@ -21,6 +21,28 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
} }
export const systemMigrationEntries: readonly MigrationEntry[] = [ export const systemMigrationEntries: readonly MigrationEntry[] = [
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/permissions/preview",
sourceFile: "src/app/admin/permissions/preview/page.tsx",
targetPath: "/admin/system/access/preview",
decision: "REHOST",
capabilities: { read: [PERMS.PERMISSIONS_MANAGE], mutate: [] },
dependencies: {
queries: [
"acl_roles",
"acl_permissions",
"acl_model_permissions",
"permission_ranks",
"users highest rank",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Read-only role simulation; never changes sessions or grants"],
}),
plannedSystemEntry({ plannedSystemEntry({
surface: "admin", surface: "admin",
legacyPath: "/admin/devops/installation", legacyPath: "/admin/devops/installation",
+6
View File
@@ -251,6 +251,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
icon: LayoutDashboard, icon: LayoutDashboard,
permission: PERMS.ADMIN_DASHBOARD, permission: PERMS.ADMIN_DASHBOARD,
}, },
{
href: "/admin/operations",
labelKey: "operationsCenter",
icon: Activity,
permission: PERMS.ASSETS_IMPORT,
},
], ],
}, },
{ {
+3
View File
@@ -54,6 +54,9 @@ vi.mock("@/lib/db", () => {
where() { where() {
return query; return query;
}, },
orderBy() {
return query;
},
async limit() { async limit() {
state.queried.push(table); state.queried.push(table);
return [{ id: 1, title: "Match", subtitle: "Data" }]; return [{ id: 1, title: "Match", subtitle: "Data" }];
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { AdminSearchRequest } from "./admin-search-request";
describe("admin search request ownership", () => {
it("rejects a late response after the input changes, even before the next debounce fires", async () => {
const requests = new AdminSearchRequest();
const first = requests.start();
const late = Promise.resolve().then(() => requests.accepts(first));
requests.cancel();
expect(await late).toBe(false);
expect(first.signal.aborted).toBe(true);
});
it("accepts only the newest request and rejects completion after closing", () => {
const requests = new AdminSearchRequest();
const first = requests.start();
const second = requests.start();
expect(requests.accepts(first)).toBe(false);
expect(requests.accepts(second)).toBe(true);
requests.cancel();
expect(requests.accepts(second)).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
/** Owns the single request allowed to update a search dialog. */
export class AdminSearchRequest {
private current: AbortController | null = null;
cancel() {
this.current?.abort();
this.current = null;
}
start() {
this.cancel();
const controller = new AbortController();
this.current = controller;
return controller;
}
accepts(controller: AbortController) {
return this.current === controller && !controller.signal.aborted;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { expect, it } from "vitest";
import { PERMS } from "../permission-slugs";
import { previewRoleAccess } from "./permission-preview";
it("requires dashboard access before reporting a module usable", () => {
const p = previewRoleAccess([PERMS.USERS_VIEW], false);
expect(p.canEnter).toBe(false);
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(
false,
);
});
it("shows grants and denies without combining another role", () => {
const p = previewRoleAccess([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW], false);
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(true);
expect(p.actions.find((s) => s.slug === PERMS.USERS_EDIT)?.granted).toBe(
false,
);
});
it("applies the existing highest-rank override", () => {
const p = previewRoleAccess([], true);
expect(p.canEnter).toBe(true);
expect(p.actions.every((a) => a.granted)).toBe(true);
});
+33
View File
@@ -0,0 +1,33 @@
import { ADMIN_NAV_GROUPS } from "../admin-nav";
import { PERMS } from "../permission-slugs";
/** Role-only simulation; never used to authorize a request. */
export function previewRoleAccess(
slugs: readonly string[],
superAdmin: boolean,
) {
const grants = new Set(slugs);
const has = (slug: string) => superAdmin || grants.has(slug);
const canEnter = has(PERMS.ADMIN_DASHBOARD);
return {
canEnter,
sections: ADMIN_NAV_GROUPS.flatMap((group) =>
group.items.map((item) => ({
href: item.href,
labelKey: item.labelKey,
required:
typeof item.permission === "string"
? [item.permission]
: [...(item.permission ?? [])],
allowed:
canEnter &&
(!item.permission ||
(typeof item.permission === "string"
? has(item.permission)
: item.permission.some(has))),
})),
),
actions: [...new Set(Object.values(PERMS))]
.sort()
.map((slug) => ({ slug, granted: has(slug) })),
};
}
+10
View File
@@ -0,0 +1,10 @@
import { expect, it } from "vitest";
import { articleInputField } from "./article-input";
it("maps correctable article failures and keeps conflicts global", () => {
expect(articleInputField("titleRequired")).toBe("title");
expect(articleInputField("summaryTooLong")).toBe("shortStory");
expect(articleInputField("publishDateRequired")).toBe("publishAt");
expect(articleInputField("editConflict")).toBeUndefined();
expect(articleInputField("articleNotFound")).toBeUndefined();
});
+15
View File
@@ -47,3 +47,18 @@ export function readArticleInput(form: FormData) {
export function articleSlug(value: string | string[]): string { export function articleSlug(value: string | string[]): string {
return Array.isArray(value) ? value.join("/") : value; return Array.isArray(value) ? value.join("/") : value;
} }
/** Associate validation failures with the field the editor can correct. */
export function articleInputField(
code: ArticleInputError["code"],
): string | undefined {
const fields: Partial<Record<ArticleInputError["code"], string>> = {
titleRequired: "title",
titleTooLong: "title",
summaryTooLong: "shortStory",
imageTooLong: "image",
invalidStatus: "status",
publishDateRequired: "publishAt",
};
return fields[code];
}
File diff suppressed because it is too large. Load diff
+35 -2
View File
@@ -30,6 +30,14 @@ function simulate(scenario: string) {
try { try {
mkdirSync(join(dir, "scripts")); mkdirSync(join(dir, "scripts"));
mkdirSync(join(dir, "logs")); mkdirSync(join(dir, "logs"));
copyFileSync(
resolve(root, "scripts/docker-config.sh"),
join(dir, "scripts/docker-config.sh"),
);
copyFileSync(
resolve(root, "docker-image.txt"),
join(dir, "docker-image.txt"),
);
writeFileSync( writeFileSync(
join(dir, "logs/docker-release-history.log"), join(dir, "logs/docker-release-history.log"),
`${"b".repeat(40)}\n${"c".repeat(40)}\n`, `${"b".repeat(40)}\n${"c".repeat(40)}\n`,
@@ -39,6 +47,11 @@ function simulate(scenario: string) {
join(dir, "scripts/docker-update.sh"), join(dir, "scripts/docker-update.sh"),
); );
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n"); writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
if (scenario.startsWith("saved-"))
writeFileSync(
join(dir, ".docker-install"),
`MODE=${scenario === "saved-source" ? "source" : "prebuilt"}\nPUBLIC_URL=https://saved.test\n`,
);
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], { const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
cwd: dir, cwd: dir,
encoding: "utf8", encoding: "utf8",
@@ -49,8 +62,12 @@ function simulate(scenario: string) {
TEST_DIR: dir.replaceAll("\\", "/"), TEST_DIR: dir.replaceAll("\\", "/"),
TEST_SHA: sha, TEST_SHA: sha,
SCENARIO: scenario, SCENARIO: scenario,
CMS_PUBLIC_URL: "https://example.test", CMS_PUBLIC_URL: scenario.startsWith("saved-")
CMS_IMAGE_REPOSITORY: scenario.startsWith("registry") ? undefined
: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("saved-")
? undefined
: scenario.startsWith("registry")
? "registry.test/team/cms" ? "registry.test/team/cms"
: "", : "",
}, },
@@ -169,3 +186,19 @@ describe("HTTP release verification", () => {
expect(result.status, result.stderr).toBe(expected); expect(result.status, result.stderr).toBe(expected);
}); });
}); });
it("updates using the saved profile and repository-provided image without arguments", () => {
const r = simulate("saved-prebuilt");
expect(r.status, r.output).toBe(0);
expect(r.calls).toContain(
`docker pull gitlab.epicnabbo.nl/simo/epicnext-cms:${sha}`,
);
expect(r.calls).toContain("https://saved.test/api/health");
expect(r.calls).not.toContain("docker compose build");
});
it("keeps source builds available for a saved source installation", () => {
const r = simulate("saved-source");
expect(r.status, r.output).toBe(0);
expect(r.calls).toContain("docker compose build");
expect(r.calls).not.toContain("docker pull");
});
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more