feat(housekeeping): deliver economy vertical
This commit is contained in:
1 parent
1ec05cda39
commit
9c4b973faf
62 files changed
+6306
-1285
No files matched your search
@@ -1,76 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { toBadgeGif } from "@/lib/images/badge-gif";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Writes a badge image to the configured emulator badge directory. The path is
|
||||
// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's
|
||||
// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only
|
||||
// ever stores .gif badges, so every upload is normalised to `<code>.gif`.
|
||||
|
||||
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
|
||||
|
||||
function back(param: string, value: string): never {
|
||||
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
|
||||
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
|
||||
const dir = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!dir) {
|
||||
back("error", "Badge upload directory not configured");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!CODE_RE.test(code)) {
|
||||
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
back("error", "No file uploaded");
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
back("error", "Uploaded file is empty");
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
back("error", "File too large (max 1MB)");
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(file.type)) {
|
||||
back("error", "File must be a GIF or PNG image");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "");
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const gif = await toBadgeGif(buffer);
|
||||
const baseDir = path.resolve(dir);
|
||||
const target = path.resolve(baseDir, `${code}.gif`);
|
||||
if (!target.startsWith(baseDir + path.sep)) {
|
||||
back("error", "Invalid path");
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, gif);
|
||||
await executeLegacyEconomyMutation(staff, "badge.upload", {
|
||||
code,
|
||||
file: formData.get("file"),
|
||||
});
|
||||
} catch {
|
||||
back("error", "Could not process or write the badge file");
|
||||
redirect("/admin/badges?error=Badge%20upload%20failed");
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "badge_upload",
|
||||
description: `Uploaded badge image "${code}.gif"`,
|
||||
targetType: "badge",
|
||||
});
|
||||
|
||||
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||
}
|
||||
@@ -1,47 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, max } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, UsersBadges } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 32);
|
||||
if (!(userId > 0) || code.length === 0) return;
|
||||
|
||||
// Fire the emulator command so the badge appears live for online users.
|
||||
await rcon.giveBadge(userId, code);
|
||||
|
||||
// Persist the badge directly so it survives a relog / offline grant.
|
||||
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||
// against duplicates and compute the next free slot ourselves.
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const [agg] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the RCON grant already succeeded for online users.
|
||||
}
|
||||
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "badge.give", {
|
||||
userId: formData.get("userId"),
|
||||
badge: formData.get("code"),
|
||||
});
|
||||
revalidatePath("/admin/badges");
|
||||
}
|
||||
@@ -1,44 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { db, MarketplaceItems } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Cancel an active marketplace listing (state 1 → 0). */
|
||||
/** Cancel an active marketplace listing (state 1 to 0). */
|
||||
export async function cancelMarketplaceListing(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const [listing] = await db
|
||||
.select({
|
||||
id: MarketplaceItems.id,
|
||||
state: MarketplaceItems.state,
|
||||
userId: MarketplaceItems.userId,
|
||||
itemId: MarketplaceItems.itemId,
|
||||
price: MarketplaceItems.price,
|
||||
})
|
||||
.from(MarketplaceItems)
|
||||
.where(eq(MarketplaceItems.id, id))
|
||||
.limit(1);
|
||||
if (listing?.state !== 1) return;
|
||||
|
||||
await db
|
||||
.update(MarketplaceItems)
|
||||
.set({ state: 0 })
|
||||
.where(eq(MarketplaceItems.id, id));
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "marketplace_cancel",
|
||||
description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`,
|
||||
targetType: "marketplace",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "marketplace.cancel", {
|
||||
id: formData.get("id"),
|
||||
});
|
||||
revalidatePath("/admin/marketplace");
|
||||
}
|
||||
@@ -1,117 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const priorityRaw = Number(formData.get("priority"));
|
||||
const priority =
|
||||
Number.isFinite(priorityRaw) && priorityRaw > 0
|
||||
? Math.floor(priorityRaw)
|
||||
: 1;
|
||||
if (!name || !badge) return;
|
||||
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(WebsiteRareValueCategories).values({
|
||||
name,
|
||||
badge,
|
||||
priority,
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
||||
}
|
||||
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> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
// Remove the category's values first to avoid orphaned rows.
|
||||
await db
|
||||
.delete(WebsiteRareValues)
|
||||
.where(eq(WebsiteRareValues.categoryId, id));
|
||||
await db
|
||||
.delete(WebsiteRareValueCategories)
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
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> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
if (!categoryId) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
|
||||
const itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId =
|
||||
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
String(formData.get("currencyType") ?? "diamonds")
|
||||
.trim()
|
||||
.slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
await db.insert(WebsiteRareValues).values({
|
||||
categoryId,
|
||||
itemId,
|
||||
name,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
});
|
||||
} catch {
|
||||
// DB error — ignore.
|
||||
}
|
||||
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> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-value.change", {
|
||||
action: "delete",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
+18
-135
@@ -1,180 +1,63 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
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 { db, WebsiteShopArticles } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Website store packages (website_shop_articles). This CMS-owned table backs
|
||||
// the public store; rows here are the buyable packages, not orders. The closest
|
||||
// "orders" record is website_paypal_transactions, exposed read-only by the page.
|
||||
|
||||
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
|
||||
function optUInt(formData: FormData, key: string): number | null {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/** Parse a required non-negative UnsignedInt, falling back to 0. */
|
||||
function reqUInt(formData: FormData, key: string): number {
|
||||
const n = optUInt(formData, key);
|
||||
return n ?? 0;
|
||||
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);
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
const costs = reqUInt(formData, "costs");
|
||||
try {
|
||||
const [result] = (await db.insert(WebsiteShopArticles).values({
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs,
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(result.insertId),
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, {
|
||||
staffId: staff.id,
|
||||
name,
|
||||
});
|
||||
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
|
||||
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 id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const input = formInput(formData);
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteShopArticles)
|
||||
.set({
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteShopArticles.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_update",
|
||||
description: `Updated shop package #${id} ("${name}")`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(id),
|
||||
articleId: String(input.id ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/shop/${id}`);
|
||||
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 id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const input = formInput(formData);
|
||||
try {
|
||||
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_delete",
|
||||
description: `Deleted shop package #${id}`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "delete",
|
||||
...input,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(id),
|
||||
articleId: String(input.id ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { db, WebsiteShopVouchers } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
type ActionResult,
|
||||
@@ -20,44 +17,15 @@ export async function createVoucher(input: {
|
||||
maxUses: number;
|
||||
expiresAt?: string;
|
||||
}): Promise<ActionResult<{ id: string }>> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const code = String(input.code ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(input.amount);
|
||||
const maxUsesRaw = Number(input.maxUses);
|
||||
const maxUses =
|
||||
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||
|
||||
if (!code || !(amount > 0)) {
|
||||
return actionError("Code and a positive amount are required");
|
||||
}
|
||||
|
||||
let expiresAt: Date | null = null;
|
||||
const expiresRaw = String(input.expiresAt ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
const [result] = (await db.insert(WebsiteShopVouchers).values({
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
useCount: 0,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
const snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"voucher.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk({ id: String(result.insertId) });
|
||||
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)");
|
||||
@@ -67,18 +35,17 @@ export async function createVoucher(input: {
|
||||
export async function deleteVoucher(input: {
|
||||
id: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||
if (!id) return actionError("Missing voucher id");
|
||||
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
|
||||
await executeLegacyEconomyMutation(staff, "voucher.change", {
|
||||
action: "delete",
|
||||
id: input.id,
|
||||
});
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_delete_failed", error, {
|
||||
voucherId: String(id),
|
||||
voucherId: input.id,
|
||||
});
|
||||
return actionError("Could not delete voucher");
|
||||
}
|
||||
|
||||
+41
-220
@@ -1,50 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const BC_PAGE_FIELDS = [
|
||||
"caption",
|
||||
"parentId",
|
||||
"pageLayout",
|
||||
"enabled",
|
||||
"visible",
|
||||
"orderNum",
|
||||
"iconImage",
|
||||
"iconColor",
|
||||
"pageHeadline",
|
||||
"pageTeaser",
|
||||
"pageSpecial",
|
||||
"pageText1",
|
||||
"pageText2",
|
||||
"pageTextDetails",
|
||||
"pageTextTeaser",
|
||||
] as const;
|
||||
|
||||
const BC_ITEM_FIELDS = [
|
||||
"itemIds",
|
||||
"catalogName",
|
||||
"orderNumber",
|
||||
"extradata",
|
||||
"pageId",
|
||||
] as const;
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
function revalidateBuilderClub(): void {
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function updateBcPage({
|
||||
@@ -52,38 +15,22 @@ export async function updateBcPage({
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "bc-page.change", {
|
||||
action: "update",
|
||||
id,
|
||||
...fields,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteBcItem({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "bc-item.change", {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
@@ -98,23 +45,12 @@ export async function updateBcItem({
|
||||
extradata?: string;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogItemsBc)
|
||||
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||
.where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "bc-item.change", {
|
||||
action: "update",
|
||||
id,
|
||||
...data,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
@@ -129,18 +65,13 @@ export async function createBcItem({
|
||||
extradata: string;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${createdId}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: createdId,
|
||||
const snapshot = await executeLegacyEconomyMutation(staff, "bc-item.change", {
|
||||
action: "create",
|
||||
pageId,
|
||||
...data,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
|
||||
}
|
||||
|
||||
export async function toggleBcPage({
|
||||
@@ -150,22 +81,12 @@ export async function toggleBcPage({
|
||||
id: number;
|
||||
field: "enabled" | "visible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
visible: CatalogPagesBc.visible,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "bc-page.change", {
|
||||
action: field === "enabled" ? "toggle-enabled" : "toggle-visible",
|
||||
id,
|
||||
});
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
@@ -180,52 +101,12 @@ export async function createBcPage(input: {
|
||||
orderNum?: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [result] = await db.insert(CatalogPagesBc).values({
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
const snapshot = await executeLegacyEconomyMutation(staff, "bc-page.change", {
|
||||
action: "create",
|
||||
...input,
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
description: `Created BC catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPagesBc.id, pageId));
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
|
||||
}
|
||||
|
||||
export async function reorderBcTreePage(input: {
|
||||
@@ -233,24 +114,9 @@ export async function reorderBcTreePage(input: {
|
||||
newParentId?: number;
|
||||
newOrderNum: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (input.newParentId !== undefined) {
|
||||
try {
|
||||
await moveBcPage(input.pageId, input.newParentId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : "Invalid move",
|
||||
};
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "bc-page.reorder", input);
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -258,53 +124,8 @@ export async function deleteBcTreePage(input: {
|
||||
pageId: number;
|
||||
mode: "reparent" | "cascade";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
|
||||
if (input.mode === "reparent") {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: page.parentId })
|
||||
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
});
|
||||
} else {
|
||||
const toDelete: number[] = [input.pageId];
|
||||
const queue: number[] = [input.pageId];
|
||||
while (queue.length > 0) {
|
||||
const children = await db
|
||||
.select({ id: CatalogPagesBc.id })
|
||||
.from(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||
});
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "bc-page.delete-tree", input);
|
||||
revalidateBuilderClub();
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
+62
-388
@@ -1,108 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
type EconomyMutationSnapshot,
|
||||
executeLegacyEconomyMutation,
|
||||
} from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { translateItemsSchema } from "@/lib/validators/catalog";
|
||||
|
||||
const CATALOG_ITEM_FIELDS = [
|
||||
"pageId",
|
||||
"itemIds",
|
||||
"catalogName",
|
||||
"costCredits",
|
||||
"costPoints",
|
||||
"pointsType",
|
||||
"amount",
|
||||
"orderNumber",
|
||||
"offerId",
|
||||
"songId",
|
||||
"limitedSells",
|
||||
"limitedStack",
|
||||
"extradata",
|
||||
"haveOffer",
|
||||
"clubOnly",
|
||||
] as const;
|
||||
|
||||
const ITEMS_BASE_FIELDS = [
|
||||
"publicName",
|
||||
"itemName",
|
||||
"type",
|
||||
"width",
|
||||
"length",
|
||||
"stackHeight",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionType",
|
||||
"interactionModesCount",
|
||||
"vendingIds",
|
||||
"customparams",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
"clothingOnWalk",
|
||||
] as const;
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
||||
async function insertCatalogItemRow(data: {
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
}): Promise<number> {
|
||||
const pageIdStr = String(data.pageId);
|
||||
return allocateCatalogItemId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO catalog_items (
|
||||
id, page_id, item_ids, catalog_name,
|
||||
cost_credits, cost_points, points_type, amount,
|
||||
order_number, offer_id, song_id,
|
||||
limited_sells, limited_stack, extradata, have_offer, club_only
|
||||
) VALUES (
|
||||
${nextId}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName},
|
||||
${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount},
|
||||
${data.orderNumber}, ${data.offerId}, ${data.songId},
|
||||
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
|
||||
${data.haveOffer}, ${data.clubOnly}
|
||||
)
|
||||
`);
|
||||
return nextId;
|
||||
});
|
||||
function revalidateCatalog(): void {
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function createCatalogItem(data: {
|
||||
@@ -123,39 +30,16 @@ export async function createCatalogItem(data: {
|
||||
clubOnly: "0" | "1";
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
let catalogName = data.catalogName.trim();
|
||||
if (!catalogName) {
|
||||
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||
if (firstId > 0) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, firstId))
|
||||
.limit(1);
|
||||
catalogName = base?.publicName || base?.itemName || String(firstId);
|
||||
}
|
||||
}
|
||||
const id = await insertCatalogItemRow({ ...data, catalogName });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_create",
|
||||
description: `Created catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id } };
|
||||
const snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"catalog-item.change",
|
||||
{ action: "create", ...data },
|
||||
);
|
||||
revalidateCatalog();
|
||||
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
|
||||
}
|
||||
|
||||
/** Bulk create with one RCON refresh at the end. */
|
||||
export async function bulkCreateCatalogItems({
|
||||
pageId,
|
||||
rows,
|
||||
}: {
|
||||
export async function bulkCreateCatalogItems(input: {
|
||||
pageId: number;
|
||||
rows: Array<{
|
||||
baseId: number;
|
||||
@@ -163,302 +47,92 @@ export async function bulkCreateCatalogItems({
|
||||
points?: number;
|
||||
pointsType?: number;
|
||||
}>;
|
||||
}) {
|
||||
}): Promise<
|
||||
| { ok: true; data: { created: number; failed: number } }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (rows.length === 0) {
|
||||
return { ok: true as const, data: { created: 0, failed: 0 } };
|
||||
let snapshot: EconomyMutationSnapshot;
|
||||
try {
|
||||
snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"catalog-item.bulk-create",
|
||||
input,
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, error: "Bulk import failed" };
|
||||
}
|
||||
if (rows.length > 500) {
|
||||
return { ok: false as const, error: "Max 500 items per bulk import" };
|
||||
}
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await insertCatalogItemRow({
|
||||
pageId,
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: 1,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
});
|
||||
created++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_bulk_create",
|
||||
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: pageId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
return { ok: true as const, data: { created, failed } };
|
||||
revalidateCatalog();
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
created: Number(snapshot.output?.created ?? 0),
|
||||
failed: Number(snapshot.output?.failed ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||
targetType: "catalog_item",
|
||||
await executeLegacyEconomyMutation(staff, "catalog-item.change", {
|
||||
action: "delete",
|
||||
ids,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidateCatalog();
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function moveCatalogItems({
|
||||
ids,
|
||||
targetPageId,
|
||||
}: {
|
||||
export async function moveCatalogItems(input: {
|
||||
ids: number[];
|
||||
targetPageId: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (ids.length === 0) {
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
const pageIdStr = String(targetPageId);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}
|
||||
WHERE id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`);
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-item.move", input);
|
||||
revalidateCatalog();
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function reorderCatalogItems({
|
||||
orders,
|
||||
}: {
|
||||
export async function reorderCatalogItems(input: {
|
||||
orders: Array<{ id: number; orderNumber: number }>;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ orderNumber })
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-item.reorder", input);
|
||||
revalidateCatalog();
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function updateCatalogItem({
|
||||
id,
|
||||
catalogFields,
|
||||
baseItem,
|
||||
}: {
|
||||
export async function updateCatalogItem(input: {
|
||||
id: number;
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
||||
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
|
||||
// page_id is often VARCHAR — update it via raw SQL when present.
|
||||
const pageIdRaw = safeCatalog.pageId;
|
||||
if (pageIdRaw !== undefined) {
|
||||
const pageIdStr = String(pageIdRaw);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||
`);
|
||||
delete safeCatalog.pageId;
|
||||
}
|
||||
|
||||
if (Object.keys(safeCatalog).length > 0) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
if (baseItem) {
|
||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, baseItem.id));
|
||||
}
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
description: `Updated catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "catalog-item.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidateCatalog();
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function translateCatalogItems(input: {
|
||||
/** `id` is items_base.id (not catalog_items.id) */
|
||||
items: Array<{ id: number; publicName: string; description?: string }>;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const parsed = translateItemsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
||||
};
|
||||
}
|
||||
const { items } = parsed.data;
|
||||
const { invalidateFurniDataCache } = await import(
|
||||
"@/lib/services/catalog-items-loader"
|
||||
const snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"catalog-item.translate",
|
||||
input,
|
||||
);
|
||||
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
||||
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
const furniPatches: Array<{
|
||||
classname: string;
|
||||
itemType: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
spriteId?: number;
|
||||
createIfMissing?: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, item.id))
|
||||
.limit(1);
|
||||
if (!base) continue;
|
||||
|
||||
const nextName = item.publicName?.trim() ?? "";
|
||||
const nextDesc = item.description ?? "";
|
||||
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
||||
|
||||
if (nameChanged) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, base.id));
|
||||
const idStr = String(base.id);
|
||||
const related = await db
|
||||
.select({
|
||||
id: CatalogItems.id,
|
||||
catalogName: CatalogItems.catalogName,
|
||||
})
|
||||
.from(CatalogItems)
|
||||
.where(
|
||||
or(
|
||||
eq(CatalogItems.itemIds, idStr),
|
||||
like(CatalogItems.itemIds, `${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||
),
|
||||
);
|
||||
for (const row of related) {
|
||||
if (row.catalogName !== nextName) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
}
|
||||
}
|
||||
namesUpdated++;
|
||||
}
|
||||
|
||||
if (nextDesc !== "" || nameChanged) {
|
||||
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
||||
furniPatches.push({
|
||||
classname: base.itemName,
|
||||
itemType: base.type || "s",
|
||||
name: nextName || base.publicName || base.itemName,
|
||||
description: nextDesc,
|
||||
spriteId: base.spriteId,
|
||||
createIfMissing: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const furniResult =
|
||||
furniPatches.length > 0
|
||||
? await patchFurniEntryNames(furniPatches)
|
||||
: { updated: 0, inserted: 0 };
|
||||
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
||||
invalidateFurniDataCache();
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "items_base_translate",
|
||||
target: "ItemsBase",
|
||||
after: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated: furniResult.updated > 0,
|
||||
furniDataInserted: furniResult.inserted,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidateCatalog();
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated: furniResult.updated,
|
||||
furniDataInserted: furniResult.inserted,
|
||||
updated: items.length,
|
||||
namesUpdated: Number(snapshot.output?.namesUpdated ?? 0),
|
||||
descriptionsUpdated: Number(snapshot.output?.descriptionsUpdated ?? 0),
|
||||
furniDataUpdated: Number(snapshot.output?.furniDataUpdated ?? 0),
|
||||
furniDataInserted: Number(snapshot.output?.furniDataInserted ?? 0),
|
||||
updated: input.items.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
+23
-126
@@ -1,71 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { deletePage, movePage } from "@/lib/services/catalog-tree";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CATALOG_PAGE_FIELDS = [
|
||||
"caption",
|
||||
"parentId",
|
||||
"pageLayout",
|
||||
"enabled",
|
||||
"visible",
|
||||
"minRank",
|
||||
"clubOnly",
|
||||
"vipOnly",
|
||||
"orderNum",
|
||||
"iconImage",
|
||||
"iconColor",
|
||||
"pageHeadline",
|
||||
"pageTeaser",
|
||||
"pageSpecial",
|
||||
"pageText1",
|
||||
"pageText2",
|
||||
"pageTextDetails",
|
||||
"pageTextTeaser",
|
||||
"includes",
|
||||
"captionSave",
|
||||
] as const;
|
||||
|
||||
function pickPageFields(fields: Record<string, unknown>) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of CATALOG_PAGE_FIELDS) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const data = pickPageFields(fields);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
if (typeof data.caption === "string" && !data.captionSave) {
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
|
||||
action: "update",
|
||||
id,
|
||||
...fields,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
@@ -73,14 +22,9 @@ export async function updateCatalogPage({
|
||||
|
||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await deletePage(id, "reparent");
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
description: `Deleted catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
@@ -93,23 +37,11 @@ export async function toggleCatalogPage({
|
||||
id: number;
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPages.enabled,
|
||||
visible: CatalogPages.visible,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ [field]: current === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
|
||||
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
@@ -126,32 +58,12 @@ export async function createCatalogPage(input: {
|
||||
orderNum?: number;
|
||||
}): Promise<ActionResult<{ id: number }>> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [result] = await db.insert(CatalogPages).values({
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
captionSave: input.caption.slice(0, 25),
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
minRank: input.minRank ?? 1,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Created catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page",
|
||||
targetId: createdId,
|
||||
});
|
||||
const snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"catalog-page.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
const createdId = Number(snapshot.output?.id);
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
@@ -161,22 +73,8 @@ export async function reorderTreePage(input: {
|
||||
newParentId?: number;
|
||||
newOrderNum: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (input.newParentId !== undefined) {
|
||||
try {
|
||||
await movePage(input.pageId, input.newParentId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : "Invalid move",
|
||||
};
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPages.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
@@ -185,9 +83,8 @@ export async function deleteTreePage(input: {
|
||||
pageId: number;
|
||||
mode: "reparent" | "cascade";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await deletePage(input.pageId, input.mode);
|
||||
await rcon.updateCatalog();
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
type EconomyMutationOperation,
|
||||
executeLegacyEconomyMutation,
|
||||
} from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
@@ -18,7 +22,16 @@ import type {
|
||||
import * as maintenance from "@/lib/services/furni-maintenance";
|
||||
|
||||
async function guard() {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return requirePermission(PERMS.CATALOG_EDIT);
|
||||
}
|
||||
|
||||
async function mutate<T>(
|
||||
operation: EconomyMutationOperation,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const staff = await guard();
|
||||
const snapshot = await executeLegacyEconomyMutation(staff, operation, input);
|
||||
return snapshot.output as T;
|
||||
}
|
||||
|
||||
export async function getFurniHealthAction(): Promise<
|
||||
@@ -37,8 +50,9 @@ export async function fixSpriteIdsAction(): Promise<
|
||||
ActionResult<FixSpriteResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixSpriteIds());
|
||||
return actionOk(
|
||||
await mutate<FixSpriteResult>("maintenance.fix-sprite-ids"),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -48,8 +62,7 @@ export async function fixCatalogOffersAction(): Promise<
|
||||
ActionResult<FixOfferResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixCatalogOffers());
|
||||
return actionOk(await mutate<FixOfferResult>("maintenance.fix-offers"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -59,8 +72,7 @@ export async function reconcileIdsAction(): Promise<
|
||||
ActionResult<ReconcileResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.reconcileIds());
|
||||
return actionOk(await mutate<ReconcileResult>("maintenance.reconcile-ids"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -70,8 +82,7 @@ export async function removeDuplicateItemsBaseAction(): Promise<
|
||||
ActionResult<DedupResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.removeDuplicates());
|
||||
return actionOk(await mutate<DedupResult>("maintenance.remove-duplicates"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -81,8 +92,9 @@ export async function previewAlignIdsAction(): Promise<
|
||||
ActionResult<AlignResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(false));
|
||||
return actionOk(
|
||||
await mutate<AlignResult>("maintenance.align-ids", { apply: false }),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -92,8 +104,9 @@ export async function applyAlignIdsAction(): Promise<
|
||||
ActionResult<AlignResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
||||
return actionOk(
|
||||
await mutate<AlignResult>("maintenance.align-ids", { apply: true }),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -103,8 +116,12 @@ export async function fixEverythingAction(input?: {
|
||||
dedupePages?: boolean;
|
||||
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixEverything(input ?? {}));
|
||||
return actionOk(
|
||||
await mutate<maintenance.FixAllResult>(
|
||||
"maintenance.fix-everything",
|
||||
input ?? {},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,26 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const ITEMS_BASE_FIELDS = [
|
||||
"publicName",
|
||||
"itemName",
|
||||
"type",
|
||||
"spriteId",
|
||||
"width",
|
||||
"length",
|
||||
"stackHeight",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionType",
|
||||
"interactionModesCount",
|
||||
"vendingIds",
|
||||
"multiheight",
|
||||
"customparams",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
"clothingOnWalk",
|
||||
] as const;
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const updateSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const updateItemsBase = adminAction(
|
||||
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||
async (ctx) => {
|
||||
const { id, fields } = ctx.data;
|
||||
const safe = pickAllowed(fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
throw new ActionError("No valid fields to update");
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: ItemsBase.id })
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Item not found");
|
||||
|
||||
// Coerce common numeric / decimal fields from form strings.
|
||||
const data: Record<string, unknown> = { ...safe };
|
||||
for (const key of [
|
||||
"spriteId",
|
||||
"width",
|
||||
"length",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionModesCount",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
]) {
|
||||
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||
}
|
||||
if (data.stackHeight !== undefined) {
|
||||
data.stackHeight = Number(data.stackHeight);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(data as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, id));
|
||||
await rcon.updateCatalog().catch(() => false);
|
||||
await logStaffActivity({
|
||||
staffId: Number(ctx.session.user.id),
|
||||
action: "items_base_update",
|
||||
description: `Updated items_base #${id}`,
|
||||
targetType: "items_base",
|
||||
targetId: id,
|
||||
});
|
||||
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");
|
||||
|
||||
+12
-16
@@ -1,34 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, Soundtracks } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function deleteSoundtrack({ id }: { id: number }) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await db.delete(Soundtracks).where(eq(Soundtracks.id, id));
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
|
||||
export async function updateSoundtrack({
|
||||
id,
|
||||
name,
|
||||
author,
|
||||
track,
|
||||
length,
|
||||
}: {
|
||||
export async function updateSoundtrack(input: {
|
||||
id: number;
|
||||
name: string;
|
||||
author: string;
|
||||
track: string;
|
||||
length: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await db
|
||||
.update(Soundtracks)
|
||||
.set({ name, author, track, length })
|
||||
.where(eq(Soundtracks.id, id));
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
Reference in new issue
Block a user