Compare commits
4
Commits
b6747553b9
...
5ff75144dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff75144dd | ||
|
|
9854719cfd | ||
|
|
67656a9aad | ||
|
|
20b85381fe |
No files matched your search
@@ -301,8 +301,27 @@ jobs:
|
|||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
# prisma generate only needs a resolvable URL — no live DB connection.
|
||||||
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
unset DATABASE_URL
|
||||||
|
# Tag releases must apply CMS SQL migrations against the live DB
|
||||||
|
# (same path as push-to-main deploy), using the production .env.
|
||||||
|
LIVE="/var/www/atom-nexst"
|
||||||
|
ln -sfn "${LIVE}/.env" "${WORK}/.env"
|
||||||
|
MIGRATE_OK=0
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if pnpm db:migrate; then
|
||||||
|
MIGRATE_OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||||
|
echo "ERROR: db:migrate failed after retries" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
pnpm build
|
pnpm build
|
||||||
pm2 restart next --update-env
|
pm2 restart next --update-env
|
||||||
pm2 save
|
pm2 save
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
|||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `pnpm db:migrate:status` | Show migration status |
|
||||||
|
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from Prisma + live DB (needs `DATABASE_URL`) |
|
||||||
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
| `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) |
|
||||||
| `pnpm analyze` | Build + open bundle analyzer |
|
| `pnpm analyze` | Build + open bundle analyzer |
|
||||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||||
|
|||||||
+4
-3
@@ -20,6 +20,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
|
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"isomorphic-dompurify": "^3.20.0",
|
"isomorphic-dompurify": "^3.21.0",
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.83.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"resend": "^6.18.1",
|
"resend": "^6.18.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.30.0",
|
"knip": "^6.31.0",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.25",
|
||||||
|
|||||||
Generated
+18
-18
@@ -34,7 +34,7 @@ importers:
|
|||||||
version: 3.2.2([email protected])
|
version: 3.2.2([email protected])
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.5.7
|
specifier: ^5.5.7
|
||||||
version: 5.5.7(@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]3.0([email protected]))([email protected]([email protected]))([email protected])
|
version: 5.5.7(@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]4.0([email protected]))([email protected]([email protected]))([email protected])
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^10.69.0
|
specifier: ^10.69.0
|
||||||
version: 10.69.0(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]([email protected])([email protected]))
|
version: 10.69.0(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected](@babel/[email protected])(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]([email protected])([email protected]))
|
||||||
@@ -63,8 +63,8 @@ importers:
|
|||||||
specifier: ^5.11.1
|
specifier: ^5.11.1
|
||||||
version: 5.11.1
|
version: 5.11.1
|
||||||
isomorphic-dompurify:
|
isomorphic-dompurify:
|
||||||
specifier: ^3.20.0
|
specifier: ^3.21.0
|
||||||
version: 3.20.0(@noble/[email protected])
|
version: 3.21.0(@noble/[email protected])
|
||||||
jpeg-js:
|
jpeg-js:
|
||||||
specifier: ^0.4.4
|
specifier: ^0.4.4
|
||||||
version: 0.4.4
|
version: 0.4.4
|
||||||
@@ -114,8 +114,8 @@ importers:
|
|||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8([email protected])
|
version: 19.2.8([email protected])
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.83.0
|
specifier: ^7.84.0
|
||||||
version: 7.83.0([email protected])
|
version: 7.84.0([email protected])
|
||||||
resend:
|
resend:
|
||||||
specifier: ^6.18.1
|
specifier: ^6.18.1
|
||||||
version: 6.18.1
|
version: 6.18.1
|
||||||
@@ -181,8 +181,8 @@ importers:
|
|||||||
specifier: ^9.1.7
|
specifier: ^9.1.7
|
||||||
version: 9.1.7
|
version: 9.1.7
|
||||||
knip:
|
knip:
|
||||||
specifier: ^6.30.0
|
specifier: ^6.31.0
|
||||||
version: 6.30.0
|
version: 6.31.0
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^17.3.0
|
specifier: ^17.3.0
|
||||||
version: 17.3.0
|
version: 17.3.0
|
||||||
@@ -3695,8 +3695,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
[email protected]0.0:
|
[email protected]1.0:
|
||||||
resolution: {integrity: sha512-WH3UcdmOrqLJF6qHaGnGINNMRwkQXVtvxlhEdygP3FdbuPjfFEsvjaqA5E16uYlNvAT5uGqK4MPvJBvtTtriEA==}
|
resolution: {integrity: sha512-Bg5mqtMnMdBBwz7MjvH3oAuLOA/j0xtawVkcAZsOpVhOsfaLYwnAvhEvz94f8585Q6LCnnNr2XNApbXlB0J3GQ==}
|
||||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -3763,8 +3763,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
[email protected]0.0:
|
[email protected]1.0:
|
||||||
resolution: {integrity: sha512-C5MsWT17QqMLtrvXIH4N9czRpt/T1GzwfkR/10qW3pdCHFUCoGF+Eeyq8FCKIkv56Gm5zSDv8fq3fs4whktehA==}
|
resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -4359,8 +4359,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.8
|
react: ^19.2.8
|
||||||
|
|
||||||
[email protected]3.0:
|
[email protected]4.0:
|
||||||
resolution: {integrity: sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==}
|
resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
@@ -5584,10 +5584,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/fast-memoize': 3.1.7
|
'@formatjs/fast-memoize': 3.1.7
|
||||||
|
|
||||||
'@hookform/[email protected](@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]3.0([email protected]))([email protected]([email protected]))([email protected])':
|
'@hookform/[email protected](@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]4.0([email protected]))([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
react-hook-form: 7.83.0([email protected])
|
react-hook-form: 7.84.0([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
@@ -7818,7 +7818,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]0.0(@noble/[email protected]):
|
[email protected]1.0(@noble/[email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
dompurify: 3.4.12
|
dompurify: 3.4.12
|
||||||
jsdom: 30.0.1(@noble/[email protected])
|
jsdom: 30.0.1(@noble/[email protected])
|
||||||
@@ -7902,7 +7902,7 @@ snapshots:
|
|||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
setimmediate: 1.0.5
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
[email protected]0.0:
|
[email protected]1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0([email protected])
|
fdir: 6.5.0([email protected])
|
||||||
formatly: 0.3.0
|
formatly: 0.3.0
|
||||||
@@ -8461,7 +8461,7 @@ snapshots:
|
|||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
[email protected]3.0([email protected]):
|
[email protected]4.0([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
|
|||||||
@@ -2,36 +2,71 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { deletePhoto } from "./admin-photos";
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
|
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
||||||
|
const limit = vi.fn();
|
||||||
|
const whereSelect = vi.fn(() => ({ limit }));
|
||||||
|
const from = vi.fn(() => ({ where: whereSelect }));
|
||||||
|
const select = vi.fn(() => ({ from }));
|
||||||
|
const whereDelete = vi.fn();
|
||||||
|
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
||||||
|
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: { cameraWeb: { delete: vi.fn() } } }));
|
vi.mock("@/lib/admin/photo-files", () => ({
|
||||||
|
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: (...args) => select(...args),
|
||||||
|
delete: (...args) => deleteFn(...args),
|
||||||
|
},
|
||||||
|
CameraWeb: { id: "id", url: "url" },
|
||||||
|
}));
|
||||||
|
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
const fakeForm = (data) => ({
|
||||||
get: (key: string) => data[key] ?? null,
|
get: (key) => data[key] ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
||||||
|
whereDelete.mockResolvedValue(undefined);
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
id: 1,
|
id: 1,
|
||||||
rank: 7,
|
rank: 7,
|
||||||
username: "admin",
|
username: "admin",
|
||||||
} as never);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deletePhoto", () => {
|
describe("deletePhoto", () => {
|
||||||
it("deletes a photo and revalidates", async () => {
|
it("deletes a photo and revalidates", async () => {
|
||||||
await deletePhoto(fakeForm({ id: "42" }) as unknown as FormData);
|
await deletePhoto(fakeForm({ id: "42" }));
|
||||||
expect(prisma.cameraWeb.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
expect(select).toHaveBeenCalled();
|
||||||
|
expect(deleteFn).toHaveBeenCalled();
|
||||||
|
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
||||||
|
expect(logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "photo_delete",
|
||||||
|
targetId: 42,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
await deletePhoto(fakeForm({ id: "0" }) as unknown as FormData);
|
await deletePhoto(fakeForm({ id: "0" }));
|
||||||
expect(prisma.cameraWeb.delete).not.toHaveBeenCalled();
|
expect(select).not.toHaveBeenCalled();
|
||||||
|
expect(deleteFn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+35
-14
@@ -1,9 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
import { CameraWeb, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||||
@@ -11,8 +13,15 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
|||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
try {
|
const [row] = await db
|
||||||
await prisma.cameraWeb.delete({ where: { id } });
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||||
|
.from(CameraWeb)
|
||||||
|
.where(eq(CameraWeb.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||||
|
await tryRemoveLocalPhotoFile(row.url);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "photo_delete",
|
action: "photo_delete",
|
||||||
@@ -20,11 +29,10 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
|||||||
targetType: "camera_web",
|
targetType: "camera_web",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// Record may have already been removed; ignore.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/admin/photos");
|
||||||
|
revalidatePath("/photos");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
||||||
@@ -36,14 +44,27 @@ export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
|||||||
.filter((n) => Number.isFinite(n) && n > 0);
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
const result = await prisma.cameraWeb.deleteMany({
|
const rows = await db
|
||||||
where: { id: { in: ids } },
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||||
});
|
.from(CameraWeb)
|
||||||
await logStaffActivity({
|
.where(inArray(CameraWeb.id, ids));
|
||||||
staffId: staff.id,
|
|
||||||
action: "photo_bulk_delete",
|
if (rows.length > 0) {
|
||||||
description: `Deleted ${result.count} camera photo(s)`,
|
await db.delete(CameraWeb).where(
|
||||||
targetType: "camera_web",
|
inArray(
|
||||||
});
|
CameraWeb.id,
|
||||||
|
rows.map((r) => r.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Promise.all(rows.map((r) => tryRemoveLocalPhotoFile(r.url)));
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "photo_bulk_delete",
|
||||||
|
description: `Deleted ${rows.length} camera photo(s)`,
|
||||||
|
targetType: "camera_web",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/admin/photos");
|
||||||
|
revalidatePath("/photos");
|
||||||
}
|
}
|
||||||
+62
-22
@@ -1,6 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, Sanctions, User, UsersSettings } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
@@ -271,8 +273,8 @@ export async function bulkAdjustCurrency({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist trade lock on `sanctions.trade_locked_until`.
|
* Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade`
|
||||||
* No first-class RCON trade-lock helper in this CMS — DB only.
|
* via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online).
|
||||||
*/
|
*/
|
||||||
export async function setTradeLock({
|
export async function setTradeLock({
|
||||||
userId,
|
userId,
|
||||||
@@ -284,33 +286,71 @@ export async function setTradeLock({
|
|||||||
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
const until = Math.max(0, Math.trunc(untilUnix));
|
const until = Math.max(0, Math.trunc(untilUnix));
|
||||||
|
const locked = until > 0;
|
||||||
|
|
||||||
const existing = await prisma.sanctions.findFirst({
|
const [user] = await db
|
||||||
where: { habboId: userId },
|
.select({
|
||||||
select: { id: true },
|
id: User.id,
|
||||||
});
|
username: User.username,
|
||||||
if (existing) {
|
online: User.online,
|
||||||
await prisma.sanctions.update({
|
})
|
||||||
where: { id: existing.id },
|
.from(User)
|
||||||
data: { tradeLockedUntil: until },
|
.where(eq(User.id, userId))
|
||||||
});
|
.limit(1);
|
||||||
} else {
|
if (!user) {
|
||||||
await prisma.sanctions.create({
|
return { ok: false as const, error: "User not found" };
|
||||||
data: {
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: Sanctions.id })
|
||||||
|
.from(Sanctions)
|
||||||
|
.where(eq(Sanctions.habboId, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) {
|
||||||
|
await tx
|
||||||
|
.update(Sanctions)
|
||||||
|
.set({
|
||||||
|
tradeLockedUntil: until,
|
||||||
|
...(locked ? { reason: "Trade lock (CMS)" } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(Sanctions.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(Sanctions).values({
|
||||||
habboId: userId,
|
habboId: userId,
|
||||||
tradeLockedUntil: until,
|
tradeLockedUntil: until,
|
||||||
reason: until > 0 ? "Trade lock (CMS)" : "",
|
reason: locked ? "Trade lock (CMS)" : "",
|
||||||
},
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(UsersSettings)
|
||||||
|
.set({
|
||||||
|
canTrade: locked ? "0" : "1",
|
||||||
|
...(locked
|
||||||
|
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.where(eq(UsersSettings.userId, userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
await rcon.setTradeLock(userId, locked);
|
||||||
|
await rcon.alertUser(
|
||||||
|
userId,
|
||||||
|
locked
|
||||||
|
? "Trading has been disabled by staff."
|
||||||
|
: "Trading has been re-enabled by staff.",
|
||||||
|
);
|
||||||
|
if (user.online === "1") {
|
||||||
|
await rcon.disconnectUser(userId, user.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: until > 0 ? "trade_lock" : "trade_unlock",
|
action: locked ? "trade_lock" : "trade_unlock",
|
||||||
description:
|
description: locked
|
||||||
until > 0
|
? `Trade-locked ${user.username} (#${userId}) until ${until}`
|
||||||
? `Trade-locked user #${userId} until ${until}`
|
: `Cleared trade lock for ${user.username} (#${userId})`,
|
||||||
: `Cleared trade lock for user #${userId}`,
|
|
||||||
targetType: "user",
|
targetType: "user",
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
|
||||||
|
describe("setTradeLock drizzle + RCON contract", () => {
|
||||||
|
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||||
|
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
|
||||||
|
|
||||||
|
it("writes sanctions + users_settings via Drizzle, not prisma facade", () => {
|
||||||
|
expect(src).toContain("@/lib/db");
|
||||||
|
expect(src).toContain("UsersSettings");
|
||||||
|
expect(src).toContain("Sanctions");
|
||||||
|
expect(src).toContain("canTrade");
|
||||||
|
expect(src).toContain("tradeLockedUntil");
|
||||||
|
expect(src).toMatch(/export async function setTradeLock/);
|
||||||
|
const fn = src.slice(src.indexOf("export async function setTradeLock"));
|
||||||
|
expect(fn).not.toContain("prisma.sanctions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
|
||||||
|
expect(rconSrc).toContain("settradelock");
|
||||||
|
expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)");
|
||||||
|
expect(src).toContain("rcon.setTradeLock");
|
||||||
|
expect(src).toContain("rcon.alertUser");
|
||||||
|
expect(src).toContain("rcon.disconnectUser");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin-photos drizzle contract", () => {
|
||||||
|
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
|
|
||||||
|
it("deletes via Drizzle CameraWeb and attempts local file purge", () => {
|
||||||
|
expect(src).toContain("@/lib/db");
|
||||||
|
expect(src).toContain("CameraWeb");
|
||||||
|
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||||
|
expect(src).not.toContain("@/lib/prisma");
|
||||||
|
expect(src).toContain('revalidatePath("/photos")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tryRemoveLocalPhotoFile", () => {
|
||||||
|
it("rejects path traversal and remote CDN urls", async () => {
|
||||||
|
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||||
|
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort local file delete for camera photos hosted under /public.
|
||||||
|
* External CDN URLs are left alone (no purge credentials in this CMS).
|
||||||
|
*/
|
||||||
|
export async function tryRemoveLocalPhotoFile(url: string): Promise<boolean> {
|
||||||
|
if (!url?.trim()) return false;
|
||||||
|
let pathname = url.trim();
|
||||||
|
if (/^https?:\/\//i.test(pathname)) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(pathname);
|
||||||
|
const app = (
|
||||||
|
process.env.APP_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
""
|
||||||
|
).replace(/\/$/, "");
|
||||||
|
if (!app || !pathname.startsWith(app)) return false;
|
||||||
|
pathname = parsed.pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pathname.startsWith("/")) return false;
|
||||||
|
const rel = pathname.replace(/^\/+/, "");
|
||||||
|
if (!rel || rel.includes("..")) return false;
|
||||||
|
const publicRoot = path.resolve(process.cwd(), "public");
|
||||||
|
const full = path.resolve(publicRoot, rel);
|
||||||
|
if (!full.startsWith(publicRoot + path.sep) && full !== publicRoot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await unlink(full);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ describe("CI workflow", () => {
|
|||||||
const workflow = readFileSync(
|
const workflow = readFileSync(
|
||||||
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
|
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
).replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
it("runs check then production deploy on push to main", () => {
|
it("runs check then production deploy on push to main", () => {
|
||||||
expect(workflow).toContain("pnpm biome:lint");
|
expect(workflow).toContain("pnpm biome:lint");
|
||||||
@@ -31,4 +31,14 @@ describe("CI workflow", () => {
|
|||||||
expect(workflow).toContain("Creating release for");
|
expect(workflow).toContain("Creating release for");
|
||||||
expect(workflow).toContain("startsWith(gitea.ref_name, 'v')");
|
expect(workflow).toContain("startsWith(gitea.ref_name, 'v')");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies CMS migrations on tag release before build", () => {
|
||||||
|
const release = workflow.slice(workflow.indexOf("\n release:"));
|
||||||
|
expect(release).toContain("pnpm db:migrate");
|
||||||
|
expect(release).toContain("unset DATABASE_URL");
|
||||||
|
const migrateAt = release.indexOf("pnpm db:migrate");
|
||||||
|
const buildAt = release.indexOf("pnpm build");
|
||||||
|
expect(migrateAt).toBeGreaterThan(-1);
|
||||||
|
expect(buildAt).toBeGreaterThan(migrateAt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("prisma-facade include contract", () => {
|
||||||
|
const src = readFileSync(
|
||||||
|
resolve(process.cwd(), "src/lib/prisma-facade.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("groups many-relations as arrays (polls/tickets/events)", () => {
|
||||||
|
expect(src).toContain('mode: "one" | "many"');
|
||||||
|
expect(src).toContain('mode === "many"');
|
||||||
|
expect(src).toContain("list.push(cleaned)");
|
||||||
|
expect(src).toContain('"many"');
|
||||||
|
expect(src).toMatch(/queryRelationRows\([\s\S]*?"many"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recurses nested include on related rows", () => {
|
||||||
|
expect(src).toContain("opts.include && related.length > 0");
|
||||||
|
expect(src).toContain(
|
||||||
|
"await attachIncludes(client, rel.referencedTable, related, opts.include)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies relation where filters when present", () => {
|
||||||
|
expect(src).toContain("buildCondition(rel.referencedTable, opts.where)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships poll/ticket/event drizzle relations used by admin includes", () => {
|
||||||
|
const relations = readFileSync(
|
||||||
|
resolve(process.cwd(), "src/db/relations.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(relations).toContain("questions: many(WebsitePollQuestion)");
|
||||||
|
expect(relations).toContain("votes: many(WebsitePollVote)");
|
||||||
|
expect(relations).toContain("messages: many(WebsiteTicketMessage)");
|
||||||
|
expect(relations).toContain("prizes: many(WebsiteEventPrize)");
|
||||||
|
expect(relations).toContain(
|
||||||
|
"registrations: many(WebsiteEventRegistration)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("drizzle schema generate script", () => {
|
||||||
|
it("is wired as pnpm db:schema:generate", () => {
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(resolve(process.cwd(), "package.json"), "utf8"),
|
||||||
|
) as { scripts: Record<string, string> };
|
||||||
|
expect(pkg.scripts["db:schema:generate"]).toContain(
|
||||||
|
"generate-drizzle-schema.mjs",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
readFileSync(
|
||||||
|
resolve(process.cwd(), "scripts/generate-drizzle-schema.mjs"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
).toContain("Usage: pnpm db:schema:generate");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the many-relation grouping fix in prisma-facade queryRelationRows.
|
||||||
|
* Keeps the contract local so we don't export private helpers.
|
||||||
|
*/
|
||||||
|
function groupRelationRows(
|
||||||
|
rows: Array<{ __fk: string; id: number }>,
|
||||||
|
mode: "one" | "many",
|
||||||
|
): Map<string, unknown> {
|
||||||
|
const map = new Map<string, unknown>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = String(row.__fk);
|
||||||
|
const cleaned = { id: row.id };
|
||||||
|
if (mode === "many") {
|
||||||
|
const list = map.get(key);
|
||||||
|
if (Array.isArray(list)) list.push(cleaned);
|
||||||
|
else map.set(key, [cleaned]);
|
||||||
|
} else if (!map.has(key)) {
|
||||||
|
map.set(key, cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("prisma-facade relation grouping", () => {
|
||||||
|
it("accumulates many rows per parent key", () => {
|
||||||
|
const map = groupRelationRows(
|
||||||
|
[
|
||||||
|
{ __fk: "1", id: 10 },
|
||||||
|
{ __fk: "1", id: 11 },
|
||||||
|
{ __fk: "2", id: 20 },
|
||||||
|
],
|
||||||
|
"many",
|
||||||
|
);
|
||||||
|
expect(map.get("1")).toEqual([{ id: 10 }, { id: 11 }]);
|
||||||
|
expect(map.get("2")).toEqual([{ id: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single row for one relations", () => {
|
||||||
|
const map = groupRelationRows(
|
||||||
|
[
|
||||||
|
{ __fk: "1", id: 10 },
|
||||||
|
{ __fk: "1", id: 11 },
|
||||||
|
],
|
||||||
|
"one",
|
||||||
|
);
|
||||||
|
expect(map.get("1")).toEqual({ id: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+46
-10
@@ -270,24 +270,36 @@ async function queryRelationRows(
|
|||||||
rel: any,
|
rel: any,
|
||||||
opts: any,
|
opts: any,
|
||||||
keyValues: unknown[],
|
keyValues: unknown[],
|
||||||
|
mode: "one" | "many",
|
||||||
): Promise<Map<string, any>> {
|
): Promise<Map<string, any>> {
|
||||||
const join = resolveJoin(table, rel);
|
const join = resolveJoin(table, rel);
|
||||||
const byCol = join.fkCols[0];
|
const byCol = join.fkCols[0];
|
||||||
const condition = inArray(byCol, coerceArray(byCol, keyValues));
|
const parts = [
|
||||||
|
inArray(byCol, coerceArray(byCol, keyValues)),
|
||||||
|
buildCondition(rel.referencedTable, opts.where),
|
||||||
|
].filter(Boolean);
|
||||||
|
const condition = parts.length === 1 ? parts[0] : and(...parts);
|
||||||
const sel = projection(rel.referencedTable, opts.select);
|
const sel = projection(rel.referencedTable, opts.select);
|
||||||
const orderBy = buildOrderBy(rel.referencedTable, opts.orderBy);
|
const orderBy = buildOrderBy(rel.referencedTable, opts.orderBy);
|
||||||
|
|
||||||
let stmt: any = sel
|
let stmt: any = sel
|
||||||
? client.select({ ...sel, __fk: byCol }).from(rel.referencedTable)
|
? client.select({ ...sel, __fk: byCol }).from(rel.referencedTable)
|
||||||
: client.select().from(rel.referencedTable);
|
: client.select().from(rel.referencedTable);
|
||||||
stmt = stmt.where(condition);
|
if (condition) stmt = stmt.where(condition);
|
||||||
if (orderBy.length) stmt = stmt.orderBy(...orderBy);
|
if (orderBy.length) stmt = stmt.orderBy(...orderBy);
|
||||||
|
|
||||||
const rows = (await stmt) as any[];
|
const rows = (await stmt) as any[];
|
||||||
const map = new Map<string, any>();
|
const map = new Map<string, any>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = row.__fk ?? row[colKey(byCol)];
|
const key = String(row.__fk ?? row[colKey(byCol)]);
|
||||||
map.set(String(key), sel ? pickRow(row, opts.select) : row);
|
const cleaned = sel ? pickRow(row, opts.select) : row;
|
||||||
|
if (mode === "many") {
|
||||||
|
const list = map.get(key);
|
||||||
|
if (Array.isArray(list)) list.push(cleaned);
|
||||||
|
else map.set(key, [cleaned]);
|
||||||
|
} else if (!map.has(key)) {
|
||||||
|
map.set(key, cleaned);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -386,13 +398,25 @@ async function attachIncludes(
|
|||||||
for (const row of rows) row[rel.fieldName] = null;
|
for (const row of rows) row[rel.fieldName] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const map = await queryRelationRows(client, table, rel, opts, values);
|
const map = await queryRelationRows(
|
||||||
|
client,
|
||||||
|
table,
|
||||||
|
rel,
|
||||||
|
opts,
|
||||||
|
values,
|
||||||
|
"one",
|
||||||
|
);
|
||||||
const join = resolveJoin(table, rel);
|
const join = resolveJoin(table, rel);
|
||||||
const refCol = join.refCols[0];
|
const refCol = join.refCols[0];
|
||||||
|
const related: any[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const refValue = row[colKey(refCol)];
|
const refValue = row[colKey(refCol)];
|
||||||
row[rel.fieldName] =
|
const hit = refValue == null ? null : (map.get(String(refValue)) ?? null);
|
||||||
refValue == null ? null : (map.get(String(refValue)) ?? null);
|
row[rel.fieldName] = hit;
|
||||||
|
if (hit) related.push(hit);
|
||||||
|
}
|
||||||
|
if (opts.include && related.length > 0) {
|
||||||
|
await attachIncludes(client, rel.referencedTable, related, opts.include);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,13 +426,25 @@ async function attachIncludes(
|
|||||||
for (const row of rows) row[rel.fieldName] = [];
|
for (const row of rows) row[rel.fieldName] = [];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const map = await queryRelationRows(client, table, rel, opts, values);
|
const map = await queryRelationRows(
|
||||||
|
client,
|
||||||
|
table,
|
||||||
|
rel,
|
||||||
|
opts,
|
||||||
|
values,
|
||||||
|
"many",
|
||||||
|
);
|
||||||
const join = resolveJoin(table, rel);
|
const join = resolveJoin(table, rel);
|
||||||
const refCol = join.refCols[0];
|
const refCol = join.refCols[0];
|
||||||
|
const related: any[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const refValue = row[colKey(refCol)];
|
const refValue = row[colKey(refCol)];
|
||||||
row[rel.fieldName] =
|
const list = refValue == null ? [] : (map.get(String(refValue)) ?? []);
|
||||||
refValue == null ? [] : (map.get(String(refValue)) ?? []);
|
row[rel.fieldName] = list;
|
||||||
|
for (const item of list) related.push(item);
|
||||||
|
}
|
||||||
|
if (opts.include && related.length > 0) {
|
||||||
|
await attachIncludes(client, rel.referencedTable, related, opts.include);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,17 @@ export class RconClient {
|
|||||||
unmuteUser(userId: number) {
|
unmuteUser(userId: number) {
|
||||||
return this.send("unmuteuser", { user_id: userId });
|
return this.send("unmuteuser", { user_id: userId });
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Best-effort live trade lock sync. Polaris/Arcturus forks may expose
|
||||||
|
* `settradelock`; unknown keys are ignored by the emulator. Prefer updating
|
||||||
|
* `users_settings.can_trade` in DB and disconnecting online users.
|
||||||
|
*/
|
||||||
|
setTradeLock(userId: number, locked: boolean) {
|
||||||
|
return this.send("settradelock", {
|
||||||
|
user_id: userId,
|
||||||
|
enabled: locked ? 0 : 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
|
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ describe("staff smoke contract", () => {
|
|||||||
expect(src).toContain("bulkAdjustCurrency");
|
expect(src).toContain("bulkAdjustCurrency");
|
||||||
expect(src).toContain("setTradeLock");
|
expect(src).toContain("setTradeLock");
|
||||||
expect(src).toContain("tradeLockedUntil");
|
expect(src).toContain("tradeLockedUntil");
|
||||||
|
expect(src).toContain("UsersSettings");
|
||||||
|
expect(src).toContain("rcon.setTradeLock");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes photos via Drizzle with local file purge helper", () => {
|
||||||
|
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
|
expect(src).toContain("CameraWeb");
|
||||||
|
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||||
|
expect(src).toContain("@/lib/admin/photo-files");
|
||||||
|
expect(src).not.toContain("@/lib/prisma");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guards dual ticket queues on admin and mod", () => {
|
it("guards dual ticket queues on admin and mod", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user