Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 83 additions & 16 deletions apps/api/src/modules/availability/availability.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,40 @@ const createBusinessHourRow = ({
updatedAt: timestamp,
});

const createMinimumStaffingRuleRow = ({
id = BigInt(1),
organizationId = BigInt(1),
dayOfWeek = "WED",
startTime = timeOfDay("09:00"),
endTime = timeOfDay("18:00"),
endsNextDay = false,
requiredCount = 1,
} = {}) => ({
id,
organizationId,
dayOfWeek,
startTime,
endTime,
endsNextDay,
requiredCount,
createdAt: timestamp,
updatedAt: timestamp,
});

const createPrismaMock = ({
organization = { id: BigInt(1), businessHours: [createBusinessHourRow()] },
organization = {
id: BigInt(1),
businessHours: [createBusinessHourRow()],
minimumStaffingRules: [createMinimumStaffingRuleRow()],
},
worker = { id: BigInt(1) },
workerAvailableTime = {},
}: {
organization?: { businessHours: ReturnType<typeof createBusinessHourRow>[]; id: bigint } | null;
organization?: {
businessHours: ReturnType<typeof createBusinessHourRow>[];
id: bigint;
minimumStaffingRules: ReturnType<typeof createMinimumStaffingRuleRow>[];
} | null;
worker?: { id: bigint } | null;
workerAvailableTime?: {
createMany?: jest.Mock;
Expand All @@ -65,15 +93,27 @@ const createPrismaMock = ({
.fn<() => Promise<{ id: bigint } | null>>()
.mockResolvedValue(organization);
const workerFindFirst = jest.fn<() => Promise<{ id: bigint } | null>>().mockResolvedValue(worker);
const workerAvailableTimeCreateMany =
workerAvailableTime.createMany ??
jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: 0 });
const workerAvailableTimeDeleteMany =
workerAvailableTime.deleteMany ??
jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: 0 });
const workerAvailableTimeFindMany =
workerAvailableTime.findMany ??
jest.fn<() => Promise<ReturnType<typeof createAvailabilityRow>[]>>().mockResolvedValue([]);

return {
prisma: {
activeSchedulePlanningPeriod: {
findUnique: jest.fn<() => Promise<null>>().mockResolvedValue(null),
},
$transaction: jest.fn(async (callback: (transaction: unknown) => unknown) =>
callback({
workerAvailableTime: {
createMany: workerAvailableTime.createMany ?? jest.fn(),
deleteMany: workerAvailableTime.deleteMany ?? jest.fn(),
findMany: workerAvailableTime.findMany ?? jest.fn(),
createMany: workerAvailableTimeCreateMany,
deleteMany: workerAvailableTimeDeleteMany,
findMany: workerAvailableTimeFindMany,
},
}),
),
Expand All @@ -84,9 +124,9 @@ const createPrismaMock = ({
findFirst: workerFindFirst,
},
workerAvailableTime: {
createMany: workerAvailableTime.createMany ?? jest.fn(),
deleteMany: workerAvailableTime.deleteMany ?? jest.fn(),
findMany: workerAvailableTime.findMany ?? jest.fn(),
createMany: workerAvailableTimeCreateMany,
deleteMany: workerAvailableTimeDeleteMany,
findMany: workerAvailableTimeFindMany,
},
} as unknown as PrismaClient,
organizationFindUnique,
Expand Down Expand Up @@ -334,6 +374,7 @@ describe("replaceAvailability", () => {
});

it("rejects availability on closed days", async () => {
const availableTimeCreateMany = jest.fn();
const { prisma } = createPrismaMock({
organization: {
id: BigInt(1),
Expand All @@ -344,6 +385,10 @@ describe("replaceAvailability", () => {
closeTime: null,
}),
],
minimumStaffingRules: [createMinimumStaffingRuleRow()],
},
workerAvailableTime: {
createMany: availableTimeCreateMany,
},
});

Expand All @@ -360,14 +405,19 @@ describe("replaceAvailability", () => {
},
],
}),
).rejects.toMatchObject({
code: ERROR_CODES.CLOSED_DAY,
statusCode: 400,
).resolves.toEqual({
items: [],
});
expect(availableTimeCreateMany).not.toHaveBeenCalled();
});

