Skip to content

Commit f73d798

Browse files
authored
Merge branch 'main' into chore/rename-bun-up-to-bun-db
2 parents 95bb689 + fb57e66 commit f73d798

File tree

10 files changed

+378
-10
lines changed

10 files changed

+378
-10
lines changed

src/articles/articles.model.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export const articleRelations = relations(articles, ({ one, many }) => ({
3434
favoritedBy: many(favoriteArticles, {
3535
relationName: 'favoriteArticle',
3636
}),
37+
comments: many(comments, {
38+
relationName: 'articleComments',
39+
}),
3740
}));
3841

3942
export const favoriteArticles = pgTable(
@@ -66,3 +69,29 @@ export const favoriteArticleRelations = relations(
6669
}),
6770
}),
6871
);
72+
73+
export const comments = pgTable('comments', {
74+
id: serial('id').primaryKey().notNull(),
75+
body: text('body').notNull(),
76+
articleId: integer('article_id')
77+
.references(() => articles.id, { onDelete: 'cascade' })
78+
.notNull(),
79+
authorId: integer('author_id')
80+
.references(() => users.id, { onDelete: 'cascade' })
81+
.notNull(),
82+
createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
83+
updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
84+
});
85+
86+
export const commentRelations = relations(comments, ({ one }) => ({
87+
article: one(articles, {
88+
fields: [comments.articleId],
89+
references: [articles.id],
90+
relationName: 'articleComments',
91+
}),
92+
author: one(users, {
93+
fields: [comments.authorId],
94+
references: [users.id],
95+
relationName: 'commentAuthor',
96+
}),
97+
}));

src/articles/articles.module.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CommentsRepository } from '@/articles/comments/comments.repository';
2+
import { CommentsService } from '@/articles/comments/comments.service';
13
import { db } from '@/database.providers';
24
import { ArticlesRepository } from '@articles/articles.repository';
35
import { ArticlesService } from '@articles/articles.service';
@@ -9,6 +11,7 @@ import { Elysia } from 'elysia';
911

