diff --git a/src/articles/articles.model.ts b/src/articles/articles.model.ts index df24d667..3e07a348 100644 --- a/src/articles/articles.model.ts +++ b/src/articles/articles.model.ts @@ -34,6 +34,9 @@ export const articleRelations = relations(articles, ({ one, many }) => ({ favoritedBy: many(favoriteArticles, { relationName: 'favoriteArticle', }), + comments: many(comments, { + relationName: 'articleComments', + }), })); export const favoriteArticles = pgTable( @@ -66,3 +69,29 @@ export const favoriteArticleRelations = relations( }), }), ); + +export const comments = pgTable('comments', { + id: serial('id').primaryKey().notNull(), + body: text('body').notNull(), + articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }) + .notNull(), + authorId: integer('author_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(), +}); + +export const commentRelations = relations(comments, ({ one }) => ({ + article: one(articles, { + fields: [comments.articleId], + references: [articles.id], + relationName: 'articleComments', + }), + author: one(users, { + fields: [comments.authorId], + references: [users.id], + relationName: 'commentAuthor', + }), +})); diff --git a/src/articles/articles.module.ts b/src/articles/articles.module.ts index 60472595..3dace76c 100644 --- a/src/articles/articles.module.ts +++ b/src/articles/articles.module.ts @@ -1,3 +1,5 @@ +import { CommentsRepository } from '@/articles/comments/comments.repository'; +import { CommentsService } from '@/articles/comments/comments.service'; import { db } from '@/database.providers'; import { ArticlesRepository } from '@articles/articles.repository'; import { ArticlesService } from '@articles/articles.service'; @@ -9,6 +11,7 @@ import { Elysia } from 'elysia'; export const setupArticles = () => { const articlesRepository = new ArticlesRepository(db); + const commentsRepository = new CommentsRepository(db); const profilesRepository = new ProfilesRepository(db); const usersRepository = new UsersRepository(db); const profilesService = new ProfilesService( @@ -19,6 +22,15 @@ export const setupArticles = () => { articlesRepository, profilesService, ); + const commentsService = new CommentsService( + commentsRepository, + profilesService, + usersRepository, + ); const authService = new AuthService(); - return new Elysia().state(() => ({ articlesService, authService })); + return new Elysia().state(() => ({ + articlesService, + authService, + commentsService, + })); }; diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts index a990a1b7..fa571c90 100644 --- a/src/articles/articles.plugin.ts +++ b/src/articles/articles.plugin.ts @@ -8,7 +8,13 @@ import { ReturnedArticleResponseSchema, UpdateArticleSchema, } from '@articles/articles.schema'; -import { Elysia } from 'elysia'; +import { Elysia, t } from 'elysia'; +import { + AddCommentSchema, + DeleteCommentResponse, + ReturnedCommentResponse, + ReturnedCommentsResponse, +} from './comments/comments.schema'; export const articlesPlugin = new Elysia().use(setupArticles).group( '/articles', @@ -67,7 +73,7 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( query: ArticleFeedQuerySchema, response: ReturnedArticleListSchema, detail: { - summary: 'Artifle Feed', + summary: 'Article Feed', }, }, ) @@ -113,5 +119,72 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( summary: 'Delete Article', }, }, + ) + .post( + '/:slug/comments', + async ({ body, params, store, request }) => { + const comment = await store.commentsService.createComment( + params.slug, + body.comment, + await store.authService.getUserIdFromHeader(request.headers), + ); + return { comment }; + }, + { + beforeHandle: app.store.authService.requireLogin, + params: t.Object({ + slug: t.String(), + }), + body: AddCommentSchema, + response: ReturnedCommentResponse, + detail: { + summary: 'Add Comment to Article', + }, + }, + ) + .get( + '/:slug/comments', + async ({ params, store, request }) => { + const userId = await store.authService.getOptionalUserIdFromHeader( + request.headers, + ); + return { + comments: await store.commentsService.getComments( + params.slug, + userId === null ? undefined : userId, + ), + }; + }, + { + params: t.Object({ + slug: t.String(), + }), + response: ReturnedCommentsResponse, + detail: { + summary: 'Get Comments from Article', + }, + }, + ) + .delete( + '/:slug/comments/:id', + async ({ params, store, request }) => { + await store.commentsService.deleteComment( + params.slug, + Number.parseInt(params.id, 10), + await store.authService.getUserIdFromHeader(request.headers), + ); + return {}; + }, + { + beforeHandle: app.store.authService.requireLogin, + params: t.Object({ + slug: t.String(), + id: t.String(), + }), + response: DeleteCommentResponse, + detail: { + summary: 'Delete Comment', + }, + }, ), ); diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index a3c0625a..077d5920 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -1,7 +1,6 @@ import type { Profile } from '@profiles/profiles.schema'; import { type Static, Type } from '@sinclair/typebox'; import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; -// Do not use path aliases here (i.e. '@/users/users.model'), as that doesn't work with Drizzle Studio import { articles, type favoriteArticles } from './articles.model'; export const insertArticleSchemaRaw = createInsertSchema(articles); @@ -71,7 +70,7 @@ export type ArticleInDb = Omit< favoritedBy: ArticleFavoritedBy[]; }; -type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect; +export type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect; export const ArticleFeedQuerySchema = Type.Object({ limit: Type.Optional(Type.Number({ minimum: 1, default: 20 })), diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts new file mode 100644 index 00000000..ffc2435a --- /dev/null +++ b/src/articles/comments/comments.repository.ts @@ -0,0 +1,71 @@ +import type { Database } from '@/database.providers'; +import { articles, comments } from '@articles/articles.model'; +import { and, desc, eq } from 'drizzle-orm'; +import type { CommentToCreate } from './comments.schema'; + +export class CommentsRepository { + constructor(private readonly db: Database) {} + + async create(commentData: CommentToCreate) { + const [comment] = await this.db + .insert(comments) + .values(commentData) + .returning(); + return comment; + } + + /** + * Find a comment by its id + * @param id - The id of the comment + * @returns The comment + */ + async findById(id: number) { + const result = await this.db.query.comments.findFirst({ + where: eq(comments.id, id), + }); + return result; + } + + /** + * Find all comments by article id + * + * Note: this operation is optimized to include the author and their followers. + * Use it with caution. If you need something simpler, consider refactoring this method and making the "with" option dynamic. + * @param articleId - The id of the article + * @returns An array of comments + */ + async findManyByArticleId(articleId: number) { + const result = await this.db.query.comments.findMany({ + where: eq(comments.articleId, articleId), + orderBy: [desc(comments.createdAt)], + with: { + author: { + columns: { + id: true, + username: true, + bio: true, + image: true, + }, + with: { + followers: true, + }, + }, + }, + }); + return result; + } + + async findBySlug(slug: string) { + const result = await this.db.query.articles.findFirst({ + where: eq(articles.slug, slug), + }); + + return result; + } + + async delete(commentId: number, authorId: number) { + return await this.db + .delete(comments) + .where(and(eq(comments.id, commentId), eq(comments.authorId, authorId))); + } +} diff --git a/src/articles/comments/comments.schema.ts b/src/articles/comments/comments.schema.ts new file mode 100644 index 00000000..5daacc1e --- /dev/null +++ b/src/articles/comments/comments.schema.ts @@ -0,0 +1,48 @@ +import { selectUserSchemaRaw } from '@/users/users.schema'; +import { comments } from '@articles/articles.model'; +import { type Static, Type } from '@sinclair/typebox'; +import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; + +export const insertCommentSchemaRaw = createInsertSchema(comments); +export const selectCommentSchemaRaw = createSelectSchema(comments); + +export const AddCommentSchema = Type.Object({ + comment: Type.Object({ + body: Type.String(), + }), +}); + +export type CommentToCreate = Static['comment'] & { + authorId: number; + articleId: number; +}; + +export const ReturnedCommentSchema = Type.Composite([ + Type.Omit(selectCommentSchemaRaw, ['articleId', 'authorId']), + Type.Object({ + author: Type.Composite([ + Type.Omit(selectUserSchemaRaw, [ + 'id', + 'email', + 'password', + 'createdAt', + 'updatedAt', + ]), + Type.Object({ + following: Type.Boolean(), + }), + ]), + }), +]); + +export type ReturnedComment = Static; + +export const ReturnedCommentResponse = Type.Object({ + comment: ReturnedCommentSchema, +}); + +export const ReturnedCommentsResponse = Type.Object({ + comments: Type.Array(ReturnedCommentSchema), +}); + +export const DeleteCommentResponse = Type.Object({}); diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts new file mode 100644 index 00000000..0dea9753 --- /dev/null +++ b/src/articles/comments/comments.service.ts @@ -0,0 +1,112 @@ +import { AuthorizationError, BadRequestError } from '@errors'; +import type { ProfilesService } from '@profiles/profiles.service'; +import { NotFoundError } from 'elysia'; +import type { CommentsRepository } from './comments.repository'; +import type { CommentToCreate, ReturnedComment } from './comments.schema'; + +export class CommentsService { + constructor( + private readonly commentsRepository: CommentsRepository, + private readonly profilesService: ProfilesService, + ) {} + + async createComment( + articleSlug: string, + commentBody: { body: string }, + userId: number, + ): Promise { + const article = await this.commentsRepository.findBySlug(articleSlug); + + if (!article) { + throw new NotFoundError(`Article with slug ${articleSlug} not found`); + } + + const commentData: CommentToCreate = { + ...commentBody, + authorId: userId, + articleId: article.id, + }; + + const comment = await this.commentsRepository.create(commentData); + const authorProfile = await this.profilesService.findByUserId( + userId, + comment.authorId, + ); + + return { + id: comment.id, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + author: authorProfile.profile, + }; + } + + /** + * Get all comments for an article + * @param articleSlug - The slug of the article + * @param currentUserId - The id of the current user. If provided, the profile of the author will be returned + * @returns An array of comments + */ + async getComments( + articleSlug: string, + currentUserId?: number, + ): Promise { + const article = await this.commentsRepository.findBySlug(articleSlug); + + if (!article) { + throw new NotFoundError(`Article with slug ${articleSlug} not found`); + } + + const comments = await this.commentsRepository.findManyByArticleId( + article.id, + ); + + return comments.map((comment) => ({ + id: comment.id, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + author: { + username: comment.author.username, + bio: comment.author.bio, + image: comment.author.image, + following: currentUserId + ? comment.author.followers.some((f) => f.followerId === currentUserId) + : false, + }, + })); + } + + async deleteComment( + articleSlug: string, + commentId: number, + userId: number, + ): Promise { + const article = await this.commentsRepository.findBySlug(articleSlug); + + if (!article) { + throw new BadRequestError(`Article with slug ${articleSlug} not found`); + } + + const comment = await this.commentsRepository.findById(commentId); + + if (!comment) { + throw new BadRequestError(`Comment with id ${commentId} not found`); + } + + if (comment.articleId !== article.id) { + throw new BadRequestError( + `Comment with id ${commentId} does not belong to article ${articleSlug}`, + ); + } + + if (comment.authorId !== userId) { + throw new AuthorizationError( + 'You can only delete comments that you authored', + ); + } + + await this.commentsRepository.delete(commentId, userId); + } +} diff --git a/src/profiles/profiles.repository.ts b/src/profiles/profiles.repository.ts index 4756dd8c..d2bb0e4d 100644 --- a/src/profiles/profiles.repository.ts +++ b/src/profiles/profiles.repository.ts @@ -16,6 +16,17 @@ export class ProfilesRepository { return result[0]; } + async findByUserId(targetUserId: number) { + const result = await this.db.query.users.findMany({ + where: eq(users.id, targetUserId), + with: { followers: true }, + }); + if (result.length === 0) { + return null; + } + return result[0]; + } + async followUser(currentUserId: number, userToFollow: number) { const result = await this.db .insert(userFollows) diff --git a/src/profiles/profiles.service.ts b/src/profiles/profiles.service.ts index 73d8e1d6..01bfea60 100644 --- a/src/profiles/profiles.service.ts +++ b/src/profiles/profiles.service.ts @@ -17,6 +17,14 @@ export class ProfilesService { return await this.generateProfileResponse(user, currentUserId); } + async findByUserId(currentUserId: number | null, targetUserId: number) { + const user = await this.repository.findByUserId(targetUserId); + if (!user) { + throw new NotFoundError('Profile not found'); + } + return await this.generateProfileResponse(user, currentUserId); + } + async followUser(currentUserId: number, targetUsername: string) { const userToFollow = await this.usersRepository.findByUsername(targetUsername);