Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: GET 전체 reviews #705

Merged
merged 3 commits into from
Aug 31, 2023
Merged
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
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('공개 상태')

Loading