1012
export const setupArticles = () => {
1113
const articlesRepository = new ArticlesRepository(db);
14+
const commentsRepository = new CommentsRepository(db);
1215
const profilesRepository = new ProfilesRepository(db);
1316
const usersRepository = new UsersRepository(db);
1417
const profilesService = new ProfilesService(
@@ -19,6 +22,15 @@ export const setupArticles = () => {
1922
articlesRepository,
2023
profilesService,
2124
);
25+
const commentsService = new CommentsService(
26+
commentsRepository,
27+
profilesService,
28+
usersRepository,
29+
);
2230
const authService = new AuthService();
23-
return new Elysia().state(() => ({ articlesService, authService }));
31+
return new Elysia().state(() => ({
32+
articlesService,
33+
authService,
34+
commentsService,
35+
}));
2436
};

src/articles/articles.plugin.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import {
88
ReturnedArticleResponseSchema,
99
UpdateArticleSchema,
1010
} from '@articles/articles.schema';
11-
import { Elysia } from 'elysia';
11+
import { Elysia, t } from 'elysia';
12+
import {
13+
AddCommentSchema,
14+
DeleteCommentResponse,
15+
ReturnedCommentResponse,
16+
ReturnedCommentsResponse,
17+
} from './comments/comments.schema';
1218

1319
export const articlesPlugin = new Elysia().use(setupArticles).group(
1420
'/articles',
@@ -67,14 +73,19 @@ export const articlesPlugin = new Elysia().use(setupArticles).group(
6773
query: ArticleFeedQuerySchema,
6874
response: ReturnedArticleListSchema,
6975
detail: {
70-
summary: 'Artifle Feed',
76+
summary: 'Article Feed',
7177
},
7278
},
7379
)
7480
.get(
7581
'/:slug',
76-
async ({ params, store }) =>
77-
store.articlesService.findBySlug(params.slug),
82+
async ({ params, store, request }) =>
83+
store.articlesService.findBySlug(
84+
params.slug,
85+
await store.authService.getOptionalUserIdFromHeader(
86+
request.headers,
87+
),
88+
),
7889
{
7990
response: ReturnedArticleResponseSchema,
8091
detail: {
@@ -113,5 +124,72 @@ export const articlesPlugin = new Elysia().use(setupArticles).group(
113124
summary: 'Delete Article',
114125
},
115126
},
127+
)
128+
.post(
129+
'/:slug/comments',
130+
async ({ body, params, store, request }) => {
131+
const comment = await store.commentsService.createComment(
132+
params.slug,
133+
body.comment,
134+
await store.authService.getUserIdFromHeader(request.headers),
135+
);
136+
return { comment };
137+
},
138+
{
139+
beforeHandle: app.store.authService.requireLogin,
140+
params: t.Object({
141+
slug: t.String(),
142+
}),
143+
body: AddCommentSchema,
144+
response: ReturnedCommentResponse,
145+
detail: {
146+
summary: 'Add Comment to Article',
147+
},
148+
},
149+
)
150+
.get(
151+
'/:slug/comments',
152+
async ({ params, store, request }) => {
153+
const userId = await store.authService.getOptionalUserIdFromHeader(
154+
request.headers,
155+
);
156+
return {
157+
comments: await store.commentsService.getComments(
158+
params.slug,
159+
userId === null ? undefined : userId,
160+
),
161+
};
162+
},
163+
{
164+
params: t.Object({
165+
slug: t.String(),
166+
}),
167+
response: ReturnedCommentsResponse,
168+
detail: {
169+
summary: 'Get Comments from Article',
170+
},
171+
},
172+
)
173+
.delete(
174+
'/:slug/comments/:id',
175+
async ({ params, store, request }) => {
176+
await store.commentsService.deleteComment(
177+
params.slug,
178+
Number.parseInt(params.id, 10),
179+
await store.authService.getUserIdFromHeader(request.headers),
180+
);
181+
return {};
182+
},
183+
{
184+
beforeHandle: app.store.authService.requireLogin,
185+
params: t.Object({
186+
slug: t.String(),
187+
id: t.String(),
188+
}),
189+
response: DeleteCommentResponse,
190+
detail: {
191+
summary: 'Delete Comment',
192+
},
193+
},
116194
),
117195
);

src/articles/articles.schema.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Profile } from '@profiles/profiles.schema';
22
import { type Static, Type } from '@sinclair/typebox';
33
import { createInsertSchema, createSelectSchema } from 'drizzle-typebox';
4-
// Do not use path aliases here (i.e. '@/users/users.model'), as that doesn't work with Drizzle Studio
54
import { articles, type favoriteArticles } from './articles.model';
65

76
export const insertArticleSchemaRaw = createInsertSchema(articles);
@@ -71,7 +70,7 @@ export type ArticleInDb = Omit<
7170
favoritedBy: ArticleFavoritedBy[];
7271
};
7372

74-
type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect;
73+
export type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect;
7574

