diff --git a/backend/src/kysely/paginated.ts b/backend/src/kysely/paginated.ts new file mode 100644 index 00000000..414e1138 --- /dev/null +++ b/backend/src/kysely/paginated.ts @@ -0,0 +1,28 @@ +import { SelectQueryBuilder } from 'kysely'; + +type Paginated = { + items: O[], + meta: { + totalItems: number; + totalPages: number; + }; +} + +export const metaPaginated = async ( + qb: SelectQueryBuilder, + { page, perPage }: { page: number; perPage: number }, +): Promise> => { + const { totalItems } = await qb + .clearSelect() + .select(({ fn }) => fn.countAll().as('totalItems')) + .executeTakeFirstOrThrow(); + + const items = await qb + .offset((page - 1) * perPage) + .limit(perPage) + .execute(); + + const totalPages = Math.ceil(totalItems / perPage); + + return { items, meta: { totalItems, totalPages } }; +}; diff --git a/backend/src/kysely/shared.ts b/backend/src/kysely/shared.ts index 0ff8ad05..58fe8de1 100644 --- a/backend/src/kysely/shared.ts +++ b/backend/src/kysely/shared.ts @@ -8,7 +8,7 @@ const throwIf = (value: T, ok: (v: T) => boolean) => { throw new Error(`값이 예상과 달리 ${value}입니다`); }; -export type Visibility = 'public' | 'private' | 'all' +export type Visibility = 'public' | 'hidden' | 'all' const roles = ['user', 'cadet', 'librarian', 'staff'] as const; export type Role = typeof roles[number] diff --git a/backend/src/v2/reviews/mod.ts b/backend/src/v2/reviews/mod.ts index 59d7b829..15f295f7 100644 --- a/backend/src/v2/reviews/mod.ts +++ b/backend/src/v2/reviews/mod.ts @@ -16,9 +16,18 @@ import { updateReview, } from './service.ts'; import { ReviewNotFoundError } from './errors.js'; +import { searchReviews } from './repository.ts' const s = initServer(); export const reviews = s.router(contract.reviews, { + get: { + middleware: [authValidate(roleSet.librarian)], + handler: async ({ query }) => { + const body = await searchReviews(query); + + return { status: 200, body }; + } + }, post: { middleware: [authValidate(roleSet.all)], // prettier-ignore diff --git a/backend/src/v2/reviews/repository.ts b/backend/src/v2/reviews/repository.ts index 40f9fa13..328995b2 100644 --- a/backend/src/v2/reviews/repository.ts +++ b/backend/src/v2/reviews/repository.ts @@ -1,8 +1,10 @@ import { match } from 'ts-pattern'; import { db } from '~/kysely/mod.ts'; -import { executeWithOffsetPagination } from 'kysely-paginate'; import { Visibility } from '~/kysely/shared.js'; import { SqlBool } from 'kysely'; +import { Simplify } from 'kysely'; +import { executeWithOffsetPagination } from 'kysely-paginate'; +import { metaPaginated } from '~/kysely/paginated'; export const bookInfoExistsById = (id: number) => db.selectFrom('book_info').where('id', '=', id).executeTakeFirst(); @@ -15,7 +17,7 @@ export const getReviewById = (id: number) => .executeTakeFirst(); type SearchOption = { - query: string; + search?: string; page: number; perPage: number; visibility: Visibility; @@ -28,34 +30,41 @@ const queryReviews = () => .leftJoin('user', 'user.id', 'reviews.userId') .leftJoin('book_info', 'book_info.id', 'reviews.bookInfoId') .select([ - 'id', - 'userId', - 'bookInfoId', - 'content', - 'createdAt', + 'reviews.id', + 'reviews.userId', + 'reviews.disabled', + 'reviews.bookInfoId', + 'reviews.content', + 'reviews.createdAt', 'book_info.title', 'user.nickname', - 'user.intraId', ]); -export const searchReviews = ({ - query, +export const searchReviews = async ({ + search, sort, visibility, page, perPage, }: SearchOption) => { const searchQuery = queryReviews() - .where('content', 'like', `%${query}%`) - .orderBy('updatedAt', sort); + .$if(search !== undefined, qb => + qb.where(eb => + eb.or([ + eb('user.nickname', 'like', `%${search}%`), + eb('book_info.title', 'like', `%${search}%`), + ]), + ), + ) + .orderBy('reviews.createdAt', sort); const withVisibility = match(visibility) .with('public', () => searchQuery.where('disabled', '=', false)) - .with('private', () => searchQuery.where('disabled', '=', true)) + .with('hidden', () => searchQuery.where('disabled', '=', true)) .with('all', () => searchQuery) .exhaustive(); - return executeWithOffsetPagination(withVisibility, { page, perPage }); + return metaPaginated(withVisibility, { page, perPage }); }; type InsertOption = { diff --git a/contracts/.npmignore b/contracts/.npmignore index 596c7d7c..a37c8d14 100644 --- a/contracts/.npmignore +++ b/contracts/.npmignore @@ -2,3 +2,7 @@ docs/* node_modules src tsconfig.json +.eslintrc.json +*.tgz +*.tar +*.tar.gz diff --git a/contracts/package.json b/contracts/package.json index fbc79e87..dbd62adf 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@jiphyeonjeon-42/contracts", - "version": "0.0.4-alpha", + "version": "0.0.11-alpha", "type": "commonjs", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -8,7 +8,8 @@ "scripts": { "build": "tsc", "dev": "tsc -w", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "release": "pnpm pack && mv jiphyeonjeon-42-contracts-$npm_package_version.tgz contracts.tgz && gh release create v$npm_package_version --title v$npm_package_version --generate-notes contracts.tgz" }, "dependencies": { "@anatine/zod-openapi": "^2.2.0", diff --git a/contracts/src/books/schema.ts b/contracts/src/books/schema.ts index bd4c4e60..056073e1 100644 --- a/contracts/src/books/schema.ts +++ b/contracts/src/books/schema.ts @@ -1,7 +1,7 @@ -import { metaSchema, positiveInt, mkErrorMessageSchema, statusSchema } from "../shared"; +import { metaSchema, positiveInt, mkErrorMessageSchema, statusSchema, metaPaginatedSchema } from "../shared"; import { z } from "../zodWithOpenapi"; -const commonQuerySchema = z.object({ +export const commonQuerySchema = z.object({ query: z.string().optional(), page: positiveInt.default(0), limit: positiveInt.default(10), @@ -70,17 +70,14 @@ export const bookInfoSchema = z.object({ lendingCnt: positiveInt, }); -export const searchBookInfosResponseSchema = z.object({ - items: z.array( - bookInfoSchema, - ), - categories: z.array( - z.object({ - name: z.string(), - count: positiveInt, - }), - ), - meta: metaSchema, +export const searchBookInfosResponseSchema = metaPaginatedSchema(bookInfoSchema) + .extend({ + categories: z.array( + z.object({ + name: z.string(), + count: positiveInt, + }), + ), }); export const searchBookInfosSortedResponseSchema = z.object({ @@ -104,8 +101,8 @@ export const searchBookInfoByIdResponseSchema = z.object({ ), }); -export const searchAllBooksResponseSchema = z.object({ - items: z.array( +export const searchAllBooksResponseSchema = + metaPaginatedSchema( z.object({ bookId: positiveInt.openapi({ example: 1 }), bookInfoId: positiveInt.openapi({ example: 1 }), @@ -121,10 +118,8 @@ export const searchAllBooksResponseSchema = z.object({ callSign: z.string().openapi({ example: 'K23.17.v1.c1' }), category: z.string().openapi({ example: '데이터 분석/AI/ML' }), isLendable: positiveInt.openapi({ example: 0 }), - }) - ), - meta: metaSchema, -}); + }) +); export const searchBookInfoCreateResponseSchema = z.object({ bookInfo: z.object({ @@ -176,4 +171,4 @@ export const formatErrorSchema = mkErrorMessageSchema('FORMAT_ERROR').describe(' export const unknownPatchErrorSchema = mkErrorMessageSchema('PATCH_ERROR').describe('예상치 못한 에러로 patch에 실패.'); -export const nonDataErrorSchema = mkErrorMessageSchema('NO_DATA_ERROR').describe('DATA가 적어도 한 개는 필요.'); \ No newline at end of file +export const nonDataErrorSchema = mkErrorMessageSchema('NO_DATA_ERROR').describe('DATA가 적어도 한 개는 필요.'); diff --git a/contracts/src/reviews/index.ts b/contracts/src/reviews/index.ts index 16ef2956..18830248 100644 --- a/contracts/src/reviews/index.ts +++ b/contracts/src/reviews/index.ts @@ -1,12 +1,13 @@ import { initContract } from '@ts-rest/core'; import { z } from 'zod'; -import { bookInfoIdSchema, bookInfoNotFoundSchema } from '../shared'; +import { bookInfoIdSchema, bookInfoNotFoundSchema, metaPaginatedSchema, offsetPaginatedSchema, paginatedSearchSchema, visibility } from '../shared'; import { contentSchema, mutationDescription, reviewIdPathSchema, reviewNotFoundSchema, } from './schema'; +import { reviewSchema } from './schema' export * from './schema'; @@ -15,6 +16,18 @@ const c = initContract(); export const reviewsContract = c.router( { + get: { + method: 'GET', + path: '/', + query: paginatedSearchSchema.extend({ + search: z.string().optional().describe('도서 제목 또는 리뷰 작성자 닉네임'), + visibility, + }), + description: '전체 도서 리뷰 목록을 조회합니다.', + responses: { + 200: metaPaginatedSchema(reviewSchema) + }, + }, post: { method: 'POST', path: '/', diff --git a/contracts/src/reviews/schema.ts b/contracts/src/reviews/schema.ts index 925f9073..24d006f4 100644 --- a/contracts/src/reviews/schema.ts +++ b/contracts/src/reviews/schema.ts @@ -15,3 +15,16 @@ export const reviewNotFoundSchema = mkErrorMessageSchema('REVIEW_NOT_FOUND').describe('검색한 리뷰가 존재하지 않습니다.'); export const mutationDescription = (action: '수정' | '삭제') => `리뷰를 ${action}합니다. 작성자 또는 관리자만 ${action} 가능합니다.`; + +export const sqlBool = z.number().int().gte(0).lte(1).transform(x => Boolean(x)).or(z.boolean()); + +export const reviewSchema = z.object({ + id: z.number().int(), + userId: z.number().int(), + nickname: z.string().nullable(), + bookInfoId: z.number().int(), + createdAt: z.date().transform(x => x.toISOString()), + title: z.string().nullable(), + content: z.string(), + disabled: sqlBool, +}) diff --git a/contracts/src/shared.ts b/contracts/src/shared.ts index 98c3ce3e..7189077d 100644 --- a/contracts/src/shared.ts +++ b/contracts/src/shared.ts @@ -8,7 +8,6 @@ export const bookInfoIdSchema = positiveInt.describe('개별 도서 ID'); export const statusSchema = z.enum(["ok", "lost", "damaged"]); -type ErrorMessage = { code: string; description: string }; /** * 오류 메시지를 통일된 형식으로 보여주는 zod 스키마를 생성합니다. @@ -42,9 +41,31 @@ export const badRequestSchema = mkErrorMessageSchema('BAD_REQUEST').describe(' export const forbiddenSchema = mkErrorMessageSchema('FORBIDDEN').describe('권한이 없습니다.'); export const metaSchema = z.object({ - totalItems: positiveInt.describe('전체 검색 결과 수 ').openapi({ example: 1 }), - itemCount: positiveInt.describe('현재 페이지의 검색 결과 수').openapi({ example: 3 }), - itemsPerPage: positiveInt.describe('한 페이지당 검색 결과 수').openapi({ example: 10 }), + totalItems: positiveInt.describe('전체 검색 결과 수 ').openapi({ example: 42 }), totalPages: positiveInt.describe('전체 결과 페이지 수').openapi({ example: 5 }), - currentPage: positiveInt.describe('현재 페이지').openapi({ example: 1 }), + // itemCount: positiveInt.describe('현재 페이지의 검색 결과 수').openapi({ example: 3 }), + // itemsPerPage: positiveInt.describe('한 페이지당 검색 결과 수').openapi({ example: 10 }), + // currentPage: positiveInt.describe('현재 페이지').openapi({ example: 1 }), }); + +export const metaPaginatedSchema = >(itemSchema: T) => + z.object({ + items: z.array(itemSchema), + meta: metaSchema, + }); + +export const positive = z.number().int().positive(); + +const page = positive.describe('검색할 페이지').openapi({ example: 1 }); +const perPage = positive.lte(100).describe('한 페이지당 검색 결과 수').openapi({ example: 10 }); +const sort = z.enum(['asc', 'desc']).default('asc').describe('정렬 방식'); + +export const paginatedSearchSchema = z.object({ page, perPage, sort }); + +export const offsetPaginatedSchema = >(itemSchema: T) => + z.object({ + rows: z.array(itemSchema), + hasNextPage: z.boolean().optional().describe('다음 페이지가 존재하는지 여부'), + hasPrevPage: z.boolean().optional().describe('이전 페이지가 존재하는지 여부'), + });export const visibility = z.enum([ 'all', 'public', 'hidden' ]).default('public').describe('공개 상태') +