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: 방명록 읽기/쓰기 기능 구현 #21

Merged
merged 23 commits into from
Jun 10, 2024
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
1 change: 1 addition & 0 deletions server/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"db:local": "wrangler d1 execute dev-db --local --file=./sql/schema.sql",
"test": "vitest"
},
"devDependencies": {
Expand Down
8 changes: 6 additions & 2 deletions server/app/sql/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
### create local db

`yarn wrangler d1 execute dev-db --local --file=./sql/users.schema.sql`
`yarn wrangler d1 execute dev-db --local --file=./sql/schema.sql`

### create remote db

`yarn wrangler d1 execute dev-db --remote --file=./sql/users.schema.sql`
`yarn wrangler d1 execute dev-db --remote --file=./sql/schema.sql`

### delete all columns

`yarn wrangler d1 execute dev-db --local --command="DELETE FROM Users"`

### delete db

Expand Down
44 changes: 44 additions & 0 deletions server/app/sql/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
DROP TABLE IF EXISTS Users;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Tekiter
파일 합쳤습니다~~
테이블 생성도 쉽고 훨씬 좋네요!

CREATE TABLE Users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
githubUserId KEY,
thumbnailUrl TEXT,
name TEXT,
githubUserName TEXT,
bio TEXT,
githubUrl TEXT,
createdAt TEXT,
updatedAt TEXT
);

DROP TABLE IF EXISTS Minihomes;
CREATE TABLE Minihomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER UNIQUE,
createdAt TEXT,
updatedAt TEXT,
FOREIGN KEY (userId) REFERENCES Users(id)
);


DROP TABLE IF EXISTS Guestbooks;
CREATE TABLE Guestbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
minihomeId INTEGER UNIQUE,
createdAt TEXT,
updatedAt TEXT,
FOREIGN KEY (minihomeId) REFERENCES Minihomes(id)
);

DROP TABLE IF EXISTS Comments;
CREATE TABLE Comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guestbookId INTEGER,
authorId INTEGER,
content TEXT,
parentId INTEGER NULL,
createdAt TEXT,
updatedAt TEXT,
FOREIGN KEY (guestbookId) REFERENCES Guestbooks(id)
FOREIGN KEY (authorId) REFERENCES Users(id)
);
9 changes: 0 additions & 9 deletions server/app/sql/users.schema.sql

This file was deleted.

29 changes: 29 additions & 0 deletions server/app/src/middlewares/getUserJwtMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { UserService } from "../router/api/user/user.service";
import { Context, Next } from "hono";
import { HTTPException } from "hono/http-exception";
import { JwtPayload } from "../router/api/auth/types";
import { User } from "../router/api/user/user.schema";

declare module "hono" {
interface ContextVariableMap {
user: User;
}
}

export const getUserJwtMiddleware =
({ userService }: { userService: UserService }) =>
async (ctx: Context, next: Next) => {
const payload = ctx.get("jwtPayload") as JwtPayload;
if (payload == null) {
throw new Error("jwt middleware 와 함께 사용해주세요.");
}
const user = await userService.getUserById(payload.sub);

if (user == null) {
throw new HTTPException(401, { message: "유저 정보가 없습니다." });
}

ctx.set("user", user);

await next();
};
8 changes: 2 additions & 6 deletions server/app/src/router/api/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { sign, verify } from "hono/jwt";
import { EXPIRATION_DURATION } from "./constant";
import { UserRepository } from "../user/user.repository";
import { type Env } from "../../../worker-env";
import { JwtPayload } from "./types";

