diff --git a/backend/src/app.ts b/backend/src/app.ts index f58c9a4b..aa7be1de 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -81,6 +81,7 @@ app.use('/api', router); createExpressEndpoints(contract, routerV2, app, { logInitialization: true, responseValidation: true, + jsonQuery: true, }); // 에러 핸들러 diff --git a/backend/src/entity/entities/VHistories.ts b/backend/src/entity/entities/VHistories.ts index c8927026..843d8250 100644 --- a/backend/src/entity/entities/VHistories.ts +++ b/backend/src/entity/entities/VHistories.ts @@ -28,7 +28,7 @@ import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; }) export class VHistories { @ViewColumn() - id: string; + id: number; @ViewColumn() lendingCondition: string; diff --git a/backend/src/v2/histories/controller/controller.ts b/backend/src/v2/histories/controller/controller.ts new file mode 100644 index 00000000..82983aed --- /dev/null +++ b/backend/src/v2/histories/controller/controller.ts @@ -0,0 +1,30 @@ +import { contract } from '@jiphyeonjeon-42/contracts'; +import { P, match } from 'ts-pattern'; +import { + UnauthorizedError, + HandlerFor, + unauthorized, +} from '../../shared'; +import { HistoriesService } from '../service'; + +// mkGetMyHistories +type GetMyDeps = Pick; +type MkGetMy = (services: GetMyDeps) => HandlerFor; +export const mkGetMyHistories: MkGetMy = ({ searchMyHistories }) => + async ({ query }) => { + const result = await searchMyHistories(query); + + return match(result) + .with(P.instanceOf(UnauthorizedError), () => unauthorized) + .otherwise((body) => ({ status: 200, body } as const)); + }; + +// mkGetAllHistories +type GetAllDeps = Pick; +type MkGetAll = (services: GetAllDeps) => HandlerFor; +export const mkGetAllHistories: MkGetAll = ({ searchAllHistories }) => async ({ query }) => { + const result = await searchAllHistories(query); + return match(result) + .with(P.instanceOf(UnauthorizedError), () => unauthorized) + .otherwise((body) => ({ status: 200, body } as const)); +}; diff --git a/backend/src/v2/histories/controller/impl.ts b/backend/src/v2/histories/controller/impl.ts new file mode 100644 index 00000000..0f1d1079 --- /dev/null +++ b/backend/src/v2/histories/controller/impl.ts @@ -0,0 +1,9 @@ +import { mkGetMyHistories, mkGetAllHistories } from './controller'; +import { + HistoriesService, +} from '../service'; + +export const implHistoriesController = (service: HistoriesService) => ({ + getMyHistories: mkGetMyHistories(service), + getAllHistories: mkGetAllHistories(service), +}); diff --git a/backend/src/v2/histories/controller/index.ts b/backend/src/v2/histories/controller/index.ts new file mode 100644 index 00000000..04714030 --- /dev/null +++ b/backend/src/v2/histories/controller/index.ts @@ -0,0 +1 @@ +export * from './controller'; diff --git a/backend/src/v2/histories/impl.ts b/backend/src/v2/histories/impl.ts new file mode 100644 index 00000000..f4f6f3d1 --- /dev/null +++ b/backend/src/v2/histories/impl.ts @@ -0,0 +1,26 @@ +import { contract } from '@jiphyeonjeon-42/contracts'; +import { initServer } from '@ts-rest/express'; +import jipDataSource from '~/app-data-source'; +import { roleSet } from '~/v1/auth/auth.type'; +import authValidate from '~/v1/auth/auth.validate'; +import { VHistories } from '~/entity/entities/VHistories'; +import { implHistoriesService } from '~/v2/histories/service/impl'; +import { implHistoriesController } from '~/v2/histories/controller/impl'; + +const service = implHistoriesService({ + historiesRepo: jipDataSource.getRepository(VHistories), +}); + +const handler = implHistoriesController(service); + +const s = initServer(); +export const histories = s.router(contract.histories, { + getMyHistories: { + middleware: [authValidate(roleSet.all)], + handler: handler.getMyHistories, + }, + getAllHistories: { + middleware: [authValidate(roleSet.librarian)], + handler: handler.getAllHistories, + }, +}); diff --git a/backend/src/v2/histories/service/impl.ts b/backend/src/v2/histories/service/impl.ts new file mode 100644 index 00000000..f3892862 --- /dev/null +++ b/backend/src/v2/histories/service/impl.ts @@ -0,0 +1,13 @@ +import { Repository } from 'typeorm'; +import { VHistories } from '~/entity/entities/VHistories'; + +import { + mkSearchHistories, +} from './service'; + +export const implHistoriesService = (repos: { + historiesRepo: Repository; +}) => ({ + searchMyHistories: mkSearchHistories(repos), + searchAllHistories: mkSearchHistories(repos), +}); diff --git a/backend/src/v2/histories/service/index.ts b/backend/src/v2/histories/service/index.ts new file mode 100644 index 00000000..529fbca9 --- /dev/null +++ b/backend/src/v2/histories/service/index.ts @@ -0,0 +1,20 @@ +import { VHistories } from '~/entity/entities/VHistories'; +import { Meta, UnauthorizedError } from '~/v2/shared'; + +type Args = { + query?: string | undefined; + page: number; + limit: number; + type?: 'title' | 'user' | 'callsign' | undefined; +}; + +export type HistoriesService = { + searchMyHistories: ( + args: Args, + ) => Promise; + searchAllHistories: ( + args: Args, + ) => Promise; + } + +export * from './service'; diff --git a/backend/src/v2/histories/service/service.ts b/backend/src/v2/histories/service/service.ts new file mode 100644 index 00000000..7d82defc --- /dev/null +++ b/backend/src/v2/histories/service/service.ts @@ -0,0 +1,68 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { match } from 'ts-pattern'; + +import { FindOperator, Like, type Repository } from 'typeorm'; +import { VHistories } from '~/entity/entities/VHistories'; + +import { Meta } from '~/v2/shared'; +import type { HistoriesService } from '.'; + +// HistoriesService + +type Repos = { historiesRepo: Repository }; + +type MkSearchHistories = ( + repos: Repos +) => HistoriesService['searchAllHistories']; + +type whereCondition = { + login: FindOperator, + title: FindOperator, + callSign: FindOperator, +} | [ + { login: FindOperator }, + { title: FindOperator }, + { callSign: FindOperator }, +]; + +export const mkSearchHistories: MkSearchHistories = ({ historiesRepo }) => async ({ + query, type, page, limit, +}): Promise<{ items: VHistories[], meta: Meta }> => { + let filterQuery: whereCondition = { + login: Like('%%'), + title: Like('%%'), + callSign: Like('%%'), + }; + if (query !== undefined) { + if (type === 'user') { + filterQuery.login = Like(`%${query}%`); + } else if (type === 'title') { + filterQuery.title = Like(`%${query}%`); + } else if (type === 'callsign') { + filterQuery.callSign = Like(`%${query}%`); + } else { + filterQuery = [ + { login: Like(`%${query}%`) }, + { title: Like(`%${query}%`) }, + { callSign: Like(`%${query}%`) }, + ]; + } + } + const [items, count] = await historiesRepo.findAndCount({ + where: filterQuery, + take: limit, + skip: limit * page, + }); + const meta: Meta = { + totalItems: count, + itemCount: items.length, + itemsPerPage: limit, + totalPages: Math.ceil(count / limit), + currentPage: page + 1, + }; + const returnObject = { + items, + meta, + }; + return returnObject; +}; diff --git a/backend/src/v2/histories/type.ts b/backend/src/v2/histories/type.ts new file mode 100644 index 00000000..6cfcd292 --- /dev/null +++ b/backend/src/v2/histories/type.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { positiveInt } from '~/v1/reviews/controller/reviews.type'; + +export type ParsedHistoriesSearchCondition = z.infer; +export const getHistoriesSearchCondition = z.object({ + query: z.string().optional(), + type: z.enum(['user', 'title', 'callsign']).optional(), + page: z.number().nonnegative().default(0), + limit: z.number().nonnegative().default(10), +}); + +export type ParsedHistoriesUserInfo = z.infer; +export const getHistoriesUserInfo = z.object({ + userId: positiveInt, + userRole: positiveInt.max(3), +}); diff --git a/backend/src/v2/routes.ts b/backend/src/v2/routes.ts index 45fdec86..3ec2dca6 100644 --- a/backend/src/v2/routes.ts +++ b/backend/src/v2/routes.ts @@ -3,10 +3,12 @@ import { contract } from '@jiphyeonjeon-42/contracts'; import { initServer } from '@ts-rest/express'; import { reviews } from './reviews/impl'; +import { histories } from './histories/impl'; import { stock } from './stock/impl'; const s = initServer(); export default s.router(contract, { reviews, + histories, stock, }); diff --git a/backend/src/v2/shared/errors.ts b/backend/src/v2/shared/errors.ts index 5c012895..3a97763d 100644 --- a/backend/src/v2/shared/errors.ts +++ b/backend/src/v2/shared/errors.ts @@ -6,6 +6,14 @@ export class BookInfoNotFoundError extends Error { } } +export class UnauthorizedError extends Error { + declare readonly _tag: 'UnauthorizedError'; + + constructor() { + super('권한이 없습니다'); + } +} + export class BookNotFoundError extends Error { declare readonly _tag: 'BookNotFoundError'; diff --git a/backend/src/v2/shared/responses.ts b/backend/src/v2/shared/responses.ts index b56ca2e2..f915bb0f 100644 --- a/backend/src/v2/shared/responses.ts +++ b/backend/src/v2/shared/responses.ts @@ -1,4 +1,4 @@ -import { bookNotFoundSchema, bookInfoNotFoundSchema, reviewNotFoundSchema } from '@jiphyeonjeon-42/contracts'; +import { bookInfoNotFoundSchema, reviewNotFoundSchema, unauthorizedSchema, bookNotFoundSchema } from '@jiphyeonjeon-42/contracts'; import { z } from 'zod'; export const reviewNotFound = { @@ -17,6 +17,14 @@ export const bookInfoNotFound = { } as z.infer, } as const; +export const unauthorized = { + status: 401, + body: { + code: 'UNAUTHORIZED', + description: '권한이 없습니다.', + } as z.infer, +} as const; + export const bookNotFound = { status: 404, body: { diff --git a/backend/src/v2/stock/controller/controller.ts b/backend/src/v2/stock/controller/controller.ts index 7c05ce9b..887b1489 100644 --- a/backend/src/v2/stock/controller/controller.ts +++ b/backend/src/v2/stock/controller/controller.ts @@ -10,15 +10,10 @@ import { StockService } from '../service'; type GetDeps = Pick; type MkGet = (services: GetDeps) => HandlerFor; export const mkGetStock: MkGet = ({ searchStock }) => - async ({ query: { page, limit } }) => { - contract.stock.get.query.safeParse({ page, limit }); - const result = await searchStock({ page, limit }); + async ({ query }) => { + const result = await searchStock(query); - return match(result) - .otherwise(() => ({ - status: 200, - body: result, - } as const)); + return { status: 200, body: result } as const; }; type PatchDeps = Pick; diff --git a/contracts/src/histories/index.ts b/contracts/src/histories/index.ts new file mode 100644 index 00000000..ea92ccc4 --- /dev/null +++ b/contracts/src/histories/index.ts @@ -0,0 +1,37 @@ +import { initContract } from '@ts-rest/core'; +import { z } from 'zod'; +import { + historiesGetQuerySchema, historiesGetResponseSchema, +} from './schema'; +import { unauthorizedSchema } from '../shared'; + +export * from './schema'; + +// contract 를 생성할 때, router 함수를 사용하여 api 를 생성 +const c = initContract(); + +export const historiesContract = c.router( + { + getMyHistories: { + method: 'GET', + path: '/mypage/histories', + description: '마이페이지에서 본인의 대출 기록을 가져온다.', + query: historiesGetQuerySchema, + responses: { + 200: historiesGetResponseSchema, + 401: unauthorizedSchema, + }, + }, + getAllHistories: { + method: 'GET', + path: '/histories', + description: '사서가 전체 대출 기록을 가져온다.', + query: historiesGetQuerySchema, + responses: { + 200: historiesGetResponseSchema, + 401: unauthorizedSchema, + }, + }, + + }, +); diff --git a/contracts/src/histories/schema.ts b/contracts/src/histories/schema.ts new file mode 100644 index 00000000..0132fe15 --- /dev/null +++ b/contracts/src/histories/schema.ts @@ -0,0 +1,32 @@ +import { dateLike, metaSchema, positiveInt } from '../shared'; +import { z } from '../zodWithOpenapi'; + +export const historiesGetQuerySchema = z.object({ + query: z.string().optional(), + type: z.enum(['user', 'title', 'callsign']).optional(), + page: z.number().int().nonnegative().default(0), + limit: z.number().int().nonnegative().default(10), +}); + +export const historiesGetResponseSchema = z.object({ + items: z.array( + z.object({ + id: positiveInt, + lendingCondition: z.string(), + login: z.string(), + returningCondition: z.string(), + penaltyDays: z.number().int().nonnegative(), + callSign: z.string(), + title: z.string(), + bookInfoId: positiveInt, + image: z.string(), + createdAt: dateLike, + returnedAt: dateLike, + updatedAt: dateLike, + dueDate: dateLike, + lendingLibrarianNickName: z.string(), + returningLibrarianNickname: z.string(), + }), + ), + meta: metaSchema, +}); diff --git a/contracts/src/index.ts b/contracts/src/index.ts index 3866dae5..47556f36 100644 --- a/contracts/src/index.ts +++ b/contracts/src/index.ts @@ -1,5 +1,6 @@ import { initContract } from '@ts-rest/core'; import { reviewsContract } from './reviews'; +import { historiesContract } from './histories'; import { usersContract } from './users'; import { likesContract } from './likes'; import { stockContract } from './stock'; @@ -14,8 +15,11 @@ export const contract = c.router( { // likes: likesContract, reviews: reviewsContract, + histories: historiesContract, + stock: stockContract, - users: usersContract, + // TODO(@scarf005): 유저 서비스 작성 +// users: usersContract, }, { pathPrefix: '/api/v2', diff --git a/contracts/src/shared.ts b/contracts/src/shared.ts index e55cf420..edc79c26 100644 --- a/contracts/src/shared.ts +++ b/contracts/src/shared.ts @@ -2,6 +2,8 @@ import { z } from './zodWithOpenapi'; export const positiveInt = z.coerce.number().int().nonnegative(); +export const dateLike = z.union([z.date(), z.string()]).transform(String) + export const bookInfoIdSchema = positiveInt.describe('개별 도서 ID'); type ErrorMessage = { code: string; description: string }; @@ -22,6 +24,10 @@ type ErrorMessage = { code: string; description: string }; export const mkErrorMessageSchema = (code: T) => z.object({ code: z.literal(code) as z.ZodLiteral }); +export const unauthorizedSchema = mkErrorMessageSchema('UNAUTHORIZED').describe( + '권한이 없습니다.', +); + export const bookNotFoundSchema = mkErrorMessageSchema('BOOK_NOT_FOUND').describe('해당 도서가 존재하지 않습니다'); diff --git a/contracts/src/stock/schema.ts b/contracts/src/stock/schema.ts index 4cb4703c..5aa14ce7 100644 --- a/contracts/src/stock/schema.ts +++ b/contracts/src/stock/schema.ts @@ -1,4 +1,4 @@ -import { metaSchema, positiveInt } from '../shared'; +import { dateLike, metaSchema, positiveInt } from '../shared'; import { z } from '../zodWithOpenapi'; export const bookIdSchema = positiveInt.describe('업데이트 할 도서 ID'); @@ -23,15 +23,15 @@ export const stockGetResponseSchema = z.object({ author: z.string(), donator: z.string(), publisher: z.string(), - publishedAt: z.string(), + publishedAt: dateLike, isbn: z.string(), image: z.string(), status: positiveInt, categoryId: positiveInt, callSign: z.string(), category: z.string(), - updatedAt: z.string(), + updatedAt: dateLike, }), ), meta: metaSchema, -}); \ No newline at end of file +});