it("rejects availability outside business hours", async () => {
const { prisma } = createPrismaMock();
it("trims availability to schedulable ranges", async () => {
const availableTimeCreateMany = jest.fn();
const { prisma } = createPrismaMock({
workerAvailableTime: {
createMany: availableTimeCreateMany,
},
});

await expect(
replaceAvailability(prisma, "1", {
Expand All @@ -382,9 +432,18 @@ describe("replaceAvailability", () => {
},
],
}),
).rejects.toMatchObject({
code: ERROR_CODES.INVALID_TIME_RANGE,
statusCode: 400,
).resolves.toEqual({
items: [],
});
expect(availableTimeCreateMany).toHaveBeenCalledWith({
data: [
{
workerId: BigInt(1),
availableDate: dateOnly("2026-07-01"),
startsAt: new Date("2026-07-01T09:00:00.000Z"),
endsAt: new Date("2026-07-01T10:00:00.000Z"),
},
],
});
});

Expand Down Expand Up @@ -437,6 +496,14 @@ describe("replaceAvailability", () => {
closesNextDay: true,
}),
],
minimumStaffingRules: [
createMinimumStaffingRuleRow({
dayOfWeek: "FRI",
startTime: timeOfDay("22:00"),
endTime: timeOfDay("02:00"),
endsNextDay: true,
}),
],
},
workerAvailableTime: {
createMany: availableTimeCreateMany,
Expand Down
151 changes: 107 additions & 44 deletions apps/api/src/modules/availability/availability.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,21 @@ import type {
import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import {
addDays,
createDateTimeOnDate,
dateStringToDate,
dateTimeStringToDate,
dateToDateString,
} from "@/utils/date-time";
import { getDayOfWeek } from "@/utils/day-of-week";
pruneAvailabilityTimesToSchedulableRanges,
type PrunedAvailabilityTime,
} from "@/modules/scheduling-time-policy";
import { dateStringToDate, dateTimeStringToDate, dateToDateString } from "@/utils/date-time";
import { toApiId, toPrismaId } from "@/utils/mapper";

type AvailabilityDatabaseClient = PrismaClient | Prisma.TransactionClient;

type OrganizationForAvailability = Prisma.OrganizationGetPayload<{
include: { businessHours: true };
include: { businessHours: true; minimumStaffingRules: true };
}>;

const createInvalidTimeRangeError = () =>
new HttpError(400, ERROR_CODES.INVALID_TIME_RANGE, "가능 시간이 조직 운영시간을 벗어났습니다.");

const createClosedDayError = () =>
new HttpError(400, ERROR_CODES.CLOSED_DAY, "휴무일에는 가능 시간을 저장할 수 없습니다.");

const findOrganizationByUserId = async (
prisma: PrismaClient,
userId: string,
Expand All @@ -44,6 +38,7 @@ const findOrganizationByUserId = async (
},
include: {
businessHours: true,
minimumStaffingRules: true,
},
});

Expand Down Expand Up @@ -77,41 +72,34 @@ const toAvailability = (availability: PrismaWorkerAvailableTime): Availability =
endsAt: availability.endsAt.toISOString(),
});