type GithubAccessTokenError = {
error: string;
Expand All @@ -22,12 +23,7 @@ type GithubUserInfo = {
bio: string;
login: string; // ex. euijinkk
};
type JwtPayload = {
sub: number;
name: string;
exp: number;
iat: number;
};

export class AuthService {
private env;
constructor({ env }: { env: Env }) {
Expand Down
6 changes: 6 additions & 0 deletions server/app/src/router/api/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type JwtPayload = {
sub: number;
name: string;
exp: number;
iat: number;
};
53 changes: 53 additions & 0 deletions server/app/src/router/api/comment/comment.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Kysely, sql } from "kysely";
import { DataBase } from "../../../types/database";
import { addTimeStamp } from "../../../utils/addTimeStamp";
import { Comment } from "./comment.schema";
import { User } from "../user/user.schema";

export class CommentRepository {
private db;
constructor({ db }: { db: Kysely<DataBase> }) {
this.db = db;
}

async getAllCommentsByGuestbookId(guestbookId: number) {
return await this.db
.selectFrom("Comments")
.where("Comments.guestbookId", "=", guestbookId)
.innerJoin("Users", "Users.id", "Comments.authorId")
.select([
"Comments.id",
"Comments.guestbookId",
"Comments.content",
"Comments.parentId",
"Comments.createdAt",
"Comments.updatedAt",
sql<User>`
json_object(
'id', Users.id,
'githubUserId', Users.githubUserId,
'thumbnailUrl', Users.thumbnailUrl,
'name', Users.name,
'githubUserName', Users.githubUserName,
'bio', Users.bio,
'githubUrl', Users.githubUrl,
'createdAt', Users.createdAt,
'updatedAt', Users.updatedAt
)`.as("author"),
Comment on lines +13 to +36
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 DB에서 긁으려니까, 좀 이상하네요. 타입 지원이 안돼서 많이 위험한듯 합니다.
JS 코드로 합치자니 그것도 마음에 들진 않아서 고민입니다

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JS단에서 처리하는것도 저는 좋은 방법이라고 생각해요! 결국 Repository 의 역할은 데이터 소스에서 데이터를 가져와 가공하는 것이기 때문에, DB에 JSON 이라는 형태로 저장된 포맷을 파싱하는 JS 로직이 들어가도 자연스러운 것 같아요.
여기에 꼭 kysely 의 메서드만 들어갈 필요는 없을 것 같아요!

Copy link
Contributor Author

@euijinkk euijinkk Jun 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기에 꼭 kysely 의 메서드만 들어갈 필요는 없을 것 같아요!

와우 그렇네요. 감사합니다 반영해볼게요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 수정하려고 했는데, 어차피 두개 테이블 조인한 결과를 selectAll 로 추출하기 어렵더라고요. id, createdAt, updatedAt 이 중복되서, alias 지어줘야함.
그렇게 치면 어차피 아래 같은 코드가 등장해야하고, 그러면 사실 kysely에서 처리하는 것과 다를 바가 없더라고요.
그래서 이 경우엔 kysely 로직으로만 가도 되겠다고 생각했어요

하지만 말씀하신 의견은 너무 좋은거같아서, 다른 부분 있으면 적용해볼게요!

async getAllCommentsByGuestbookId(guestbookId: number) {
  const comments = await this.db
    .selectFrom("Comments")
    .where("Comments.guestbookId", "=", guestbookId)
    .innerJoin("Users", "Users.id", "Comments.authorId")
    .select([
      "Comments.id as commentId",
      "Comments.createdAt as commentCreatedAt",
      "Comments.updatedAt as commentUpdatedAt",
      "Comments.content",
      "Comments.guestbookId",
      "Comments.authorId",
      "Users.id as userId",
      "Users.createdAt as userCreatedAt",
      "Users.updatedAt as userUpdatedAt",
    ])
    .execute();

  return comments.map(
    ({
      id,
      content,
      parentId,
      createdAt,
      updatedAt,
      authorId,
      ...author
    }) => ({
      id,
      content,
      parentId,
      createdAt,
      updatedAt,
      author: { ...author, id: authorId },
    })
  );
}

])
.execute();
}

async createComment(props: {
guestbookId: Comment["guestbookId"];
authorId: Comment["authorId"];
content: Comment["content"];
parentId: Comment["parentId"];
}) {
return await this.db
.insertInto("Comments")
.values(addTimeStamp(props) as Comment)
.returningAll()
.executeTakeFirstOrThrow();
}
}
9 changes: 9 additions & 0 deletions server/app/src/router/api/comment/comment.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface Comment {
id: number;
guestbookId: number;
authorId: number;
content: string;
parentId: number | null;
createdAt: string;
updatedAt: string;
}
84 changes: 84 additions & 0 deletions server/app/src/router/api/comment/comment.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Env } from "../../../worker-env";
import { User } from "../user/user.schema";
import { UserService } from "../user/user.service";
import { CommentRepository } from "./comment.repository";
import { Comment } from "./comment.schema";

type AllCommentDTO = {
guestbookId: number;
comments: Array<CommentWithRepies>;
};

type CommentWithRepies = CommentDTO & { replies: CommentDTO[] };

interface CommentDTO {
id: number;
content: string;
author: User;
createdAt: string;
updatedAt: string;
}

export class CommentService {
private env;
private commentRepository;
private userService;
constructor({
env,
commentRepository,
userService,
}: {
env: Env;
commentRepository: CommentRepository;
userService: UserService;
}) {
this.env = env;
this.commentRepository = commentRepository;
this.userService = userService;
}

async getAllGuestbookCommentsByGithubUserName(githubUserName: string) {
const guestbook =
await this.userService.getGuestbookByGithubUserName(githubUserName);
if (guestbook == null) {
return { result: "notFound" as const };
}
const comments = await this.commentRepository.getAllCommentsByGuestbookId(
guestbook.id
);
const commentMap = new Map<number, CommentWithRepies>();
comments.forEach((comment) => {
const { parentId, ...commentData } = comment;

if (!parentId) {
// parentId가 없는 경우 (최상위 댓글)
commentMap.set(commentData.id, { ...commentData, replies: [] });
} else {
// 대댓글인 경우
const parentComment = commentMap.get(parentId);
if (parentComment == null) {
return;
}
parentComment.replies.push(commentData);
}
});

const result = Array.from(commentMap.values());

return {
result: "success" as const,
comments: result,
guestbookId: guestbook.id,
};
}

async createComment(props: {
guestbookId: Comment["guestbookId"];
authorId: Comment["authorId"];
content: Comment["content"];
parentId: Comment["parentId"];
}) {
const res = await this.commentRepository.createComment(props);
return res;
}
}
48 changes: 39 additions & 9 deletions server/app/src/router/api/guestbook/guestbook.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { MOCK_GUEST_BOOK_LIST, createNewMockGuestBook } from "./mock";
import { createNewMockGuestBook } from "./mock";
import { jwt } from "hono/jwt";
import { type Env } from "../../../worker-env";
import { CommentService } from "../comment/comment.service";
import { HTTPException } from "hono/http-exception";
import { getUserJwtMiddleware } from "../../../middlewares/getUserJwtMiddleware";
import { UserService } from "../user/user.service";

export const createGuestbookController = ({ env }: { env: Env }) => {
export const createGuestbookController = ({
env,
commentService,
userService,
}: {
env: Env;
commentService: CommentService;
userService: UserService;
}) => {
return new Hono()
.get(
"/:githubUserName",
Expand All @@ -16,30 +28,48 @@ export const createGuestbookController = ({ env }: { env: Env }) => {
})
),
async (ctx) => {
const { githubUserName } = ctx.req.valid("param");
const { result, comments, guestbookId } =
await commentService.getAllGuestbookCommentsByGithubUserName(
githubUserName
);
if (result === "notFound") {
throw new HTTPException(404, { message: "Guestbook not found" });
}
return ctx.json({
success: true,
data: MOCK_GUEST_BOOK_LIST,
data: { comments, guestbookId },
});
}
)
.post(
"/",
jwt({
secret: env.JWT_SECRET_KEY,
}),
zValidator(
"json",
z.object({
content: z.string(),
guestbookId: z.number(),
parentCommentId: z.nullable(z.number()),
parentId: z.number().optional().nullable(),
})
),
jwt({
secret: env.JWT_SECRET_KEY,
}),
getUserJwtMiddleware({ userService }),
async (ctx) => {
const { content } = ctx.req.valid("json");
const { content, guestbookId, parentId } = ctx.req.valid("json");
const user = ctx.get("user");

const comment = await commentService.createComment({
content,
guestbookId,
parentId: parentId ?? null,
authorId: user.id,
});

return ctx.json({
success: true,
data: createNewMockGuestBook({ content }),
data: comment,
});
}
)
Expand Down
27 changes: 27 additions & 0 deletions server/app/src/router/api/guestbook/guestbook.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Kysely } from "kysely";
import { DataBase } from "../../../types/database";
import { addTimeStamp } from "../../../utils/addTimeStamp";
import { Guestbook } from "./guestbook.schema";

export class GuestbookRepository {
private db;
constructor({ db }: { db: Kysely<DataBase> }) {
this.db = db;
}

async getGuestbookByMinihomeId(minihomeId: number) {
return await this.db
.selectFrom("Guestbooks")
.selectAll()
.where("Guestbooks.minihomeId", "=", minihomeId)
.executeTakeFirst();
}

async createGuestbook(minihomeId: number) {
return await this.db
.insertInto("Guestbooks")
.values(addTimeStamp({ minihomeId }) as Guestbook)
.returningAll()
.executeTakeFirstOrThrow();
}
}
Loading