Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ca6358e
add article CRUD endpoints
Hajbo Mar 17, 2025
b919017
Implement comments feature for articles
yamcodes Mar 18, 2025
5c4447f
improve names and repository
yamcodes Mar 18, 2025
813b6c8
Fix delete method in CommentsRepository to return the result of the d…
yamcodes Mar 18, 2025
557f2d2
Rename to slugify
Hajbo Mar 19, 2025
d3e679f
Add trailing slashes
Hajbo Mar 19, 2025
aba29d2
Return nulls if no article was found
Hajbo Mar 19, 2025
b7714e1
Update models & migrations
Hajbo Mar 19, 2025
c879e3a
Fix db:drop utility
Hajbo Mar 19, 2025
969bcc8
Specify error for optional authentication
Hajbo Mar 19, 2025
d811f95
Fix returned article count
Hajbo Mar 19, 2025
100453b
Fix feed by only returning articles from followed users, run bun fix
Hajbo Mar 19, 2025
e707eb6
Simplifiy update logic
Hajbo Mar 19, 2025
7c8a103
Merge branch 'main' into feature/articles-crud
Hajbo Mar 19, 2025
f90e537
Merge branch 'feature/articles-crud' into 78-implement-article-commen…
yamcodes Mar 19, 2025
08df554
edit taglist
yamcodes Mar 19, 2025
76b0ec8
fix more weird ai stuff
yamcodes Mar 19, 2025
4a70667
fix missing types
yamcodes Mar 19, 2025
b8a055a
fix DeleteArticleResponse schema
yamcodes Mar 19, 2025
23ec6aa
Add article CRUD endpoints and some others (#129)
Hajbo Mar 20, 2025
a6cc3fe
fix dates
yamcodes Mar 20, 2025
91447f6
Refactor comment author retrieval and add findByUserId method
yamcodes Mar 20, 2025
83ab1c4
Enhance comment retrieval with author details
yamcodes Mar 20, 2025
41ee814
Refactor comment author profile retrieval in CommentsService
yamcodes Mar 20, 2025
7f75dc5
Merge branch 'main' into 78-implement-article-comment-feature-2
yamcodes Mar 20, 2025
1cd3254
remove unused method
yamcodes Mar 20, 2025
f8e7faa
Enhance CommentsService and ProfilesService with improved type handli…
yamcodes Mar 20, 2025
18114bd
Refactor CommentsRepository and CommentsService for enhanced comment …
yamcodes Mar 21, 2025
29923e7
Simplify findById method in CommentsRepository
yamcodes Mar 21, 2025
dd9b96e
Update error handling in CommentsService to use NotFoundError
yamcodes Mar 21, 2025
581e9db
Update error handling in CommentsService to throw NotFoundError for m…
yamcodes Mar 21, 2025
3d964a3
Merge branch 'main' into 78-implement-article-comment-feature-2
yamcodes Mar 21, 2025
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
29 changes: 29 additions & 0 deletions src/articles/articles.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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',
}),
}));
14 changes: 13 additions & 1 deletion src/articles/articles.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand All @@ -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,
}));
};
77 changes: 75 additions & 2 deletions src/articles/articles.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -67,7 +73,7 @@ export const articlesPlugin = new Elysia().use(setupArticles).group(
query: ArticleFeedQuerySchema,
response: ReturnedArticleListSchema,
detail: {
summary: 'Artifle Feed',
summary: 'Article Feed',
},
},
)
Expand Down Expand Up @@ -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',
},
},
),
);
3 changes: 1 addition & 2 deletions src/articles/articles.schema.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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 })),
Expand Down
71 changes: 71 additions & 0 deletions src/articles/comments/comments.repository.ts
Original file line number Diff line number Diff line change
@@ -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)));
}
}
48 changes: 48 additions & 0 deletions src/articles/comments/comments.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AddCommentSchema>['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<typeof ReturnedCommentSchema>;

export const ReturnedCommentResponse = Type.Object({
comment: ReturnedCommentSchema,
});

export const ReturnedCommentsResponse = Type.Object({
comments: Type.Array(ReturnedCommentSchema),
});

export const DeleteCommentResponse = Type.Object({});
Loading
Loading