refactor(api): extract vacation management support
This commit is contained in:
@@ -18,6 +18,16 @@ import {
|
||||
dispatchVacationWebhookInBackground,
|
||||
notifyVacationStatusInBackground,
|
||||
} from "./vacation-side-effects.js";
|
||||
import {
|
||||
assertVacationApprovable,
|
||||
assertVacationCancelable,
|
||||
assertVacationRejectable,
|
||||
buildApprovedVacationUpdateData,
|
||||
buildRejectedVacationUpdateData,
|
||||
buildVacationStatusUpdateData,
|
||||
canActorCancelVacation,
|
||||
isVacationManagerRole,
|
||||
} from "./vacation-management-support.js";
|
||||
|
||||
const BatchCreatePublicHolidaysSchema = z.object({
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
@@ -44,10 +54,7 @@ export const vacationManagementProcedures = {
|
||||
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||
"Vacation",
|
||||
);
|
||||
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
|
||||
if (!approvableStatuses.includes(existing.status)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
|
||||
}
|
||||
assertVacationApprovable(existing.status);
|
||||
|
||||
await assertVacationStillChargeable(ctx.db, {
|
||||
resourceId: existing.resourceId,
|
||||
@@ -74,13 +81,11 @@ export const vacationManagementProcedures = {
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
...deductionSnapshotWriteData,
|
||||
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
|
||||
data: buildApprovedVacationUpdateData({
|
||||
deductionSnapshotWriteData,
|
||||
approvedById: userRecord?.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
@@ -121,16 +126,13 @@ export const vacationManagementProcedures = {
|
||||
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||
"Vacation",
|
||||
);
|
||||
if (existing.status !== VacationStatus.PENDING) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
|
||||
}
|
||||
assertVacationRejectable(existing.status);
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: VacationStatus.REJECTED,
|
||||
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
|
||||
},
|
||||
data: buildRejectedVacationUpdateData({
|
||||
rejectionReason: input.rejectionReason,
|
||||
}),
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
@@ -200,13 +202,11 @@ export const vacationManagementProcedures = {
|
||||
});
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: vacation.id },
|
||||
data: {
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
...deductionSnapshotWriteData,
|
||||
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
|
||||
data: buildApprovedVacationUpdateData({
|
||||
deductionSnapshotWriteData,
|
||||
approvedById: userRecord?.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
@@ -252,10 +252,9 @@ export const vacationManagementProcedures = {
|
||||
|
||||
await ctx.db.vacation.updateMany({
|
||||
where: { id: { in: vacations.map((vacation) => vacation.id) } },
|
||||
data: {
|
||||
status: VacationStatus.REJECTED,
|
||||
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
|
||||
},
|
||||
data: buildRejectedVacationUpdateData({
|
||||
rejectionReason: input.rejectionReason,
|
||||
}),
|
||||
});
|
||||
|
||||
for (const vacation of vacations) {
|
||||
@@ -293,28 +292,29 @@ export const vacationManagementProcedures = {
|
||||
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||
"Vacation",
|
||||
);
|
||||
if (existing.status === VacationStatus.CANCELLED) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
|
||||
}
|
||||
assertVacationCancelable(existing.status);
|
||||
|
||||
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
|
||||
if (!userRecord) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
const isManagerOrAdmin = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
if (!isManagerOrAdmin) {
|
||||
if (existing.requestedById !== userRecord.id) {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
const resource = isVacationManagerRole(userRecord.systemRole) || existing.requestedById === userRecord.id
|
||||
? null
|
||||
: await ctx.db.resource.findUnique({
|
||||
where: { id: existing.resourceId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!resource || resource.userId !== userRecord.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only cancel your own vacation requests",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!canActorCancelVacation({
|
||||
actorId: userRecord.id,
|
||||
actorRole: userRecord.systemRole,
|
||||
requestedById: existing.requestedById,
|
||||
resourceUserId: resource?.userId,
|
||||
})) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only cancel your own vacation requests",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
@@ -396,24 +396,18 @@ export const vacationManagementProcedures = {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
if (input.status !== "CANCELLED" && !isManager) {
|
||||
if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { status: input.status };
|
||||
if (input.status === "APPROVED") {
|
||||
data.approvedById = userRecord.id;
|
||||
data.approvedAt = new Date();
|
||||
data.rejectionReason = null;
|
||||
}
|
||||
if (input.note !== undefined) {
|
||||
data.note = input.note;
|
||||
}
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data,
|
||||
data: buildVacationStatusUpdateData({
|
||||
status: input.status,
|
||||
note: input.note,
|
||||
approvedById: userRecord.id,
|
||||
approvedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Prisma } from "@capakraken/db";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import type { UpdateVacationStatusInput } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
type VacationApprovalWriteData = Record<string, unknown>;
|
||||
|
||||
export const approvableVacationStatuses: readonly VacationStatus[] = [
|
||||
VacationStatus.PENDING,
|
||||
VacationStatus.CANCELLED,
|
||||
VacationStatus.REJECTED,
|
||||
];
|
||||
|
||||
export function assertVacationApprovable(status: VacationStatus): void {
|
||||
if (!approvableVacationStatuses.includes(status)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertVacationRejectable(status: VacationStatus): void {
|
||||
if (status !== VacationStatus.PENDING) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Only PENDING vacations can be rejected",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertVacationCancelable(status: VacationStatus): void {
|
||||
if (status === VacationStatus.CANCELLED) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Already cancelled",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isVacationManagerRole(role: string | null | undefined): boolean {
|
||||
return role === "ADMIN" || role === "MANAGER";
|
||||
}
|
||||
|
||||
export function canActorCancelVacation(input: {
|
||||
actorId: string;
|
||||
actorRole: string | null | undefined;
|
||||
requestedById: string | null | undefined;
|
||||
resourceUserId: string | null | undefined;
|
||||
}): boolean {
|
||||
if (isVacationManagerRole(input.actorRole)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return input.requestedById === input.actorId || input.resourceUserId === input.actorId;
|
||||
}
|
||||
|
||||
export function buildApprovedVacationUpdateData(input: {
|
||||
deductionSnapshotWriteData: VacationApprovalWriteData;
|
||||
approvedById?: string | undefined;
|
||||
approvedAt: Date;
|
||||
}): Prisma.VacationUncheckedUpdateInput {
|
||||
return {
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
...input.deductionSnapshotWriteData,
|
||||
...(input.approvedById ? { approvedById: input.approvedById } : {}),
|
||||
approvedAt: input.approvedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRejectedVacationUpdateData(input: {
|
||||
rejectionReason?: string | undefined;
|
||||
}): Prisma.VacationUncheckedUpdateInput {
|
||||
return {
|
||||
status: VacationStatus.REJECTED,
|
||||
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVacationStatusUpdateData(input: {
|
||||
status: UpdateVacationStatusInput["status"];
|
||||
note?: string | undefined;
|
||||
approvedById: string;
|
||||
approvedAt: Date;
|
||||
}): Prisma.VacationUncheckedUpdateInput {
|
||||
const data: Prisma.VacationUncheckedUpdateInput = { status: input.status };
|
||||
|
||||
if (input.status === VacationStatus.APPROVED) {
|
||||
data.approvedById = input.approvedById;
|
||||
data.approvedAt = input.approvedAt;
|
||||
data.rejectionReason = null;
|
||||
}
|
||||
if (input.note !== undefined) {
|
||||
data.note = input.note;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user