Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DB_NAME"
WEB_APP_ORIGIN="http://localhost:3000"
API_PUBLIC_BASE_URL="http://localhost:3001/api"
JWT_ACCESS_SECRET="replace-with-local-access-secret"
JWT_ACCESS_EXPIRES_IN="15m"
REFRESH_TOKEN_EXPIRES_DAYS="7"
Expand Down
7 changes: 5 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
"scripts": {
"start:dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts",
"dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts",
"build": "pnpm --filter @fragment/shared build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"start": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\"",
"test": "jest --runInBand",
"test:watch": "jest --watch",
"typecheck": "pnpm --filter @fragment/shared build && tsc --noEmit"
"typecheck": "corepack pnpm --filter @fragment/shared build && corepack pnpm --filter @fragment/database build && tsc --noEmit"
},
"dependencies": {
"@fragment/database": "workspace:*",
Expand All @@ -20,6 +20,8 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.3",
"swagger-ui-express": "^5.0.1",
"yaml": "^2.9.0",
"zod": "^3.25.76"
},
"devDependencies": {
Expand All @@ -30,6 +32,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/supertest": "^6.0.3",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9",
"jest": "^29.7.0",
"supertest": "^7.2.2",
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import swaggerUi from "swagger-ui-express";
import type { PrismaClient } from "@fragment/database";

import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import { errorHandler } from "@/middlewares/error-handler";
import { getOpenApiDocument } from "@/openapi";
import { apiRoutes } from "@/routes";

type AppDependencies = {
Expand All @@ -31,6 +33,19 @@ export const createApp = ({ prisma }: AppDependencies) => {
app.use(express.json());
app.use(cookieParser());

const openApiDocument = getOpenApiDocument();

app.get("/api/openapi.json", (_req, res) => {
res.json(openApiDocument);
});
app.use(
"/api/docs",
swaggerUi.serve,
swaggerUi.setup(openApiDocument, {
customSiteTitle: "프래그먼트 API Docs",
}),
);

app.use("/api", apiRoutes);

app.use((_req, _res, next) => {
Expand Down
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, '""')}"`;
};

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")}`;
}
Loading