feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -17,9 +17,6 @@ NODE_ENV=production
|
||||
PORT=3002
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
UV_THREADPOOL_SIZE=16
|
||||
# Non-production preview only; production always returns 404.
|
||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
||||
|
||||
# --- HOTEL & URLS ---
|
||||
HOTEL_NAME=EPIC WEB CONTROL
|
||||
APP_URL=http://localhost:3002
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
pnpm exec lint-staged
|
||||
@@ -1,2 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
@@ -34,66 +34,6 @@ const nextConfig: NextConfig = {
|
||||
productionBrowserSourceMaps: false,
|
||||
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/admin/import",
|
||||
destination: "/admin/studio/furni",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/badges",
|
||||
destination: "/admin/studio/badges",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/furni",
|
||||
destination: "/admin/studio/furni",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/furni/upload",
|
||||
destination: "/admin/studio/upload",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/clothing",
|
||||
destination: "/admin/studio/clothing",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/effects",
|
||||
destination: "/admin/studio/effects",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/pets",
|
||||
destination: "/admin/studio/pets",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/clone",
|
||||
destination: "/admin/studio/clone",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/sync",
|
||||
destination: "/admin/studio/sync",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/repair-icons",
|
||||
destination: "/admin/studio/repair-icons",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/audit",
|
||||
destination: "/admin/studio/audit",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
turbopack: {
|
||||
ignoreIssue: [
|
||||
{
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -60,7 +58,6 @@
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.85.0",
|
||||
"resend": "6.21.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
|
||||
Generated
-127
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,23 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
|
||||
import {
|
||||
discoverLegacyPages,
|
||||
recordedLegacyPages,
|
||||
} from "../src/features/housekeeping/migration/discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
|
||||
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
|
||||
|
||||
const discovered = discoverLegacyPages();
|
||||
const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX);
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
recorded,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
if (discovered.length > 0) {
|
||||
issues.push(
|
||||
`legacy UI routes remain after cutover: ${discovered.map((page) => page.legacyPath).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parityTest = spawnSync(
|
||||
process.execPath,
|
||||
@@ -31,7 +40,7 @@ if (issues.length > 0 || !parityPassed) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
|
||||
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${recorded.length} historical rows valid; legacy UI pages present=${discovered.length}`,
|
||||
);
|
||||
const removed = HOUSEKEEPING_MIGRATION_MATRIX.filter(
|
||||
(row) => row.status === "REMOVED",
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Content advertisement legacy wrappers", () => {
|
||||
"ad.change",
|
||||
{ action: "create", image: "https://example.com/ad.png" },
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
|
||||
});
|
||||
|
||||
it("returns early when image is empty", async () => {
|
||||
|
||||
@@ -31,10 +31,10 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
action: "createAd",
|
||||
error: result.error.messageKey,
|
||||
});
|
||||
revalidatePath("/admin/ads");
|
||||
revalidatePath("/ase/content/media/ads");
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
redirect("/ase/content/media/ads");
|
||||
}
|
||||
|
||||
export async function updateAd(formData: FormData): Promise<void> {
|
||||
@@ -57,10 +57,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
id: Number(raw),
|
||||
error: result.error.messageKey,
|
||||
});
|
||||
revalidatePath(`/admin/ads/${raw}`);
|
||||
revalidatePath(`/ase/content/media/ads/${raw}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
redirect("/ase/content/media/ads");
|
||||
}
|
||||
|
||||
const deleteAdInput = z.object({
|
||||
@@ -82,7 +82,7 @@ export const deleteAd = adminAction(
|
||||
{ action: "delete", id: ctx.data.id.toString() },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Advertisement not found");
|
||||
revalidatePath("/admin/ads");
|
||||
revalidatePath("/ase/content/media/ads");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -37,7 +37,9 @@ describe("sendHotelAlert", () => {
|
||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||
);
|
||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/system/operations/alerts",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns early when message is empty", async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
// admin page. The emulator writes its own alert_logs row on receipt.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/alerts");
|
||||
revalidatePath("/ase/system/operations/alerts");
|
||||
}
|
||||
|
||||
/** Mark every unread ops alert as read. */
|
||||
@@ -67,5 +67,5 @@ export async function markAllAlertsRead(): Promise<void> {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
revalidatePath("/admin/alerts");
|
||||
revalidatePath("/ase/system/operations/alerts");
|
||||
}
|
||||
@@ -22,5 +22,5 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
{ applicationId, decision: "dismiss" },
|
||||
);
|
||||
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
|
||||
revalidatePath("/admin/applications");
|
||||
revalidatePath("/ase/people/staff/applications");
|
||||
}
|
||||
@@ -39,10 +39,10 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
);
|
||||
if (!result.ok) {
|
||||
redirect(
|
||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||
"/ase/content/editorial/articles/new?error=Database error while creating article. Please try again.",
|
||||
);
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
redirect("/ase/content/editorial/articles");
|
||||
}
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
@@ -53,9 +53,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
"article.change",
|
||||
{ action: "update", id, ...articleInput(formData) },
|
||||
);
|
||||
if (!result.ok) redirect("/admin/articles?error=Update failed");
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
redirect("/admin/articles");
|
||||
if (!result.ok)
|
||||
redirect("/ase/content/editorial/articles?error=Update failed");
|
||||
revalidatePath(`/ase/content/editorial/articles/${id}`);
|
||||
redirect("/ase/content/editorial/articles");
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
@@ -66,6 +67,7 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
"article.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
if (!result.ok) redirect("/admin/articles?error=Delete failed");
|
||||
redirect("/admin/articles");
|
||||
if (!result.ok)
|
||||
redirect("/ase/content/editorial/articles?error=Delete failed");
|
||||
redirect("/ase/content/editorial/articles");
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const code = String(formData.get("code") ?? "");
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "badge.upload", {
|
||||
code,
|
||||
file: formData.get("file"),
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/badges?error=Badge%20upload%20failed");
|
||||
}
|
||||
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "badge.give", {
|
||||
userId: formData.get("userId"),
|
||||
badge: formData.get("code"),
|
||||
});
|
||||
revalidatePath("/admin/badges");
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
createBanner as createBannerAction,
|
||||
deleteBanner as deleteBannerAction,
|
||||
updateBanner as updateBannerAction,
|
||||
} from "./banners";
|
||||
|
||||
export async function createBanner(
|
||||
input: Parameters<typeof createBannerAction>[0],
|
||||
) {
|
||||
return createBannerAction(input);
|
||||
}
|
||||
|
||||
export async function updateBanner(
|
||||
input: Parameters<typeof updateBannerAction>[0],
|
||||
) {
|
||||
return updateBannerAction(input);
|
||||
}
|
||||
|
||||
export async function deleteBanner(
|
||||
input: Parameters<typeof deleteBannerAction>[0],
|
||||
) {
|
||||
return deleteBannerAction(input);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ describe("legacy admin ban wrappers", () => {
|
||||
"ban.create",
|
||||
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||
});
|
||||
|
||||
it("returns early when userId is invalid", async () => {
|
||||
@@ -62,6 +62,6 @@ describe("legacy admin ban wrappers", () => {
|
||||
"ban.lift",
|
||||
{ id: 42 },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create ban");
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath("/ase/people/moderation/bans");
|
||||
}
|
||||
|
||||
export async function liftBan(formData: FormData): Promise<void> {
|
||||
@@ -51,5 +51,5 @@ export async function liftBan(formData: FormData): Promise<void> {
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not lift ban");
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath("/ase/people/moderation/bans");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function templateInput(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
subject: String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
body: String(formData.get("body") ?? "").normalize("NFC"),
|
||||
variables: String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
isActive: formData.get("isActive") != null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const input = templateInput(formData);
|
||||
if (!input.name || !input.subject || !input.body) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"email-template.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
if (!result.ok) throw new Error("Email template creation failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/u.test(id)) return;
|
||||
const input = templateInput(formData);
|
||||
if (!input.subject || !input.body) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"email-template.change",
|
||||
{ action: "update", id, ...input },
|
||||
);
|
||||
if (!result.ok) throw new Error("Email template update failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"email-template.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
if (!result.ok) throw new Error("Email template deletion failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
|
||||
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
|
||||
// Both tables are emulator-owned; we only ever read/update existing rows or add new
|
||||
// keys via upsert. We never migrate or drop them.
|
||||
|
||||
function mutationContext(staff: {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}) {
|
||||
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(staff, {
|
||||
isSuperAdmin: false,
|
||||
has: matches,
|
||||
hasAny: (...slugs: string[]) => slugs.some(matches),
|
||||
hasAll: (...slugs: string[]) => slugs.every(matches),
|
||||
}),
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireMutation(
|
||||
staff: { id: number; rank: number; username: string },
|
||||
operation:
|
||||
| "configuration.emulator-setting.update"
|
||||
| "configuration.emulator-text.update",
|
||||
input: { key: string; value: string },
|
||||
): Promise<void> {
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(staff),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
}
|
||||
|
||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 512);
|
||||
if (!key) return;
|
||||
await requireMutation(staff, "configuration.emulator-setting.update", {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
|
||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await requireMutation(staff, "configuration.emulator-text.update", {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
@@ -33,12 +33,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("disbandGuild legacy wrapper", () => {
|
||||
it("keeps rate-limited ACL, service input, and /admin revalidation", async () => {
|
||||
it("keeps rate-limited ACL, service input, and ASE revalidation", async () => {
|
||||
await disbandGuild(form({ id: "9" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
|
||||
guildId: 9,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/community/guilds");
|
||||
});
|
||||
it("keeps invalid IDs as a no-op", async () => {
|
||||
await disbandGuild(form({ id: "0" }));
|
||||
|
||||
@@ -24,5 +24,5 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
if (result.error.code === "NOT_FOUND") return;
|
||||
throw new Error("Could not disband guild");
|
||||
}
|
||||
revalidatePath("/admin/guilds");
|
||||
revalidatePath("/ase/people/community/guilds");
|
||||
}
|
||||
@@ -37,10 +37,8 @@ const helpCenterTicketIdSchema = z.object({
|
||||
|
||||
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||
const id = String(ticketId);
|
||||
revalidatePath("/admin/help-tickets");
|
||||
revalidatePath(`/admin/help-tickets/${id}`);
|
||||
revalidatePath("/mod/help-tickets");
|
||||
revalidatePath(`/mod/help-tickets/${id}`);
|
||||
revalidatePath("/ase/people/support/help-tickets");
|
||||
revalidatePath(`/ase/people/support/help-tickets/${id}`);
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
@@ -76,10 +74,10 @@ export const liftBanFromHelpTicket = adminAction(
|
||||
}
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath("/ase/people/moderation/bans");
|
||||
const removed = Number(result.data.output?.removed ?? 0);
|
||||
const userId = Number(result.data.output?.userId);
|
||||
revalidatePath(`/admin/users/show/${userId}`);
|
||||
revalidatePath(`/ase/people/users/${userId}`);
|
||||
return actionOk({ removed, userId });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("Content help legacy wrappers", () => {
|
||||
"help-question.change",
|
||||
expect.objectContaining({ action: "create", name: "FAQ" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
||||
});
|
||||
it("delegates update and redirects", async () => {
|
||||
await updateHelpQuestion(
|
||||
@@ -56,7 +56,7 @@ describe("Content help legacy wrappers", () => {
|
||||
"help-question.change",
|
||||
expect.objectContaining({ action: "update", id: "42" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
||||
});
|
||||
it("delegates delete and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||
@@ -65,6 +65,6 @@ describe("Content help legacy wrappers", () => {
|
||||
"help-question.change",
|
||||
{ action: "delete", id: "42" },
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
||||
});
|
||||
});
|
||||
@@ -39,13 +39,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
if (!result.ok) {
|
||||
revalidatePath("/admin/help-questions");
|
||||
revalidatePath("/ase/content/help/questions");
|
||||
redirect(
|
||||
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
|
||||
"/ase/content/help/questions/new?error=Unique name collision or database error. Please try again.",
|
||||
);
|
||||
}
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect("/admin/help-questions");
|
||||
revalidatePath("/ase/content/help/questions");
|
||||
redirect("/ase/content/help/questions");
|
||||
}
|
||||
|
||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
@@ -60,10 +60,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
{ action: "update", id, ...input },
|
||||
);
|
||||
if (!result.ok) {
|
||||
revalidatePath(`/admin/help-questions/${id}`);
|
||||
revalidatePath(`/ase/content/help/questions/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
redirect("/ase/content/help/questions");
|
||||
}
|
||||
|
||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
@@ -75,5 +75,5 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
"help-question.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
redirect("/admin/help-questions");
|
||||
redirect("/ase/content/help/questions");
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { asc } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function exportPermissions(): Promise<string> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
|
||||
const perms = await db
|
||||
.select({
|
||||
permission: WebsiteHousekeepingPermissions.permission,
|
||||
minRank: WebsiteHousekeepingPermissions.minRank,
|
||||
description: WebsiteHousekeepingPermissions.description,
|
||||
groupName: WebsiteHousekeepingPermissions.groupName,
|
||||
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
|
||||
})
|
||||
.from(WebsiteHousekeepingPermissions)
|
||||
.orderBy(
|
||||
asc(WebsiteHousekeepingPermissions.groupName),
|
||||
asc(WebsiteHousekeepingPermissions.permission),
|
||||
);
|
||||
|
||||
return JSON.stringify(perms, null, 2);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function run(
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not update IP rules");
|
||||
revalidatePath("/admin/ip");
|
||||
revalidatePath("/ase/people/moderation/ip");
|
||||
}
|
||||
|
||||
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -96,7 +96,9 @@ describe("saveMaintenance", () => {
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(mockReload).toHaveBeenCalledOnce();
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/system/operations/maintenance",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables maintenance mode", async () => {
|
||||
|
||||
@@ -60,5 +60,5 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
revalidatePath("/admin/maintenance");
|
||||
revalidatePath("/ase/system/operations/maintenance");
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
/** Cancel an active marketplace listing (state 1 to 0). */
|
||||
export async function cancelMarketplaceListing(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "marketplace.cancel", {
|
||||
id: formData.get("id"),
|
||||
});
|
||||
revalidatePath("/admin/marketplace");
|
||||
}
|
||||
@@ -60,6 +60,6 @@ describe("Content media legacy wrappers", () => {
|
||||
filename: "photo.png",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/library");
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export async function uploadMedia(
|
||||
);
|
||||
if (!result.ok) throw new Error("Media upload failed");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/ase/content/media/library");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function deleteMedia(name: string): Promise<void> {
|
||||
{ filename: name },
|
||||
);
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/ase/content/media/library");
|
||||
}
|
||||
|
||||
export async function uploadMediaAndReturn(
|
||||
@@ -67,7 +67,7 @@ export async function uploadMediaAndReturn(
|
||||
);
|
||||
if (!result.ok) return "";
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/ase/content/media/library");
|
||||
return typeof result.data.output?.url === "string"
|
||||
? result.data.output.url
|
||||
: "";
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
hiddenGroups: z.array(z.string()),
|
||||
hiddenItems: z.array(z.string()),
|
||||
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
export const saveAdminNavConfig = adminAction(
|
||||
{
|
||||
permission: PERMS.SETTINGS_EDIT,
|
||||
schema,
|
||||
rateLimitKey: "admin-nav-config-save",
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"navigation.update",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new Error("Navigation update failed");
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
},
|
||||
);
|
||||
@@ -39,7 +39,7 @@ describe("deletePhoto", () => {
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
||||
id: 42,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
it("returns early when id is not positive", async () => {
|
||||
|
||||
@@ -18,6 +18,6 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
"photo.delete",
|
||||
{ id },
|
||||
);
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/ase/content/media/photos");
|
||||
revalidatePath("/photos");
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
||||
allowedIps: text(formData, "allowedIps") || undefined,
|
||||
rateLimit: Number(text(formData, "rateLimit") || 300),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
redirect("/admin/radio/api-keys?created=1");
|
||||
revalidatePath("/ase/hotel/radio/api-keys");
|
||||
redirect("/ase/hotel/radio/api-keys?created=1");
|
||||
}
|
||||
|
||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
@@ -28,7 +28,7 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
revalidatePath("/ase/hotel/radio/api-keys");
|
||||
}
|
||||
|
||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
@@ -36,5 +36,5 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
revalidatePath("/ase/hotel/radio/api-keys");
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export async function createTrack(formData: FormData): Promise<void> {
|
||||
sortOrder: Number(text(formData, "sortOrder") || 0),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
revalidatePath("/ase/hotel/radio/autodj");
|
||||
}
|
||||
|
||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
@@ -36,7 +36,7 @@ export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
id: text(formData, "id"),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
revalidatePath("/ase/hotel/radio/autodj");
|
||||
}
|
||||
|
||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
@@ -44,5 +44,5 @@ export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
revalidatePath("/ase/hotel/radio/autodj");
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
comment: text(formData, "comment") || undefined,
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/settings");
|
||||
revalidatePath("/ase/hotel/radio/settings");
|
||||
}
|
||||
|
||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
@@ -42,7 +42,7 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
entries,
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/settings");
|
||||
revalidatePath("/ase/hotel/radio/settings");
|
||||
}
|
||||
|
||||
function bannerInput(formData: FormData) {
|
||||
@@ -62,7 +62,7 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
"radio.banner.create",
|
||||
bannerInput(formData),
|
||||
);
|
||||
revalidatePath("/admin/radio/banners");
|
||||
revalidatePath("/ase/hotel/radio/banners");
|
||||
}
|
||||
|
||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
@@ -71,7 +71,7 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
id: text(formData, "id"),
|
||||
...bannerInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/radio/banners");
|
||||
revalidatePath("/ase/hotel/radio/banners");
|
||||
}
|
||||
|
||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
@@ -79,7 +79,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/banners");
|
||||
revalidatePath("/ase/hotel/radio/banners");
|
||||
}
|
||||
|
||||
function rankInput(formData: FormData) {
|
||||
@@ -98,7 +98,7 @@ export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
"radio.rank.create",
|
||||
rankInput(formData),
|
||||
);
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
revalidatePath("/ase/hotel/radio/ranks");
|
||||
}
|
||||
|
||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
@@ -107,7 +107,7 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
id: text(formData, "id"),
|
||||
...rankInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
revalidatePath("/ase/hotel/radio/ranks");
|
||||
}
|
||||
|
||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
@@ -115,5 +115,5 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
revalidatePath("/ase/hotel/radio/ranks");
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export async function deleteShout(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
|
||||
id: String(formData.get("id") ?? "").trim(),
|
||||
});
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
revalidatePath("/ase/hotel/radio/moderation");
|
||||
}
|
||||
@@ -35,6 +35,6 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
),
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/points");
|
||||
redirect("/admin/radio/points?saved=1");
|
||||
revalidatePath("/ase/hotel/radio/points");
|
||||
redirect("/ase/hotel/radio/points?saved=1");
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-category.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function deleteCategory(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-category.change", {
|
||||
action: "delete",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function createValue(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-value.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function deleteValue(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-value.change", {
|
||||
action: "delete",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,63 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
|
||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, { staffId: staff.id });
|
||||
return;
|
||||
}
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
const input = formInput(formData);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(input.id ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
revalidatePath(`/admin/shop/${String(input.id ?? "")}`);
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
const input = formInput(formData);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "delete",
|
||||
...input,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(input.id ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe("Content tag legacy wrappers", () => {
|
||||
name: "News",
|
||||
backgroundColor: "#ff0000",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
||||
});
|
||||
it("uses the legacy default color", async () => {
|
||||
await createTag(form({ name: "Test" }));
|
||||
@@ -64,7 +64,7 @@ describe("Content tag legacy wrappers", () => {
|
||||
correlationId: "legacy",
|
||||
});
|
||||
await createTag(form({ name: "News" }));
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
||||
});
|
||||
it("delegates update", async () => {
|
||||
await updateTag(
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function createTag(formData: FormData): Promise<void> {
|
||||
"tag.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
revalidatePath("/admin/tags");
|
||||
revalidatePath("/ase/content/editorial/tags");
|
||||
}
|
||||
|
||||
export async function updateTag(formData: FormData): Promise<void> {
|
||||
@@ -44,7 +44,7 @@ export async function updateTag(formData: FormData): Promise<void> {
|
||||
"tag.change",
|
||||
{ action: "update", id, ...input },
|
||||
);
|
||||
revalidatePath("/admin/tags");
|
||||
revalidatePath("/ase/content/editorial/tags");
|
||||
}
|
||||
|
||||
export async function deleteTag(formData: FormData): Promise<void> {
|
||||
@@ -56,5 +56,5 @@ export async function deleteTag(formData: FormData): Promise<void> {
|
||||
"tag.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
revalidatePath("/admin/tags");
|
||||
revalidatePath("/ase/content/editorial/tags");
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export async function createTeam(formData: FormData): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create team");
|
||||
revalidatePath("/admin/teams");
|
||||
revalidatePath("/ase/people/staff/teams");
|
||||
}
|
||||
|
||||
export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
@@ -49,5 +49,5 @@ export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
throw new Error("Could not delete team");
|
||||
}
|
||||
revalidatePath("/admin/teams");
|
||||
revalidatePath("/ase/people/staff/teams");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,52 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
export async function createVoucher(input: {
|
||||
code: string;
|
||||
amount: number;
|
||||
maxUses: number;
|
||||
expiresAt?: string;
|
||||
}): Promise<ActionResult<{ id: string }>> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
const snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"voucher.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk({ id: String(snapshot.output?.id) });
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_create_failed", error);
|
||||
return actionError("Could not create voucher (code may already exist)");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteVoucher(input: {
|
||||
id: string;
|
||||
}): Promise<ActionResult> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "voucher.change", {
|
||||
action: "delete",
|
||||
id: input.id,
|
||||
});
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_delete_failed", error, {
|
||||
voucherId: input.id,
|
||||
});
|
||||
return actionError("Could not delete voucher");
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves VPN payload, /admin revalidation, and redirect", async () => {
|
||||
it("preserves VPN payload, ASE revalidation, and redirect", async () => {
|
||||
await saveVpn(
|
||||
form({
|
||||
vpn_block_enabled: "1",
|
||||
@@ -51,8 +51,8 @@ it("preserves VPN payload, /admin revalidation, and redirect", async () => {
|
||||
apiKey: "abc123",
|
||||
blockMessage: "",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn");
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/vpn");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
|
||||
});
|
||||
|
||||
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
|
||||
@@ -66,5 +66,5 @@ it("preserves fail-soft redirect without claiming a saved revalidation", async (
|
||||
});
|
||||
await saveVpn(form({ vpn_provider: "none" }));
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
||||
.slice(0, 255),
|
||||
},
|
||||
);
|
||||
if (result.ok) revalidatePath("/admin/vpn");
|
||||
if (result.ok) revalidatePath("/ase/people/moderation/vpn");
|
||||
// Preserve fail-soft legacy navigation even when persistence is unavailable.
|
||||
redirect("/admin/vpn?saved=1");
|
||||
redirect("/ase/people/moderation/vpn?saved=1");
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export async function addWord(input: {
|
||||
);
|
||||
if (!result.ok)
|
||||
return actionError("Could not add word (it may already exist)");
|
||||
revalidatePath("/admin/wordfilter");
|
||||
revalidatePath("/ase/people/moderation/word-filter");
|
||||
return actionOk({ id: String(result.data.after?.id ?? "") });
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
return actionError("Could not remove word");
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
revalidatePath("/ase/people/moderation/word-filter");
|
||||
return actionOk();
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function boxInput(formData: FormData) {
|
||||
const position = Number(formData.get("position"));
|
||||
return {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
icon: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position:
|
||||
Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0,
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
function refreshBoxes(): void {
|
||||
revalidatePath("/admin/writeable-boxes");
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
async function executeBox(
|
||||
formData: FormData,
|
||||
action: "create" | "update" | "delete" | "toggle",
|
||||
): Promise<boolean> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (action !== "create" && !/^\d+$/u.test(id)) return false;
|
||||
const input = boxInput(formData);
|
||||
if ((action === "create" || action === "update") && !input.title)
|
||||
return false;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"writeable-box.change",
|
||||
{ action, ...(id ? { id } : {}), ...input, next: formData.get("next") },
|
||||
);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "create")) refreshBoxes();
|
||||
}
|
||||
export async function updateBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "update")) refreshBoxes();
|
||||
}
|
||||
export async function deleteBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "delete")) refreshBoxes();
|
||||
}
|
||||
export async function toggleBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "toggle")) refreshBoxes();
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function getBadgeData({ code }: { code: string }) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [badge] = await db
|
||||
.select({
|
||||
badgeName: WebsiteBadges.badgeName,
|
||||
badgeDescription: WebsiteBadges.badgeDescription,
|
||||
})
|
||||
.from(WebsiteBadges)
|
||||
.where(eq(WebsiteBadges.badgeKey, code))
|
||||
.limit(1);
|
||||
if (!badge) return { ok: false as const, data: null };
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { name: badge.badgeName, desc: badge.badgeDescription },
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateBadge({
|
||||
code,
|
||||
name,
|
||||
desc,
|
||||
}: {
|
||||
code: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
.values({
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/import/badges");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const bannerSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
subtitle: z.string().max(500).optional().default(""),
|
||||
image: z.string().max(500),
|
||||
link: z.string().max(500).optional().default(""),
|
||||
color: z.string().max(20).optional().default(""),
|
||||
isActive: z.coerce.number().int().min(0).max(1).default(1),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
startDate: z.string().max(50).nullable().optional(),
|
||||
endDate: z.string().max(50).nullable().optional(),
|
||||
});
|
||||
|
||||
async function runBanner(
|
||||
ctx: {
|
||||
data: Record<string, unknown>;
|
||||
requestId: unknown;
|
||||
session: { user: { id: number } };
|
||||
},
|
||||
action: "create" | "update" | "delete",
|
||||
) {
|
||||
return contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"banner.change",
|
||||
{ action, ...ctx.data },
|
||||
);
|
||||
}
|
||||
|
||||
export const createBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||
async (ctx) => {
|
||||
const result = await runBanner(ctx, "create");
|
||||
if (!result.ok) throw new ActionError("Banner creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
const updateBannerInput = bannerSchema
|
||||
.partial()
|
||||
.extend({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const updateBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||
async (ctx) => {
|
||||
const result = await runBanner(ctx, "update");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const deleteBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||
async (ctx) => {
|
||||
const result = await runBanner(ctx, "delete");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -6,8 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function revalidateBuilderClub(): void {
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
}
|
||||
|
||||
export async function updateBcPage({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function revalidateCatalog(): void {
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
}
|
||||
|
||||
export async function createCatalogItem(data: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function updateCatalogPage({
|
||||
id,
|
||||
...fields,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function toggleCatalogPage({
|
||||
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function createCatalogPage(input: {
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
const createdId = Number(snapshot.output?.id);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function reorderTreePage(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -85,6 +85,6 @@ export async function deleteTreePage(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,7 +136,7 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content legacy wrappers", () => {
|
||||
describe("Content compatibility wrappers", () => {
|
||||
it("delegates article creation and preserves redirect ordering", async () => {
|
||||
await createArticle(
|
||||
form({
|
||||
@@ -151,7 +151,7 @@ describe("Content legacy wrappers", () => {
|
||||
"article.change",
|
||||
expect.objectContaining({ action: "create", title: "Launch" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/articles");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/editorial/articles");
|
||||
});
|
||||
|
||||
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||
@@ -165,7 +165,7 @@ describe("Content legacy wrappers", () => {
|
||||
"ad.change",
|
||||
expect.objectContaining({ action: "create" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
|
||||
});
|
||||
|
||||
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
||||
@@ -308,22 +308,14 @@ describe("Content legacy wrappers", () => {
|
||||
for (const path of [
|
||||
"src/actions/admin-ads.ts",
|
||||
"src/actions/admin-articles.ts",
|
||||
"src/actions/admin-banners.ts",
|
||||
"src/actions/admin-email-templates.ts",
|
||||
"src/actions/admin-help.ts",
|
||||
"src/actions/admin-media.ts",
|
||||
"src/actions/admin-nav-menu.ts",
|
||||
"src/actions/admin-photos.ts",
|
||||
"src/actions/admin-tags.ts",
|
||||
"src/actions/admin-theme.ts",
|
||||
"src/actions/admin-writeable-boxes.ts",
|
||||
"src/actions/banners.ts",
|
||||
"src/actions/events.ts",
|
||||
"src/actions/polls.ts",
|
||||
"src/actions/prefixes.ts",
|
||||
"src/actions/save-favicon.ts",
|
||||
"src/actions/save-logo.ts",
|
||||
"src/actions/translations.ts",
|
||||
"src/actions/emulator.ts",
|
||||
]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
@@ -335,21 +327,4 @@ describe("Content legacy wrappers", () => {
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes the real banner manager through the required compatibility action", () => {
|
||||
const source = readFileSync(
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain('from "@/actions/admin-banners"');
|
||||
expect(source).not.toContain('from "@/actions/banners"');
|
||||
});
|
||||
|
||||
it("exports explicit async banner wrappers accepted by Next server actions", () => {
|
||||
const source = readFileSync("src/actions/admin-banners.ts", "utf8");
|
||||
expect(source).not.toMatch(/export\s*\{/u);
|
||||
for (const action of ["createBanner", "updateBanner", "deleteBanner"]) {
|
||||
expect(source).toContain(`export async function ${action}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,38 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { deleteImportedItem } from "@/lib/services/furni-import";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
||||
|
||||
export const deleteImportedFurni = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||
async (ctx) =>
|
||||
actionOk(
|
||||
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
),
|
||||
);
|
||||
|
||||
const translateToggleSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
/** Persist the global "translate furniture names" setting from the studio. */
|
||||
export const setFurnidataTranslateEnabled = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: translateToggleSchema },
|
||||
async (ctx) => {
|
||||
const value = ctx.data.enabled ? "1" : "0";
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: "furnidata_translate_enabled", value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await siteSettings.reload();
|
||||
return actionOk({ enabled: ctx.data.enabled });
|
||||
},
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const updateSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const updateItemsBase = adminAction(
|
||||
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||
async (context) => {
|
||||
const { id, fields } = context.data;
|
||||
await executeLegacyEconomyMutation(
|
||||
{ id: Number(context.session.user.id) },
|
||||
"items-base.update",
|
||||
{ id, fields },
|
||||
);
|
||||
revalidatePath("/admin/items");
|
||||
revalidatePath(`/admin/items/${id}`);
|
||||
revalidatePath("/admin/catalog");
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { asc, count, desc, eq, gte, ne, sql } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export interface MultiAccountCluster {
|
||||
key: string;
|
||||
label: string;
|
||||
accountCount: number;
|
||||
accounts: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
online: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function detectMultiAccounts({
|
||||
minAccounts,
|
||||
limit,
|
||||
}: {
|
||||
minAccounts: number;
|
||||
limit: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.USERS_VIEW);
|
||||
const clusters: MultiAccountCluster[] = [];
|
||||
|
||||
const accountCount = count(User.id);
|
||||
const ipGroups = await db
|
||||
.select({
|
||||
ipCurrent: User.ipCurrent,
|
||||
accountCount,
|
||||
})
|
||||
.from(User)
|
||||
.where(ne(User.ipCurrent, ""))
|
||||
.groupBy(User.ipCurrent)
|
||||
.having(gte(accountCount, minAccounts))
|
||||
.orderBy(desc(sql`COUNT(${User.id})`))
|
||||
.limit(limit);
|
||||
|
||||
for (const group of ipGroups) {
|
||||
const users = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
rank: User.rank,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.ipCurrent, group.ipCurrent))
|
||||
.orderBy(asc(User.id));
|
||||
clusters.push({
|
||||
key: group.ipCurrent,
|
||||
label: `IP: ${group.ipCurrent}`,
|
||||
accountCount: Number(group.accountCount),
|
||||
accounts: users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
rank: u.rank,
|
||||
online: u.online,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, data: { clusters } };
|
||||
}
|
||||
@@ -109,14 +109,16 @@ describe("legacy user safe-action wrappers", () => {
|
||||
});
|
||||
|
||||
describe("legacy application and word-filter wrappers", () => {
|
||||
it("keeps tolerant application dismissal and /admin revalidation", async () => {
|
||||
it("keeps tolerant application dismissal and ASE revalidation", async () => {
|
||||
await dismissApplication(form({ id: "9" }));
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 1 }),
|
||||
"application.decide",
|
||||
{ applicationId: "9", decision: "dismiss" },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/people/staff/applications",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves application and wordfilter IDs above Number.MAX_SAFE_INTEGER", async () => {
|
||||
@@ -130,7 +132,7 @@ describe("legacy application and word-filter wrappers", () => {
|
||||
{ action: "delete", id: "9007199254740993" },
|
||||
]);
|
||||
});
|
||||
it("preserves word-filter ActionResult shapes and /admin revalidation", async () => {
|
||||
it("preserves word-filter ActionResult shapes and ASE revalidation", async () => {
|
||||
await expect(addWord({ word: "spam" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { id: "12" },
|
||||
@@ -143,6 +145,8 @@ describe("legacy application and word-filter wrappers", () => {
|
||||
{ action: "add", word: "spam" },
|
||||
{ action: "delete", id: "12" },
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/people/moderation/word-filter",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -188,18 +188,17 @@ describe("legacy People support and moderation wrappers", () => {
|
||||
{ ticketId: "9007199254740993", status: "reopen" },
|
||||
{ ticketId: "9007199254740993" },
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/admin/help-tickets/9007199254740993",
|
||||
"/ase/people/support/help-tickets",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/mod/help-tickets/9007199254740993",
|
||||
"/ase/people/support/help-tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/help/tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
|
||||
});
|
||||
|
||||
it("delegates every CFH and moderation transport action", async () => {
|
||||
|
||||
@@ -79,7 +79,9 @@ describe("legacy wrapper failure compatibility", () => {
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/staff/teams");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/people/moderation/word-filter",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const createPrefixSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
color: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
effect: z.string().optional(),
|
||||
active: z.coerce.number().int().min(0).max(1).default(1),
|
||||
});
|
||||
const updatePrefixSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
text: z.string().min(1),
|
||||
color: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
effect: z.string().optional(),
|
||||
active: z.coerce.number().int().min(0).max(1).optional(),
|
||||
});
|
||||
const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) });
|
||||
const removeBlacklistWordSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
const updatePrefixSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
async function run(
|
||||
ctx: {
|
||||
data: Record<string, unknown>;
|
||||
requestId: unknown;
|
||||
session: { user: { id: number } };
|
||||
},
|
||||
operation:
|
||||
| "prefix.change"
|
||||
| "prefix-blacklist.change"
|
||||
| "prefix-settings.update",
|
||||
input: Record<string, unknown>,
|
||||
) {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok && result.error.code === "NOT_FOUND")
|
||||
throw new ActionError("User not found");
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export const createPrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||
(ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data }),
|
||||
);
|
||||
export const updatePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||
(ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data }),
|
||||
);
|
||||
export const deletePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
||||
(ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data }),
|
||||
);
|
||||
export const addBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||
(ctx) => run(ctx, "prefix-blacklist.change", { action: "add", ...ctx.data }),
|
||||
);
|
||||
export const removeBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
||||
(ctx) =>
|
||||
run(ctx, "prefix-blacklist.change", { action: "remove", ...ctx.data }),
|
||||
);
|
||||
export const updatePrefixSettings = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
||||
(ctx) => run(ctx, "prefix-settings.update", ctx.data),
|
||||
);
|
||||
@@ -9,7 +9,7 @@ export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room-item.update", payload);
|
||||
const roomId = Number(payload.roomId);
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
revalidatePath(`/ase/hotel/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function bulkDeleteRoomItems(input: {
|
||||
@@ -18,7 +18,7 @@ export async function bulkDeleteRoomItems(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
|
||||
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
|
||||
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function deleteRoomItem(input: {
|
||||
@@ -27,7 +27,7 @@ export async function deleteRoomItem(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room-item.delete", input);
|
||||
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
|
||||
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function roomRconAction(input: {
|
||||
@@ -41,7 +41,7 @@ export async function roomRconAction(input: {
|
||||
export async function deleteRoom(input: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||
await executeLegacyHotelMutation(staff, "room.delete", input);
|
||||
revalidatePath("/admin/rooms");
|
||||
revalidatePath("/ase/hotel/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom(input: {
|
||||
@@ -53,5 +53,5 @@ export async function updateRoom(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room.update", input);
|
||||
revalidatePath(`/admin/rooms/${input.id}`);
|
||||
revalidatePath(`/ase/hotel/rooms/${input.id}`);
|
||||
}
|
||||
@@ -107,5 +107,5 @@ export async function deleteFavicon(): Promise<{
|
||||
|
||||
function siteRevalidate(): void {
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
revalidatePath("/ase/content/brand/favicon");
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function deleteSoundtrack({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
|
||||
export async function updateSoundtrack(input: {
|
||||
id: number;
|
||||
name: string;
|
||||
author: string;
|
||||
track: string;
|
||||
length: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { CLIENT_TRANSLATION_FILES } from "@/lib/client-translation-files";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
const saveClientTranslationsSchema = z.object({
|
||||
fileId: z.enum(
|
||||
CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]],
|
||||
),
|
||||
data: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const saveTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"translation.cms.save",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Translation save failed");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const saveClientTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"translation.client.save",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Translation save failed");
|
||||
return actionOk({
|
||||
commentsLost: result.data.output?.commentsLost === true,
|
||||
unpatchedKeys: Array.isArray(result.data.output?.unpatchedKeys)
|
||||
? result.data.output.unpatchedKeys
|
||||
: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,53 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db, UserWatch } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const toggleWatchSchema = z.object({
|
||||
targetUserId: z.coerce.number().int().positive(),
|
||||
reason: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle a watch entry for the calling staff on the given target user.
|
||||
* If a row already exists it's removed; otherwise it's created with the
|
||||
* provided reason (defaulting to empty). Returns the resulting state so
|
||||
* the UI can flip the badge without re-fetching.
|
||||
*/
|
||||
export const toggleUserWatch = adminAction(
|
||||
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
|
||||
async (ctx) => {
|
||||
const staffId = ctx.session.user.id;
|
||||
const { targetUserId, reason } = ctx.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: UserWatch.id })
|
||||
.from(UserWatch)
|
||||
.where(
|
||||
and(
|
||||
eq(UserWatch.staffId, staffId),
|
||||
eq(UserWatch.targetUserId, targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.delete(UserWatch).where(eq(UserWatch.id, existing.id));
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: false });
|
||||
}
|
||||
|
||||
await db.insert(UserWatch).values({
|
||||
staffId,
|
||||
targetUserId,
|
||||
reason: reason ?? "",
|
||||
});
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: true });
|
||||
},
|
||||
);
|
||||
@@ -8,7 +8,7 @@ import { TopHeader } from "@/components/top-header";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||
* Public site chrome. Route group `(site)` keeps this off `/ase` and `/client`,
|
||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||
*/
|
||||
export default async function SiteLayout({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,71 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { updateAd } from "@/actions/admin-ads";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, WebsiteAds } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { EditAdDeleteButton } from "../edit-ad-delete-button";
|
||||
|
||||
export default async function EditAd({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let ad: typeof WebsiteAds.$inferSelect | null = null;
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(WebsiteAds)
|
||||
.where(eq(WebsiteAds.id, BigInt(id)))
|
||||
.limit(1);
|
||||
ad = row ?? null;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!ad) notFound();
|
||||
|
||||
const t = await getTranslations("pages.admin.ads");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("edit")} #{String(ad.id)}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={updateAd} className="admin-card">
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<input
|
||||
name="image"
|
||||
defaultValue={ad.image}
|
||||
placeholder={t("form.imagePlaceholder")}
|
||||
required
|
||||
maxLength={255}
|
||||
/>
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</form>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<EditAdDeleteButton id={String(ad.id)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteAd } from "@/actions/admin-ads";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export type AdRow = {
|
||||
id: string;
|
||||
image: string;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export function AdsTable({ ads }: { ads: AdRow[] }) {
|
||||
const t = useTranslations("pages.admin.ads");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleDelete(row: AdRow) {
|
||||
const ok = await confirm({
|
||||
title: t("delete"),
|
||||
description: t("confirmDelete"),
|
||||
confirmLabel: t("delete"),
|
||||
cancelLabel: t("form.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteAd({ id: row.id }), {
|
||||
successMessage: t("deleteSuccess"),
|
||||
errorMessage: t("deleteError"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colPreview")}</th>
|
||||
<th>{t("colImage")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th>{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image}
|
||||
alt=""
|
||||
style={{ maxHeight: 40, maxWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{ad.createdAt ?? "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(ad)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteAd } from "@/actions/admin-ads";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export function EditAdDeleteButton({ id }: { id: string }) {
|
||||
const t = useTranslations("pages.admin.ads");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirm({
|
||||
title: t("delete"),
|
||||
description: t("confirmDelete"),
|
||||
confirmLabel: t("delete"),
|
||||
cancelLabel: t("form.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteAd({ id }), {
|
||||
successMessage: t("deleteSuccess"),
|
||||
errorMessage: t("deleteError"),
|
||||
redirectTo: "/admin/ads",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("form.deleteAd")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createAd } from "@/actions/admin-ads";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function NewAd() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
New advertisement
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={createAd} className="admin-card">
|
||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||
<Button type="submit" variant="default">
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, WebsiteAds } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { AdsTable } from "./ads-table";
|
||||
|
||||
export default async function AdminAds() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.ads");
|
||||
let ads: (typeof WebsiteAds.$inferSelect)[] = [];
|
||||
try {
|
||||
ads = await db
|
||||
.select()
|
||||
.from(WebsiteAds)
|
||||
.orderBy(desc(WebsiteAds.id))
|
||||
.limit(500);
|
||||
} catch {
|
||||
ads = [];
|
||||
}
|
||||
|
||||
const latest = ads[0]?.createdAt ? formatDate(ads[0].createdAt, "date") : "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/ads/new">+ {t("newAd")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" />
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentAd")}
|
||||
icon="🕒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("allAds")}</h2>
|
||||
{ads.length === 0 ? (
|
||||
<div className="admin-empty">{t("noAds")}</div>
|
||||
) : (
|
||||
<AdsTable
|
||||
ads={ads.map((ad) => ({
|
||||
id: String(ad.id),
|
||||
image: ad.image,
|
||||
createdAt: ad.createdAt ? formatDate(ad.createdAt, "date") : null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "../loading";
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,84 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function EditArticle({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const [article] = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
slug: WebsiteArticles.slug,
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
fullStory: WebsiteArticles.fullStory,
|
||||
image: WebsiteArticles.image,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, BigInt(id)))
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
if (!article) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("form.title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
await updateArticle(fd);
|
||||
}}
|
||||
defaultValues={{
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
image: article.image,
|
||||
shortStory: article.shortStory,
|
||||
fullStory: article.fullStory,
|
||||
}}
|
||||
/>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(article.id)} />
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.deleteArticle")}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import Link from "@/components/link";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function NewArticle({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("form.title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm action={createArticle} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,23 +0,0 @@
|
||||
import { asc } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db, WebsiteBanner } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { BannersManager } from "./banners-manager";
|
||||
|
||||
export default async function BannersPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const banners = await db
|
||||
.select()
|
||||
.from(WebsiteBanner)
|
||||
.orderBy(asc(WebsiteBanner.sortOrder));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BannersManager banners={banners} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,44 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function CatalogDetailLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<Skeleton className="h-5 w-40 mt-2" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
{Array.from({ length: 4 }, (_, i) => i).map((n) => (
|
||||
<Skeleton
|
||||
key={`skeleton-tab-${n}`}
|
||||
className="h-9 w-24 rounded-md"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
|
||||
<div key={`skeleton-field-${n}`} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,67 +0,0 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { BcPageDetail } from "../bc-manager";
|
||||
|
||||
export default async function BcPageDetailPage({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id: idStr } = await paramsPromise;
|
||||
const id = parseInt(idStr, 10);
|
||||
if (Number.isNaN(id)) notFound();
|
||||
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
|
||||
const [bcPage] = await db
|
||||
.select()
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!bcPage) notFound();
|
||||
|
||||
const bcItems = await db
|
||||
.select()
|
||||
.from(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, id))
|
||||
.orderBy(asc(CatalogItemsBc.orderNumber));
|
||||
|
||||
return (
|
||||
<BcPageDetail
|
||||
page={{
|
||||
id: bcPage.id,
|
||||
parentId: bcPage.parentId,
|
||||
caption: bcPage.caption,
|
||||
pageLayout: bcPage.pageLayout,
|
||||
iconColor: bcPage.iconColor,
|
||||
iconImage: bcPage.iconImage,
|
||||
orderNum: bcPage.orderNum,
|
||||
visible: bcPage.visible,
|
||||
enabled: bcPage.enabled,
|
||||
pageHeadline: bcPage.pageHeadline,
|
||||
pageTeaser: bcPage.pageTeaser,
|
||||
pageSpecial: bcPage.pageSpecial ?? "",
|
||||
pageText1: bcPage.pageText1 ?? "",
|
||||
pageText2: bcPage.pageText2 ?? "",
|
||||
pageTextDetails: bcPage.pageTextDetails ?? "",
|
||||
pageTextTeaser: bcPage.pageTextTeaser ?? "",
|
||||
}}
|
||||
items={bcItems.map((i) => ({
|
||||
id: i.id,
|
||||
pageId: i.pageId,
|
||||
itemIds: i.itemIds,
|
||||
catalogName: i.catalogName,
|
||||
orderNumber: i.orderNumber,
|
||||
extradata: i.extradata,
|
||||
}))}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function BuilderClubPage() {
|
||||
redirect("/admin/catalog?catalog=bc");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const CatalogManagerDialog = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
|
||||
(m) => ({
|
||||
default: m.CatalogManagerDialog,
|
||||
}),
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user