Skip to content
Merged
25 changes: 25 additions & 0 deletions apps/api/src/modules/dashboard/dashboard.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { PrismaClient } from "@fragment/database";
import type { Request, RequestHandler } from "express";

import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import { getDashboard } from "./dashboard.service";

const getRequestOrganizationId = (req: Request) => {
if (!req.organization) {
throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다.");
}

return req.organization.id;
};

export const getDashboardHandler: RequestHandler = async (req, res, next) => {
try {
const prisma = req.app.locals.prisma as PrismaClient;
const result = await getDashboard(prisma, getRequestOrganizationId(req));

res.status(200).json(result);
} catch (error) {
next(error);
}
};
43 changes: 43 additions & 0 deletions apps/api/src/modules/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { PrismaClient } from "@fragment/database";
import type { DashboardResponse } from "@fragment/shared";

import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import { toOrganizationDetail } from "@/modules/organization/organization.service";
import { scheduleDetailInclude, toScheduleDetail } from "@/modules/schedules/schedules.mapper";
import { toPrismaId } from "@/utils/mapper";

export async function getDashboard(
prisma: PrismaClient,
organizationId: string,
): Promise<DashboardResponse> {
const organizationDatabaseId = toPrismaId(organizationId);
const organization = await prisma.organization.findUnique({
where: {
id: organizationDatabaseId,
},
include: {
businessHours: true,
},
});

if (!organization) {
throw new HttpError(404, ERROR_CODES.NOT_FOUND, "조직을 찾을 수 없습니다.");
}

const latestConfirmedSchedule = await prisma.schedule.findFirst({
where: {
organizationId: organizationDatabaseId,
status: "CONFIRMED",
},
orderBy: [{ confirmedAt: "desc" }, { id: "desc" }],
include: scheduleDetailInclude,
});

return {
organization: toOrganizationDetail(organization),
latestConfirmedSchedule: latestConfirmedSchedule
? toScheduleDetail(latestConfirmedSchedule)
: null,
};
}
6 changes: 4 additions & 2 deletions apps/api/src/modules/organization/organization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { toApiId, toPrismaId } from "@/utils/mapper";

type OrganizationDatabaseClient = PrismaClient | Prisma.TransactionClient;