7675
export const ArticleFeedQuerySchema = Type.Object({
7776
limit: Type.Optional(Type.Number({ minimum: 1, default: 20 })),

src/articles/articles.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ export class ArticlesService {
3232
return await this.repository.find({ ...query, limit, offset });
3333
}
3434

35-
async findBySlug(slug: string) {
35+
async findBySlug(slug: string, currentUserId: number | null = null) {
3636
const article = await this.repository.findBySlug(slug);
3737
if (!article) {
3838
throw new NotFoundError('Article not found');
3939
}
40-
return await this.generateArticleResponse(article, null);
40+
return await this.generateArticleResponse(article, currentUserId);
4141
}
4242

4343
async createArticle(article: ArticleToCreateData, currentUserId: number) {
@@ -75,7 +75,7 @@ export class ArticlesService {
7575
{ ...article, slug: newSlug },
7676
currentUserId,
7777
);
78-
return this.findBySlug(newSlug);
78+
return this.findBySlug(newSlug, currentUserId);
7979
}
8080

8181
async deleteArticle(slug: string, currentUserId: number) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Database } from '@/database.providers';
2+
import { articles, comments } from '@articles/articles.model';
3+
import { and, desc, eq } from 'drizzle-orm';
4+
import type { CommentToCreate } from './comments.schema';
5+
6+
export class CommentsRepository {
7+
constructor(private readonly db: Database) {}
8+
9+
async create(commentData: CommentToCreate) {
10+
const [comment] = await this.db
11+
.insert(comments)
12+
.values(commentData)
13+
.returning();
14+
return comment;
15+
}
16+
17+
/**
18+
* Find a comment by its id
19+
* @param id - The id of the comment
20+
* @returns The comment
21+
*/
22+
async findById(id: number) {
23+
const result = await this.db.query.comments.findFirst({
24+
where: eq(comments.id, id),
25+
});
26+
return result;
27+
}
28+
29+
/**
30+
* Find all comments by article id
31+
*
32+
* Note: this operation is optimized to include the author and their followers.
33+
* Use it with caution. If you need something simpler, consider refactoring this method and making the "with" option dynamic.
34+
* @param articleId - The id of the article
35+
* @returns An array of comments
36+
*/
37+
async findManyByArticleId(articleId: number) {
38+
const result = await this.db.query.comments.findMany({
39+
where: eq(comments.articleId, articleId),
40+
orderBy: [desc(comments.createdAt)],
41+
with: {
42+
author: {
43+
columns: {
44+
id: true,
45+
username: true,
46+
bio: true,
47+
image: true,
48+
},
49+
with: {
50+
followers: true,
51+
},
52+
},
53+
},
54+
});
55+
return result;
56+
}
57+
58+
async findBySlug(slug: string) {
59+
const result = await this.db.query.articles.findFirst({
60+
where: eq(articles.slug, slug),
61+
});
62+
63+
return result;
64+
}
65+
66+
async delete(commentId: number, authorId: number) {
67+
return await this.db
68+
.delete(comments)
69+
.where(and(eq(comments.id, commentId), eq(comments.authorId, authorId)));
70+
}
71+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { selectUserSchemaRaw } from '@/users/users.schema';
2+
import { comments } from '@articles/articles.model';
3+
import { type Static, Type } from '@sinclair/typebox';
4+
import { createInsertSchema, createSelectSchema } from 'drizzle-typebox';
5+
6+
export const insertCommentSchemaRaw = createInsertSchema(comments);
7+
export const selectCommentSchemaRaw = createSelectSchema(comments);
8+
9+
export const AddCommentSchema = Type.Object({
10+
comment: Type.Object({
11+
body: Type.String(),
12+
}),
13+
});
14+
15+
export type CommentToCreate = Static<typeof AddCommentSchema>['comment'] & {
16+
authorId: number;
17+
articleId: number;
18+
};
19+
20+
export const ReturnedCommentSchema = Type.Composite([
21+
Type.Omit(selectCommentSchemaRaw, ['articleId', 'authorId']),
22+
Type.Object({
23+
author: Type.Composite([
24+
Type.Omit(selectUserSchemaRaw, [
25+
'id',
26+
'email',
27+
'password',
28+
'createdAt',
29+
'updatedAt',
30+
]),
31+
Type.Object({
32+
following: Type.Boolean(),
33+
}),
34+
]),
35+
}),
36+
]);
37+
38+
export type ReturnedComment = Static<typeof ReturnedCommentSchema>;
39+
40+
export const ReturnedCommentResponse = Type.Object({
41+
comment: ReturnedCommentSchema,
42+
});
43+
44+
export const ReturnedCommentsResponse = Type.Object({
45+
comments: Type.Array(ReturnedCommentSchema),
46+
});
47+
48+
export const DeleteCommentResponse = Type.Object({});

0 commit comments

Comments
 (0)