Skip to content

Commit

Permalink
feat: GET 전체 reviews (#705)
Browse files Browse the repository at this point in the history
* feat: 메타데이터 페이지네이션

* feat: GET reviews

* build: 자동 배포 명령어
  • Loading branch information
scarf005 authored Aug 31, 2023
1 parent cd8eec0 commit 3652cb1
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 43 deletions.
28 changes: 28 additions & 0 deletions backend/src/kysely/paginated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SelectQueryBuilder } from 'kysely';

type Paginated<O> = {
items: O[],
meta: {
totalItems: number;
totalPages: number;
};
}

export const metaPaginated = async <DB, TB extends keyof DB, O>(
qb: SelectQueryBuilder<DB, TB, O>,
{ page, perPage }: { page: number; perPage: number },
): Promise<Paginated<O>> => {
const { totalItems } = await qb
.clearSelect()
.select(({ fn }) => fn.countAll<number>().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 } };
};
2 changes: 1 addition & 1 deletion backend/src/kysely/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const throwIf = <T>(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]

Expand Down
9 changes: 9 additions & 0 deletions backend/src/v2/reviews/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 23 additions & 14 deletions backend/src/v2/reviews/repository.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -15,7 +17,7 @@ export const getReviewById = (id: number) =>
.executeTakeFirst();

type SearchOption = {
query: string;
search?: string;
page: number;
perPage: number;
visibility: Visibility;
Expand All @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions contracts/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ docs/*
node_modules
src
tsconfig.json
.eslintrc.json
*.tgz
*.tar
*.tar.gz
5 changes: 3 additions & 2 deletions contracts/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"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",
"author": "the jiphyeonjeon developers",
"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",
Expand Down
35 changes: 15 additions & 20 deletions contracts/src/books/schema.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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({
Expand All @@ -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 }),
Expand All @@ -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({
Expand Down Expand Up @@ -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가 적어도 한 개는 필요.');
export const nonDataErrorSchema = mkErrorMessageSchema('NO_DATA_ERROR').describe('DATA가 적어도 한 개는 필요.');
15 changes: 14 additions & 1 deletion contracts/src/reviews/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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: '/',
Expand Down
13 changes: 13 additions & 0 deletions contracts/src/reviews/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
31 changes: 26 additions & 5 deletions contracts/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 스키마를 생성합니다.
Expand Down Expand Up @@ -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 = <T extends z.ZodType<any>>(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 = <T extends z.ZodType<any>>(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('공개 상태')

0 comments on commit 3652cb1

Please sign in to comment.