type OrganizationWithBusinessHours = Prisma.OrganizationGetPayload<{
export type OrganizationWithBusinessHours = Prisma.OrganizationGetPayload<{
include: {
businessHours: true;
};
Expand All @@ -28,7 +28,9 @@ const getClosesNextDay = ({
}: CreateOrganizationRequest["businessHours"][number]) =>
!isClosed && openTime !== null && closeTime !== null && openTime > closeTime;

const toOrganizationDetail = (organization: OrganizationWithBusinessHours): OrganizationDetail => ({
export const toOrganizationDetail = (
organization: OrganizationWithBusinessHours,
): OrganizationDetail => ({
id: toApiId(organization.id),
name: organization.name,
businessHours: [...organization.businessHours]
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/modules/schedule-history/schedule-history.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { PrismaClient } from "@fragment/database";
import type { ScheduleHistoryQuery } from "@fragment/shared";
import type { Request, RequestHandler } from "express";

import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import {
exportScheduleHistoryCsv,
getScheduleHistoryDetail,
listScheduleHistory,
} from "./schedule-history.service";

const getRequestOrganizationId = (req: Request) => {
if (!req.organization) {
throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다.");
}

return req.organization.id;
};

const getScheduleIdParam = (req: Request) => {
const scheduleId = req.params.scheduleId;

if (typeof scheduleId !== "string") {
throw new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다.");
}

return scheduleId;
};

export const listScheduleHistoryHandler: RequestHandler = async (req, res, next) => {
try {
const prisma = req.app.locals.prisma as PrismaClient;
const result = await listScheduleHistory(
prisma,
getRequestOrganizationId(req),
req.query as unknown as ScheduleHistoryQuery,
);

res.status(200).json(result);
} catch (error) {
next(error);
}
};

export const getScheduleHistoryDetailHandler: RequestHandler = async (req, res, next) => {
try {
const prisma = req.app.locals.prisma as PrismaClient;
const result = await getScheduleHistoryDetail(
prisma,
getRequestOrganizationId(req),
getScheduleIdParam(req),
);

res.status(200).json(result);
} catch (error) {
next(error);
}
};

export const exportScheduleHistoryCsvHandler: RequestHandler = async (req, res, next) => {
try {
const prisma = req.app.locals.prisma as PrismaClient;
const scheduleId = getScheduleIdParam(req);
const result = await exportScheduleHistoryCsv(
prisma,
getRequestOrganizationId(req),
scheduleId,
);

res
.status(200)
.type("text/csv; charset=utf-8")
.set("Content-Disposition", `attachment; filename="schedule-${scheduleId}.csv"`)
.send(result);
} catch (error) {
next(error);
}
};
156 changes: 156 additions & 0 deletions apps/api/src/modules/schedule-history/schedule-history.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { PrismaClient } from "@fragment/database";
import type {
ScheduleHistoryDetailResponse,
ScheduleHistoryListResponse,
ScheduleHistoryQuery,
} from "@fragment/shared";

import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import { dateStringToDate, dateToDateString, dateToTimeString } from "@/utils/date-time";
import { toApiId, toPrismaId } from "@/utils/mapper";
import { scheduleDetailInclude, toScheduleDetail } from "@/modules/schedules/schedules.mapper";

const createScheduleNotFoundError = () =>
new HttpError(404, ERROR_CODES.SCHEDULE_NOT_FOUND, "스케줄을 찾을 수 없습니다.");

const WEEKDAY_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const;

const createDateRangeFilter = ({ year, month }: ScheduleHistoryQuery) => {
if (year === undefined) {
return {};
}

const startDate = dateStringToDate(`${year}-${String(month ?? 1).padStart(2, "0")}-01`);
const endDate =
month === undefined ? dateStringToDate(`${year}-12-31`) : new Date(Date.UTC(year, month, 0));

return {
endDate: {
gte: startDate,
},
startDate: {
lte: endDate,
},
};
};

export async function listScheduleHistory(
prisma: PrismaClient,
organizationId: string,
query: ScheduleHistoryQuery,
): Promise<ScheduleHistoryListResponse> {
const schedules = await prisma.schedule.findMany({
where: {
organizationId: toPrismaId(organizationId),
status: "CONFIRMED",
...createDateRangeFilter(query),
},
orderBy: [{ confirmedAt: "desc" }, { id: "desc" }],
});

return {
items: schedules.map((schedule) => ({
id: toApiId(schedule.id),
startDate: dateToDateString(schedule.startDate),
endDate: dateToDateString(schedule.endDate),
confirmedAt: schedule.confirmedAt!.toISOString(),
})),
};
}

export async function getScheduleHistoryDetail(
prisma: PrismaClient,
organizationId: string,
scheduleId: string,
): Promise<ScheduleHistoryDetailResponse> {
const schedule = await prisma.schedule.findFirst({
where: {
id: toPrismaId(scheduleId),
organizationId: toPrismaId(organizationId),
status: "CONFIRMED",
},
include: scheduleDetailInclude,
});

if (!schedule) {
throw createScheduleNotFoundError();
}

return toScheduleDetail(schedule) as ScheduleHistoryDetailResponse;
}

const FORMULA_INJECTION_PREFIX_PATTERN = /^\s*[=+\-@]/;

const escapeCsvCell = (value: string) => {
const safeValue = FORMULA_INJECTION_PREFIX_PATTERN.test(value) ? `'${value}` : value;

if (!/[",\n\r]/.test(safeValue)) {
return safeValue;
}

return `"${safeValue.replace(/"/g, '""')}"`;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const createCsvRow = (values: string[]) => values.map(escapeCsvCell).join(",");
const UTF8_BOM = "\uFEFF";

const createScheduleDateRange = (startDate: string, endDate: string) => {
const dates: string[] = [];
let currentDate = startDate;

while (currentDate <= endDate) {
dates.push(currentDate);
const nextDate = dateStringToDate(currentDate);
nextDate.setUTCDate(nextDate.getUTCDate() + 1);
currentDate = dateToDateString(nextDate);
}

return dates;
};

const getAssignmentTimeRange = (
assignment: ScheduleHistoryDetailResponse["assignments"][number],
) => {
return `${dateToTimeString(new Date(assignment.startsAt))}-${dateToTimeString(
new Date(assignment.endsAt),
)}`;
};

const getScheduleTimeRanges = (assignments: ScheduleHistoryDetailResponse["assignments"]) => {
return Array.from(new Set(assignments.map(getAssignmentTimeRange))).sort((a, b) =>
a.localeCompare(b),
);
};

export async function exportScheduleHistoryCsv(
prisma: PrismaClient,
organizationId: string,
scheduleId: string,
): Promise<string> {
const schedule = await getScheduleHistoryDetail(prisma, organizationId, scheduleId);
const dates = createScheduleDateRange(schedule.startDate, schedule.endDate);
const timeRanges = getScheduleTimeRanges(schedule.assignments);
const rows = [
createCsvRow(["날짜", "요일", ...timeRanges]),
...dates.map((date) => {
const workDate = dateStringToDate(date);

return createCsvRow([
date,
WEEKDAY_LABELS[workDate.getUTCDay()],
...timeRanges.map((timeRange) => {
return schedule.assignments
.filter(
(assignment) =>
assignment.workDate === date && getAssignmentTimeRange(assignment) === timeRange,
)
.map((assignment) => assignment.workerNameSnapshot)
.join(" / ");
}),
]);
}),
];

return `${UTF8_BOM}${rows.join("\n")}`;
}
21 changes: 21 additions & 0 deletions apps/api/src/routes/dashboard.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { PrismaClient } from "@fragment/database";
import { Router } from "express";

import { createRequireAuth } from "@/middlewares/require-auth";
import { createRequireOrganization } from "@/middlewares/require-organization";
import { verifyAccessToken } from "@/modules/auth/auth.tokens";
import { getDashboardHandler } from "@/modules/dashboard/dashboard.handlers";

export const dashboardRoutes = Router();
const requireAuth = createRequireAuth({ verifyAccessToken });

dashboardRoutes.use(requireAuth);
dashboardRoutes.use((req, res, next) => {
const requireOrganization = createRequireOrganization({
prisma: req.app.locals.prisma as PrismaClient,
});

return requireOrganization(req, res, next);
});

dashboardRoutes.get("/", getDashboardHandler);
4 changes: 4 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { Router } from "express";

import { availabilityRoutes } from "@/routes/availability.routes";
import { authRoutes } from "@/routes/auth.routes";
import { dashboardRoutes } from "@/routes/dashboard.routes";
import { organizationRoutes } from "@/routes/organization.routes";
import { scheduleHistoryRoutes } from "@/routes/schedule-history.routes";
import { schedulesRoutes } from "@/routes/schedules.routes";
import { staffingRulesRoutes } from "@/routes/staffing-rules.routes";
import { workersRoutes } from "@/routes/workers.routes";
Expand All @@ -11,7 +13,9 @@ export const apiRoutes = Router();

apiRoutes.use("/availability", availabilityRoutes);
apiRoutes.use("/auth", authRoutes);
apiRoutes.use("/dashboard", dashboardRoutes);
apiRoutes.use("/organization", organizationRoutes);
apiRoutes.use("/schedule-history", scheduleHistoryRoutes);
apiRoutes.use("/schedules", schedulesRoutes);
apiRoutes.use("/staffing-rules", staffingRulesRoutes);
apiRoutes.use("/workers", workersRoutes);
Loading
Loading