const assertAvailabilityItemInBusinessHours = (
organization: OrganizationForAvailability,
input: ReplaceAvailabilityRequest,
) => {
const isPrismaClient = (client: AvailabilityDatabaseClient): client is PrismaClient =>
"$transaction" in client;

const assertAvailabilityItemsInDateRange = (input: ReplaceAvailabilityRequest) => {
for (const item of input.items) {
if (item.availableDate < input.startDate || item.availableDate > input.endDate) {
throw createInvalidTimeRangeError();
}

const availableDate = dateStringToDate(item.availableDate);
const businessHour = organization.businessHours.find(
(currentBusinessHour) => currentBusinessHour.dayOfWeek === getDayOfWeek(availableDate),
);

if (
!businessHour ||
businessHour.isClosed ||
!businessHour.openTime ||
!businessHour.closeTime
) {
throw createClosedDayError();
}

const startsAt = dateTimeStringToDate(item.startsAt);
const endsAt = dateTimeStringToDate(item.endsAt);
const openAt = createDateTimeOnDate(availableDate, businessHour.openTime);
const closeDate = businessHour.closesNextDay ? addDays(availableDate, 1) : availableDate;
const closeAt = createDateTimeOnDate(closeDate, businessHour.closeTime);

if (startsAt >= endsAt || startsAt < openAt || endsAt > closeAt) {
if (dateTimeStringToDate(item.startsAt) >= dateTimeStringToDate(item.endsAt)) {
throw createInvalidTimeRangeError();
}
}
};

const createPrunedAvailabilityItems = (
organization: OrganizationForAvailability,
availableTimes: Pick<
PrismaWorkerAvailableTime,
"availableDate" | "endsAt" | "startsAt" | "workerId"
>[],
): PrunedAvailabilityTime[] =>
pruneAvailabilityTimesToSchedulableRanges({
availableTimes,
businessHours: organization.businessHours,
rules: organization.minimumStaffingRules,
});

export async function listAvailability(
prisma: PrismaClient,
userId: string,
Expand All @@ -126,6 +114,8 @@ export async function listAvailability(
throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다.");
}

await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationId);

const items = await prisma.workerAvailableTime.findMany({
where: {
workerId,
Expand Down Expand Up @@ -156,7 +146,17 @@ export async function replaceAvailability(
throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다.");
}

assertAvailabilityItemInBusinessHours(organization, input);
assertAvailabilityItemsInDateRange(input);

const prunedItems = createPrunedAvailabilityItems(
organization,
input.items.map((item) => ({
workerId,
availableDate: dateStringToDate(item.availableDate),
startsAt: dateTimeStringToDate(item.startsAt),
endsAt: dateTimeStringToDate(item.endsAt),
})),
);

return prisma.$transaction(async (transaction) => {
const dateRange = {
Expand All @@ -171,14 +171,9 @@ export async function replaceAvailability(
},
});

if (input.items.length > 0) {
if (prunedItems.length > 0) {
await transaction.workerAvailableTime.createMany({
data: input.items.map((item) => ({
workerId,
availableDate: dateStringToDate(item.availableDate),
startsAt: dateTimeStringToDate(item.startsAt),
endsAt: dateTimeStringToDate(item.endsAt),
})),
data: prunedItems,
});
}

Expand All @@ -195,3 +190,71 @@ export async function replaceAvailability(
};
});
}

export async function pruneOrganizationAvailabilityForActivePlanningPeriod(
client: AvailabilityDatabaseClient,
organizationId: bigint,
): Promise<void> {
const activePeriod = await client.activeSchedulePlanningPeriod.findUnique({
where: {
organizationId,
},
});

if (!activePeriod) {
return;
}

const organization = await client.organization.findUnique({
where: {
id: organizationId,
},
include: {
businessHours: true,
minimumStaffingRules: true,
},
});

if (!organization) {
return;
}

const dateRange = {
gte: activePeriod.startDate,
lte: activePeriod.endDate,
};
const existingItems = await client.workerAvailableTime.findMany({
where: {
worker: {
organizationId,
},
availableDate: dateRange,
},
orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
});
const prunedItems = createPrunedAvailabilityItems(organization, existingItems);

const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => {
await transaction.workerAvailableTime.deleteMany({
where: {
worker: {
organizationId,
},
availableDate: dateRange,
},
});

if (prunedItems.length > 0) {
await transaction.workerAvailableTime.createMany({
data: prunedItems,
});
}
};

if (isPrismaClient(client)) {
await client.$transaction(replaceAvailability);
Comment on lines +237 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

프루닝 대상 조회도 같은 트랜잭션 안으로 넣어 주세요.

replaceAvailability는 트랜잭션에서 삭제/재생성하지만, 재생성할 prunedItems는 트랜잭션 시작 전에 계산됩니다. 동시 저장이 끼어들면 새 가능 시간이 삭제되고 이전 스냅샷만 다시 생성될 수 있습니다.

수정 예시
-  const existingItems = await client.workerAvailableTime.findMany({
-    where: {
-      worker: {
-        organizationId,
-      },
-      availableDate: dateRange,
-    },
-    orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
-  });
-  const prunedItems = createPrunedAvailabilityItems(organization, existingItems);
-
   const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => {
+    const existingItems = await transaction.workerAvailableTime.findMany({
+      where: {
+        worker: {
+          organizationId,
+        },
+        availableDate: dateRange,
+      },
+      orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
+    });
+    const prunedItems = createPrunedAvailabilityItems(organization, existingItems);
+
     await transaction.workerAvailableTime.deleteMany({
       where: {
         worker: {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => {
await transaction.workerAvailableTime.deleteMany({
where: {
worker: {
organizationId,
},
availableDate: dateRange,
},
});
if (prunedItems.length > 0) {
await transaction.workerAvailableTime.createMany({
data: prunedItems,
});
}
};
if (isPrismaClient(client)) {
await client.$transaction(replaceAvailability);
const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => {
const existingItems = await transaction.workerAvailableTime.findMany({
where: {
worker: {
organizationId,
},
availableDate: dateRange,
},
orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
});
const prunedItems = createPrunedAvailabilityItems(organization, existingItems);
await transaction.workerAvailableTime.deleteMany({
where: {
worker: {
organizationId,
},
availableDate: dateRange,
},
});
if (prunedItems.length > 0) {
await transaction.workerAvailableTime.createMany({
data: prunedItems,
});
}
};
if (isPrismaClient(client)) {
await client.$transaction(replaceAvailability);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/availability/availability.service.ts` around lines 237 -
255, 프루닝 대상 조회가 트랜잭션 밖에서 계산되어 삭제/재생성 시점과 스냅샷이 어긋나는 문제입니다.
`availability.service.ts`의 `replaceAvailability`와 `client.$transaction` 흐름에서
`prunedItems` 계산도 같은 트랜잭션 안으로 옮기고, `deleteMany`와 `createMany`가 같은 읽기 시점의 데이터만
사용하도록 `AvailabilityDatabaseClient` 기반의 트랜잭션 콜백 내부에서 조회부터 재생성까지 처리하세요.

return;
}

await replaceAvailability(client);
}
Loading