refactor(api): extract country procedures

This commit is contained in:
2026-03-31 20:25:11 +02:00
parent e375d634f6
commit a22dee6d25
4 changed files with 618 additions and 215 deletions
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createAuditEntry } = vi.hoisted(() => ({
createAuditEntry: vi.fn(),
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry,
}));
import {
createCountry,
deleteMetroCity,
updateCountry,
} from "../router/country-procedure-support.js";
function createContext(db: Record<string, unknown>) {
return {
db: db as never,
dbUser: { id: "user_admin" } as never,
};
}
describe("country procedure support", () => {
beforeEach(() => {
createAuditEntry.mockReset();
});
it("creates a country after checking code uniqueness", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const create = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Germany",
metroCities: [],
});
const result = await createCountry(
createContext({
country: { findUnique, create },
}),
{
code: "DE",
name: "Germany",
dailyWorkingHours: 8,
},
);
expect(findUnique).toHaveBeenCalledWith({
where: { code: "DE" },
});
expect(create).toHaveBeenCalledWith({
data: {
code: "DE",
name: "Germany",
dailyWorkingHours: 8,
},
include: { metroCities: true },
});
expect(result.id).toBe("country_de");
expect(createAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
entityType: "Country",
action: "CREATE",
entityId: "country_de",
userId: "user_admin",
}),
);
});
it("rechecks code uniqueness only when the code changes", async () => {
const findUnique = vi
.fn()
.mockResolvedValueOnce({
id: "country_de",
code: "DE",
name: "Germany",
})
.mockResolvedValueOnce(null);
const update = vi.fn().mockResolvedValue({
id: "country_de",
code: "DEU",
name: "Germany",
metroCities: [],
});
const result = await updateCountry(
createContext({
country: { findUnique, update },
}),
{
id: "country_de",
data: {
code: "DEU",
},
},
);
expect(findUnique).toHaveBeenNthCalledWith(1, {
where: { id: "country_de" },
});
expect(findUnique).toHaveBeenNthCalledWith(2, {
where: { code: "DEU" },
});
expect(update).toHaveBeenCalledWith({
where: { id: "country_de" },
data: { code: "DEU" },
include: { metroCities: true },
});
expect(result.code).toBe("DEU");
expect(createAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
entityType: "Country",
action: "UPDATE",
before: expect.objectContaining({ code: "DE" }),
after: expect.objectContaining({ code: "DEU" }),
}),
);
});
it("deletes a metro city only after the resource-count guard passes", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "city_berlin",
name: "Berlin",
_count: { resources: 0 },
});
const remove = vi.fn().mockResolvedValue({ id: "city_berlin" });
const result = await deleteMetroCity(
createContext({
metroCity: { findUnique, delete: remove },
}),
{ id: "city_berlin" },
);
expect(findUnique).toHaveBeenCalledWith({
where: { id: "city_berlin" },
include: { _count: { select: { resources: true } } },
});
expect(remove).toHaveBeenCalledWith({
where: { id: "city_berlin" },
});
expect(result).toEqual({
success: true,
id: "city_berlin",
name: "Berlin",
});
expect(createAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
entityType: "MetroCity",
action: "DELETE",
entityId: "city_berlin",
}),
);
});
});
@@ -0,0 +1,152 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createAuditEntry } = vi.hoisted(() => ({
createAuditEntry: vi.fn(),
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry,
}));
import { countryRouter } from "../router/country.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(countryRouter);
function createProtectedCaller(
db: Record<string, unknown>,
options: {
role?: SystemRole;
granted?: PermissionKey[];
} = {},
) {
const { role = SystemRole.USER, granted = [] } = options;
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: role === SystemRole.ADMIN ? "user_admin" : "user_1",
systemRole: role,
permissionOverrides: granted.length > 0 ? { granted } : null,
},
});
}
describe("country router", () => {
beforeEach(() => {
createAuditEntry.mockReset();
});
it("lists countries through the protected router", async () => {
const findMany = vi.fn().mockResolvedValue([
{ id: "country_de", code: "DE", name: "Germany", metroCities: [] },
]);
const caller = createProtectedCaller({
country: { findMany },
});
const result = await caller.list({ isActive: true });
expect(findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
expect(result).toHaveLength(1);
});
it("allows detailed country reads with resource-overview permission", async () => {
const findFirst = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
scheduleRules: null,
metroCities: [{ id: "city_berlin", name: "Berlin", countryId: "country_de" }],
_count: { resources: 4 },
});
const caller = createProtectedCaller({
country: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst,
},
}, {
granted: [PermissionKey.VIEW_ALL_RESOURCES],
});
const result = await caller.getByIdentifier({ identifier: "DE" });
expect(findFirst).toHaveBeenCalledWith({
where: { code: { equals: "DE", mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
expect(result._count.resources).toBe(4);
});
it("creates and updates countries through the admin router", async () => {
const findUnique = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "country_de",
code: "DE",
name: "Germany",
});
const create = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Germany",
metroCities: [],
});
const update = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Deutschland",
metroCities: [],
});
const caller = createProtectedCaller({
country: { findUnique, create, update },
}, {
role: SystemRole.ADMIN,
});
const created = await caller.create({
code: "DE",
name: "Germany",
dailyWorkingHours: 8,
});
const updated = await caller.update({
id: "country_de",
data: {
name: "Deutschland",
},
});
expect(create).toHaveBeenCalledWith({
data: {
code: "DE",
name: "Germany",
dailyWorkingHours: 8,
},
include: { metroCities: true },
});
expect(update).toHaveBeenCalledWith({
where: { id: "country_de" },
data: { name: "Deutschland" },
include: { metroCities: true },
});
expect(created.id).toBe("country_de");
expect(updated.name).toBe("Deutschland");
expect(createAuditEntry).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,274 @@
import {
CreateCountrySchema,
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import type { TRPCContext } from "../trpc.js";
import {
assertCountryCodeAvailable,
assertMetroCityDeletable,
buildCountryCreateData,
buildCountryListWhere,
buildCountryUpdateData,
buildMetroCityCreateData,
buildMetroCityUpdateData,
findCountryByIdentifier,
} from "./country-support.js";
type CountryProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
type CountryIdentifierReadModel = {
id: string;
code: string;
name: string;
isActive: boolean;
dailyWorkingHours: number;
};
type CountryDetailReadModel = CountryIdentifierReadModel & {
scheduleRules?: Prisma.JsonValue | null;
metroCities: Array<{ id: string; name: string; countryId: string }>;
_count: { resources: number };
};
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export const countryListInputSchema = z.object({
isActive: z.boolean().optional(),
}).optional();
export const countryIdentifierInputSchema = z.object({
identifier: z.string().trim().min(1),
});
export const countryIdInputSchema = z.object({
id: z.string(),
});
export const metroCityIdInputSchema = z.object({
id: z.string(),
});
export const countryUpdateInputSchema = z.object({
id: z.string(),
data: UpdateCountrySchema,
});
export const metroCityUpdateInputSchema = z.object({
id: z.string(),
data: UpdateMetroCitySchema,
});
type CountryListInput = z.infer<typeof countryListInputSchema>;
type CountryIdentifierInput = z.infer<typeof countryIdentifierInputSchema>;
type CountryIdInput = z.infer<typeof countryIdInputSchema>;
type CreateCountryInput = z.infer<typeof CreateCountrySchema>;
type UpdateCountryInput = z.infer<typeof countryUpdateInputSchema>;
type CreateMetroCityInput = z.infer<typeof CreateMetroCitySchema>;
type UpdateMetroCityInput = z.infer<typeof metroCityUpdateInputSchema>;
type MetroCityIdInput = z.infer<typeof metroCityIdInputSchema>;
export async function listCountries(ctx: CountryProcedureContext, input: CountryListInput) {
return ctx.db.country.findMany({
where: buildCountryListWhere(input ?? {}),
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
}
export async function resolveCountryByIdentifier(
ctx: CountryProcedureContext,
input: CountryIdentifierInput,
) {
return findCountryByIdentifier<CountryIdentifierReadModel>(ctx.db, input.identifier, {
select: {
id: true,
code: true,
name: true,
isActive: true,
dailyWorkingHours: true,
},
});
}
export async function getCountryByIdentifier(
ctx: CountryProcedureContext,
input: CountryIdentifierInput,
) {
return findCountryByIdentifier<CountryDetailReadModel>(ctx.db, input.identifier, {
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
export async function getCountryById(ctx: CountryProcedureContext, input: CountryIdInput) {
return findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.id },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
}),
"Country",
);
}
export async function getMetroCityById(
ctx: CountryProcedureContext,
input: MetroCityIdInput,
) {
return findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
select: { id: true, name: true, countryId: true },
}),
"Metro city",
);
}
export async function createCountry(ctx: CountryProcedureContext, input: CreateCountryInput) {
await assertCountryCodeAvailable(ctx.db, input.code);
const created = await ctx.db.country.create({
data: buildCountryCreateData(input),
include: { metroCities: true },
});
void createAuditEntry({
db: ctx.db,
entityType: "Country",
entityId: created.id,
entityName: created.name,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}
export async function updateCountry(ctx: CountryProcedureContext, input: UpdateCountryInput) {
const existing = await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.id } }),
"Country",
);
if (input.data.code && input.data.code !== existing.code) {
await assertCountryCodeAvailable(ctx.db, input.data.code, existing.id);
}
const before = existing as unknown as Record<string, unknown>;
const updated = await ctx.db.country.update({
where: { id: input.id },
data: buildCountryUpdateData(input.data),
include: { metroCities: true },
});
void createAuditEntry({
db: ctx.db,
entityType: "Country",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}
export async function createMetroCity(ctx: CountryProcedureContext, input: CreateMetroCityInput) {
await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.countryId } }),
"Country",
);
const created = await ctx.db.metroCity.create({
data: buildMetroCityCreateData(input),
});
void createAuditEntry({
db: ctx.db,
entityType: "MetroCity",
entityId: created.id,
entityName: created.name,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}
export async function updateMetroCity(
ctx: CountryProcedureContext,
input: UpdateMetroCityInput,
) {
const existing = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({ where: { id: input.id } }),
"Metro city",
);
const before = existing as unknown as Record<string, unknown>;
const updated = await ctx.db.metroCity.update({
where: { id: input.id },
data: buildMetroCityUpdateData(input.data),
});
void createAuditEntry({
db: ctx.db,
entityType: "MetroCity",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}
export async function deleteMetroCity(
ctx: CountryProcedureContext,
input: MetroCityIdInput,
) {
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
}),
"Metro city",
);
assertMetroCityDeletable(city);
await ctx.db.metroCity.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
entityType: "MetroCity",
entityId: city.id,
entityName: city.name,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
before: city as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true, id: city.id, name: city.name };
}
+36 -215
View File
@@ -1,13 +1,3 @@
import {
CreateCountrySchema,
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import {
adminProcedure,
createTRPCRouter,
@@ -15,234 +5,65 @@ import {
resourceOverviewProcedure,
} from "../trpc.js";
import {
assertCountryCodeAvailable,
assertMetroCityDeletable,
buildCountryCreateData,
buildCountryListWhere,
buildCountryUpdateData,
buildMetroCityCreateData,
buildMetroCityUpdateData,
findCountryByIdentifier,
} from "./country-support.js";
type CountryIdentifierReadModel = {
id: string;
code: string;
name: string;
isActive: boolean;
dailyWorkingHours: number;
};
type CountryDetailReadModel = CountryIdentifierReadModel & {
scheduleRules?: Prisma.JsonValue | null;
metroCities: Array<{ id: string; name: string; countryId: string }>;
_count: { resources: number };
};
countryIdInputSchema,
countryIdentifierInputSchema,
countryListInputSchema,
countryUpdateInputSchema,
createCountry,
createMetroCity,
deleteMetroCity,
getCountryById,
getCountryByIdentifier,
getMetroCityById,
listCountries,
metroCityIdInputSchema,
metroCityUpdateInputSchema,
resolveCountryByIdentifier,
updateCountry,
updateMetroCity,
} from "./country-procedure-support.js";
import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared";
export const countryRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.db.country.findMany({
where: buildCountryListWhere(input ?? {}),
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
}),
.input(countryListInputSchema)
.query(({ ctx, input }) => listCountries(ctx, input)),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
return findCountryByIdentifier<CountryIdentifierReadModel>(ctx.db, input.identifier, {
select: {
id: true,
code: true,
name: true,
isActive: true,
dailyWorkingHours: true,
},
});
}),
.input(countryIdentifierInputSchema)
.query(({ ctx, input }) => resolveCountryByIdentifier(ctx, input)),
getByIdentifier: resourceOverviewProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
return findCountryByIdentifier<CountryDetailReadModel>(ctx.db, input.identifier, {
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}),
.input(countryIdentifierInputSchema)
.query(({ ctx, input }) => getCountryByIdentifier(ctx, input)),
getById: resourceOverviewProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.id },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
}),
"Country",
);
return country;
}),
.input(countryIdInputSchema)
.query(({ ctx, input }) => getCountryById(ctx, input)),
getCityById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
select: { id: true, name: true, countryId: true },
}),
"Metro city",
);
return city;
}),
.input(metroCityIdInputSchema)
.query(({ ctx, input }) => getMetroCityById(ctx, input)),
create: adminProcedure
.input(CreateCountrySchema)
.mutation(async ({ ctx, input }) => {
await assertCountryCodeAvailable(ctx.db, input.code);
const created = await ctx.db.country.create({
data: buildCountryCreateData(input),
include: { metroCities: true },
});
void createAuditEntry({
db: ctx.db,
entityType: "Country",
entityId: created.id,
entityName: created.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}),
.mutation(({ ctx, input }) => createCountry(ctx, input)),
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateCountrySchema }))
.mutation(async ({ ctx, input }) => {
const existing = await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.id } }),
"Country",
);
if (input.data.code && input.data.code !== existing.code) {
await assertCountryCodeAvailable(ctx.db, input.data.code, existing.id);
}
const before = existing as unknown as Record<string, unknown>;
const updated = await ctx.db.country.update({
where: { id: input.id },
data: buildCountryUpdateData(input.data),
include: { metroCities: true },
});
void createAuditEntry({
db: ctx.db,
entityType: "Country",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
.input(countryUpdateInputSchema)
.mutation(({ ctx, input }) => updateCountry(ctx, input)),
// ─── Metro City ─────────────────────────────────────────────
createCity: adminProcedure
.input(CreateMetroCitySchema)
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.countryId } }),
"Country",
);
const created = await ctx.db.metroCity.create({
data: buildMetroCityCreateData(input),
});
void createAuditEntry({
db: ctx.db,
entityType: "MetroCity",
entityId: created.id,
entityName: created.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}),
.mutation(({ ctx, input }) => createMetroCity(ctx, input)),
updateCity: adminProcedure
.input(z.object({ id: z.string(), data: UpdateMetroCitySchema }))
.mutation(async ({ ctx, input }) => {
const existing = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({ where: { id: input.id } }),
"Metro city",
);
const before = existing as unknown as Record<string, unknown>;
const updated = await ctx.db.metroCity.update({
where: { id: input.id },
data: buildMetroCityUpdateData(input.data),
});
void createAuditEntry({
db: ctx.db,
entityType: "MetroCity",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
.input(metroCityUpdateInputSchema)
.mutation(({ ctx, input }) => updateMetroCity(ctx, input)),
deleteCity: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
}),
"Metro city",
);
assertMetroCityDeletable(city);
await ctx.db.metroCity.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
entityType: "MetroCity",
entityId: city.id,
entityName: city.name,
action: "DELETE",
userId: ctx.dbUser?.id,
before: city as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true, id: city.id, name: city.name };
}),
.input(metroCityIdInputSchema)
.mutation(({ ctx, input }) => deleteMetroCity(ctx, input)),
});