feat(housekeeping): deliver people account workflows
This commit is contained in:
1 parent
7920d4f46c
commit
e1b31ff773
45 files changed
+4290
-1243
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,24 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createLegacyPeopleMutationContext,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, id));
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const rawId = formPositiveBigInt(formData, "id");
|
||||
if (!rawId) return;
|
||||
const applicationId = Number(rawId);
|
||||
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
|
||||
|
||||
await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.USERS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"application.decide",
|
||||
{ applicationId, decision: "dismiss" },
|
||||
);
|
||||
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+20
-53
@@ -1,65 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import {
|
||||
db,
|
||||
GuildForumViews,
|
||||
Guilds,
|
||||
GuildsForumsComments,
|
||||
GuildsForumsThreads,
|
||||
GuildsMembers,
|
||||
Items,
|
||||
Rooms,
|
||||
} from "@/lib/db";
|
||||
createLegacyPeopleMutationContext,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Disband a guild and clean related membership/forum rows. */
|
||||
export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
const guildId = Number(formData.get("id"));
|
||||
if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
|
||||
|
||||
const [guild] = await db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
name: Guilds.name,
|
||||
userId: Guilds.userId,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, id))
|
||||
.limit(1);
|
||||
if (!guild) return;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const threads = await tx
|
||||
.select({ id: GuildsForumsThreads.id })
|
||||
.from(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, id));
|
||||
const threadIds = threads.map((t) => t.id);
|
||||
if (threadIds.length > 0) {
|
||||
await tx
|
||||
.delete(GuildsForumsComments)
|
||||
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||
await tx
|
||||
.delete(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, id));
|
||||
}
|
||||
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
|
||||
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
|
||||
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
|
||||
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
|
||||
await tx.delete(Guilds).where(eq(Guilds.id, id));
|
||||
});
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "guild_disband",
|
||||
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
|
||||
targetType: "guild",
|
||||
targetId: id,
|
||||
});
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.USERS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"guild.disband",
|
||||
{ guildId },
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "NOT_FOUND") return;
|
||||
throw new Error("Could not disband guild");
|
||||
}
|
||||
revalidatePath("/admin/guilds");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+42
-51
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+45
-36
@@ -1,50 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createLegacyPeopleMutationContext,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteTeams } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function text(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function createTeam(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
|
||||
const rankName = String(formData.get("rankName") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const rankName = text(formData, "rankName");
|
||||
if (!rankName) return;
|
||||
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const jobDescription = String(formData.get("jobDescription") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const staffColor =
|
||||
String(formData.get("staffColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || "#327fa8";
|
||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||
|
||||
const now = new Date();
|
||||
await db.insert(WebsiteTeams).values({
|
||||
rankName: rankName.slice(0, 255),
|
||||
badge: badge ? badge.slice(0, 255) : null,
|
||||
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
|
||||
staffColor: staffColor.slice(0, 255),
|
||||
hiddenRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.USERS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"team.change",
|
||||
{
|
||||
action: "create",
|
||||
rankName,
|
||||
badge: text(formData, "badge"),
|
||||
jobDescription: text(formData, "jobDescription"),
|
||||
staffColor: text(formData, "staffColor") || "#327fa8",
|
||||
hiddenRank: formData.get("hiddenRank") === "on",
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create team");
|
||||
revalidatePath("/admin/teams");
|
||||
}
|
||||
|
||||
export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
|
||||
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const teamId = Number(formData.get("id"));
|
||||
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.USERS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"team.change",
|
||||
{ action: "delete", teamId },
|
||||
);
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
throw new Error("Could not delete team");
|
||||
}
|
||||
revalidatePath("/admin/teams");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+28
-72
File diff suppressed because it is too large.
Load diff
@@ -1,56 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createLegacyPeopleMutationContext,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteWordfilter } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function addWord(input: {
|
||||
word: string;
|
||||
}): Promise<ActionResult<{ id: string }>> {
|
||||
await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||
const word = String(input.word ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!word) return actionError("Word is required");
|
||||
|
||||
try {
|
||||
const [result] = (await db
|
||||
.insert(WebsiteWordfilter)
|
||||
.values({ word })) as unknown as [ResultSetHeader];
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
revalidatePath("/admin/wordfilter");
|
||||
return actionOk({ id: String(result.insertId) });
|
||||
} catch {
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.WORDFILTER_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"word-filter.update",
|
||||
{ action: "add", word },
|
||||
);
|
||||
if (!result.ok)
|
||||
return actionError("Could not add word (it may already exist)");
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
return actionOk({ id: String(result.data.after?.id ?? "") });
|
||||
}
|
||||
|
||||
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||
const raw = String(input.id ?? "").normalize("NFC");
|
||||
if (!raw) return actionError("Missing word id");
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
revalidatePath("/admin/wordfilter");
|
||||
return actionOk();
|
||||
} catch {
|
||||
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||
const id = Number(String(input.id ?? "").normalize("NFC"));
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
return actionError("Missing word id");
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.WORDFILTER_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"word-filter.update",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
return actionError("Could not remove word");
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||
createLegacyPeopleMutationContext: vi.fn(
|
||||
(staff, permission, correlationId) => ({
|
||||
staff,
|
||||
permission,
|
||||
correlationId,
|
||||
}),
|
||||
),
|
||||
peopleMutationService: { execute },
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { USERS_EDIT: "admin.users.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: {}, User: {}, UsersCurrency: {} }));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
|
||||
import { bulkAdjustCurrency } from "./bulk-users";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
} as never);
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { userIds: [7, 8] },
|
||||
after: { completed: 2, total: 2, failedIds: [] },
|
||||
},
|
||||
correlationId: "bulk-adjust",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps one ACL check while delegating a positive bulk adjustment", async () => {
|
||||
await expect(
|
||||
bulkAdjustCurrency({
|
||||
userIds: [7, 8],
|
||||
amount: 25,
|
||||
type: "credits",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { adjusted: 2, total: 2, failedIds: [] },
|
||||
});
|
||||
expect(requirePermission).toHaveBeenCalledTimes(1);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"users.bulk-currency",
|
||||
{ userIds: [7, 8], amount: 25, type: "credits" },
|
||||
);
|
||||
});
|
||||
+66
-150
File diff suppressed because it is too large.
Load diff
+114
-225
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { execute, staff } = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
staff: { id: 1, rank: 7, username: "admin" },
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||
createLegacyPeopleMutationContext: vi.fn(
|
||||
(actor, permission, correlationId) => ({
|
||||
actor,
|
||||
permission,
|
||||
correlationId,
|
||||
}),
|
||||
),
|
||||
peopleMutationService: { execute },
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: vi.fn(async () => staff),
|
||||
requirePermissionRateLimited: vi.fn(async () => staff),
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
SETTINGS_EDIT: "admin.settings.edit",
|
||||
USERS_EDIT: "admin.users.edit",
|
||||
WORDFILTER_EDIT: "admin.wordfilter.edit",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||
actionError: (error: string) => ({ ok: false, error }),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { disbandGuild } from "./admin-guilds";
|
||||
import { addWhitelist } from "./admin-ip";
|
||||
import { createTeam, deleteTeam } from "./admin-teams";
|
||||
import { deleteWord } from "./admin-wordfilter";
|
||||
|
||||
const form = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
||||
const failure = (code: string) => ({
|
||||
ok: false as const,
|
||||
error: { code, messageKey: "errors.housekeeping.dependencyUnavailable" },
|
||||
correlationId: "wrapper-failure",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("legacy wrapper failure compatibility", () => {
|
||||
it("keeps guild persistence failures throwing while a missing guild remains a no-op", async () => {
|
||||
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
|
||||
await expect(disbandGuild(form({ id: "9" }))).rejects.toThrow();
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
|
||||
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
|
||||
await expect(disbandGuild(form({ id: "9" }))).resolves.toBeUndefined();
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps IP and team persistence failures throwing", async () => {
|
||||
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
|
||||
await expect(
|
||||
addWhitelist(form({ ipAddress: "192.0.2.1" })),
|
||||
).rejects.toThrow();
|
||||
|
||||
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
|
||||
await expect(createTeam(form({ rankName: "Moderator" }))).rejects.toThrow();
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps already-gone team and word-filter deletes successful", async () => {
|
||||
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
|
||||
await expect(deleteTeam(form({ id: "42" }))).resolves.toBeUndefined();
|
||||
|
||||
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
|
||||
await expect(deleteWord({ id: "42" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||
});
|
||||
});
|
||||
@@ -2,30 +2,6 @@ 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", () => {
|
||||
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).toContain("db.");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -33,7 +9,6 @@ describe("admin-photos drizzle contract", () => {
|
||||
expect(src).toContain("@/lib/db");
|
||||
expect(src).toContain("CameraWeb");
|
||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(src).toContain("@/lib/db");
|
||||
expect(src).toContain('revalidatePath("/photos")');
|
||||
});
|
||||
});
|
||||
|
||||
+145
-353
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../../foundation/contracts";
|
||||
import { PEOPLE_PRIMARY_ROUTES } from "./routes";
|
||||
|
||||
export const peopleManifest = {
|
||||
id: "people",
|
||||
@@ -34,7 +35,7 @@ export const peopleManifest = {
|
||||
PERMS.MOD_CFH_EDIT,
|
||||
PERMS.MOD_TICKETS_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
routes: PEOPLE_PRIMARY_ROUTES,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
|
||||
interface PeopleMultiAccountsPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleMultiAccountsPage({
|
||||
context: _context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleMultiAccountsPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Multi-account clusters"
|
||||
description="Review bounded account clusters grouped by current IP."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={(data) =>
|
||||
data.kind === "multi-accounts" && data.page.items.length === 0
|
||||
}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "multi-accounts" ? (
|
||||
<ul className="space-y-3">
|
||||
{data.page.items.map((cluster) => (
|
||||
<li
|
||||
key={cluster.key}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
{cluster.key}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{cluster.accountCount} accounts
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
|
||||
{cluster.accounts.map((account) => (
|
||||
<li key={account.id}>
|
||||
<a href={account.href}>{account.username}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleMultiAccountsPage(
|
||||
input: HousekeepingPageInput,
|
||||
) {
|
||||
const result = await peopleUsersQuery.run(input.context, {
|
||||
routeId: "people.users.multi-accounts",
|
||||
list: {},
|
||||
});
|
||||
return <PeopleMultiAccountsPage context={input.context} result={result} />;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
|
||||
interface PeopleStaffPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleStaffQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleStaffPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleStaffPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Staff"
|
||||
description="Review staff applications and team definitions."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={(data) => data.page.items.length === 0}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "applications" ? (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((application) => (
|
||||
<li
|
||||
key={application.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2>{application.username ?? `User #${application.userId}`}</h2>
|
||||
<p>{application.content}</p>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.application.decide">
|
||||
Dismiss application
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : data.kind === "teams" ? (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((team) => (
|
||||
<li
|
||||
key={team.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2>{team.name}</h2>
|
||||
<p>{team.jobDescription ?? "No job description"}</p>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.team.change">
|
||||
Team controls
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId;
|
||||
const result =
|
||||
routeId === "people.staff.applications" || routeId === "people.staff.teams"
|
||||
? await peopleStaffQuery.run(input.context, { routeId, list: {} })
|
||||
: fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
createCorrelationId(),
|
||||
);
|
||||
return <PeopleStaffPage context={input.context} result={result} />;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,86 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
|
||||
interface PeopleUsersPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleUsersPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleUsersPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Users"
|
||||
description="Search accounts and open the canonical People workflow."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "users" ? (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{data.page.total} matching users
|
||||
</p>
|
||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{data.page.items.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex flex-wrap items-center gap-3 py-3 text-sm"
|
||||
>
|
||||
<a
|
||||
className="font-medium text-[var(--admin-text)]"
|
||||
href={user.href}
|
||||
>
|
||||
{user.username}
|
||||
</a>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
Rank {user.rank}
|
||||
</span>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{user.online ? "Online" : "Offline"}
|
||||
</span>
|
||||
{user.mail !== null ? <span>{user.mail}</span> : null}
|
||||
{user.ipCurrent !== null ? (
|
||||
<span>{user.ipCurrent}</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<a
|
||||
href={`${user.href}/edit`}
|
||||
data-housekeeping-command="people.user.update"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<span data-housekeeping-command="people.user.ban">
|
||||
Ban controls available
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
|
||||
const result = await peopleUsersQuery.run(input.context, {
|
||||
routeId: "people.users.list",
|
||||
list: {},
|
||||
});
|
||||
return <PeopleUsersPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { renderPeopleCommunityPage } from "./pages/community";
|
||||
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
|
||||
import { renderPeopleStaffPage } from "./pages/staff";
|
||||
import { renderPeopleUserDetailPage } from "./pages/user-detail";
|
||||
import { renderPeopleUserEditPage } from "./pages/user-edit";
|
||||
import { renderPeopleUsersPage } from "./pages/users";
|
||||
|
||||
export const PEOPLE_PRIMARY_ROUTE_IDS = [
|
||||
"people.users.list",
|
||||
"people.users.edit",
|
||||
"people.users.multi-accounts",
|
||||
"people.users.detail",
|
||||
"people.community.online",
|
||||
"people.community.guilds",
|
||||
"people.community.guild-detail",
|
||||
"people.staff.applications",
|
||||
"people.staff.teams",
|
||||
] as const;
|
||||
|
||||
type PeoplePrimaryRouteId = (typeof PEOPLE_PRIMARY_ROUTE_IDS)[number];
|
||||
|
||||
function rendererFor(
|
||||
routeId: PeoplePrimaryRouteId,
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
if (routeId === "people.users.list") return renderPeopleUsersPage;
|
||||
if (routeId === "people.users.edit") return renderPeopleUserEditPage;
|
||||
if (routeId === "people.users.multi-accounts")
|
||||
return renderPeopleMultiAccountsPage;
|
||||
if (routeId === "people.users.detail") return renderPeopleUserDetailPage;
|
||||
if (routeId.startsWith("people.community.")) return renderPeopleCommunityPage;
|
||||
return renderPeopleStaffPage;
|
||||
}
|
||||
|
||||
export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
PEOPLE_PRIMARY_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -157,3 +157,5 @@ export const PEOPLE_ROUTES = [
|
||||
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
|
||||
),
|
||||
] as const satisfies readonly HousekeepingRouteDefinition[];
|
||||
|
||||
export const PEOPLE_PRIMARY_ROUTES = Object.freeze(PEOPLE_ROUTES.slice(0, 9));
|
||||
File diff suppressed because it is too large.
Load diff
+104
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands";
|
||||
import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands";
|
||||
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
|
||||
import { anyCapability, ok } from "../contracts";
|
||||
import {
|
||||
@@ -60,6 +66,12 @@ describe("housekeeping command bootstrap", () => {
|
||||
expect(
|
||||
SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(SYSTEM_COMMAND_IDS);
|
||||
expect(
|
||||
USER_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(USER_COMMAND_IDS);
|
||||
expect(
|
||||
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(COMMUNITY_COMMAND_IDS);
|
||||
expect(() =>
|
||||
registerHousekeepingCommand({
|
||||
id: "system.bootstrap.too-late",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
||||
import { USER_COMMANDS } from "../../domains/people/commands/user-commands";
|
||||
import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
import {
|
||||
@@ -36,6 +38,8 @@ export function registerHousekeepingCommands<
|
||||
}
|
||||
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SYSTEM_COMMANDS,
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { PEOPLE_PRIMARY_ROUTES } from "../domains/people/routes";
|
||||
import { SYSTEM_ROUTES } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
@@ -355,7 +356,11 @@ describe("housekeeping registry", () => {
|
||||
descriptionKey: expected.descriptionKey,
|
||||
});
|
||||
expect(actual.routes).toEqual(
|
||||
expected.id === "system" ? SYSTEM_ROUTES : [],
|
||||
expected.id === "people"
|
||||
? PEOPLE_PRIMARY_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual([]);
|
||||
expect(actual.inboxSources).toEqual([]);
|
||||
|
||||
@@ -74,12 +74,12 @@ function adminContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeRequestConsumers() {
|
||||
async function invokeRequestConsumers(expectedPageError = "NEXT_NOT_FOUND") {
|
||||
const shell = await AdminNextDomainLayout({
|
||||
children: null,
|
||||
params: Promise.resolve({ domain: "operations" }),
|
||||
});
|
||||
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
await expect(AdminNextPage()).rejects.toThrow(expectedPageError);
|
||||
const commandContext = await requireHousekeepingCapability(
|
||||
anyCapability("admin.dashboard"),
|
||||
);
|
||||
@@ -102,7 +102,9 @@ describe("getHousekeepingCapabilityContext", () => {
|
||||
});
|
||||
|
||||
it("isolates real shell, page, and command preflight consumers between logical requests", async () => {
|
||||
const first = await invokeRequestConsumers();
|
||||
const first = await invokeRequestConsumers(
|
||||
"NEXT_REDIRECT:/ase-next/people/users",
|
||||
);
|
||||
|
||||
expect(first.shell).toBeDefined();
|
||||
expect(first.commandContext.actor).toEqual({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PEOPLE_PRIMARY_ROUTE_IDS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
|
||||
@@ -16,6 +17,9 @@ describe("housekeeping route handlers", () => {
|
||||
|
||||
expect(new Set(handlerIds).size).toBe(handlerIds.length);
|
||||
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||
expect(handlerIds).toEqual(SYSTEM_ROUTE_IDS);
|
||||
expect(handlerIds).toEqual([
|
||||
...PEOPLE_PRIMARY_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PEOPLE_PRIMARY_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||
@@ -14,4 +15,4 @@ export interface HousekeepingRouteHandler {
|
||||
}
|
||||
|
||||
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze([...SYSTEM_ROUTE_HANDLERS]);
|
||||
Object.freeze([...PEOPLE_PRIMARY_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
|
||||
@@ -3295,6 +3295,23 @@
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"people": {
|
||||
"users": {
|
||||
"list": "Users",
|
||||
"edit": "Edit user",
|
||||
"multi-accounts": "Multi-account clusters",
|
||||
"detail": "User details"
|
||||
},
|
||||
"community": {
|
||||
"online": "Online users",
|
||||
"guilds": "Guilds",
|
||||
"guild-detail": "Guild details"
|
||||
},
|
||||
"staff": {
|
||||
"applications": "Staff applications",
|
||||
"teams": "Staff teams"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permissions",
|
||||
|
||||
@@ -3300,6 +3300,23 @@
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"people": {
|
||||
"users": {
|
||||
"list": "Utenti",
|
||||
"edit": "Modifica utente",
|
||||
"multi-accounts": "Gruppi multi-account",
|
||||
"detail": "Dettagli utente"
|
||||
},
|
||||
"community": {
|
||||
"online": "Utenti online",
|
||||
"guilds": "Gruppi",
|
||||
"guild-detail": "Dettagli gruppo"
|
||||
},
|
||||
"staff": {
|
||||
"applications": "Candidature staff",
|
||||
"teams": "Team staff"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permessi",
|
||||
|
||||
Reference in new issue
Block a user