From ca6358ec05a13a4096b42b05869541f891f68704 Mon Sep 17 00:00:00 2001 From: Hajbo Date: Tue, 18 Mar 2025 00:07:01 +0700 Subject: [PATCH 01/28] add article CRUD endpoints --- .vscode/settings.json | 8 +- db/config.ts | 5 + db/drop.ts | 11 +- db/migrations/0007_elite_patriot.sql | 24 ++ db/migrations/meta/0007_snapshot.json | 307 ++++++++++++++++++++++++++ db/migrations/meta/_journal.json | 7 + src/app.module.ts | 23 +- src/articles/articles.model.ts | 68 ++++++ src/articles/articles.module.ts | 24 ++ src/articles/articles.plugin.ts | 116 ++++++++++ src/articles/articles.repository.ts | 176 +++++++++++++++ src/articles/articles.schema.ts | 99 +++++++++ src/articles/articles.service.ts | 124 +++++++++++ src/auth/auth.service.ts | 9 + src/database.providers.ts | 3 +- src/profiles/profiles.schema.ts | 8 +- src/profiles/profiles.service.ts | 11 +- src/users/users.model.ts | 3 + src/users/users.schema.ts | 2 +- src/utils/slug.ts | 39 ++++ tsconfig.json | 1 + 21 files changed, 1050 insertions(+), 18 deletions(-) create mode 100644 db/migrations/0007_elite_patriot.sql create mode 100644 db/migrations/meta/0007_snapshot.json create mode 100644 src/articles/articles.model.ts create mode 100644 src/articles/articles.module.ts create mode 100644 src/articles/articles.plugin.ts create mode 100644 src/articles/articles.repository.ts create mode 100644 src/articles/articles.schema.ts create mode 100644 src/articles/articles.service.ts create mode 100644 src/utils/slug.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 6dfcb722..0312feed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,11 +15,5 @@ "password": "postgres" } ], - "cSpell.words": [ - "bedstack", - "Elysia", - "elysiajs", - "favicons", - "typesafe" - ] + "cSpell.words": ["bedstack", "Elysia", "elysiajs", "favicons", "typesafe"] } diff --git a/db/config.ts b/db/config.ts index 1059da8e..573f0675 100644 --- a/db/config.ts +++ b/db/config.ts @@ -19,4 +19,9 @@ export default defineConfig({ dialect: 'postgresql', dbCredentials: dbCredentials, strict: true, + // Redefine default migrations table and schema for the sake of clarity + migrations: { + table: '__drizzle_migrations', + schema: 'drizzle', + }, }); diff --git a/db/drop.ts b/db/drop.ts index 2f5901de..a46ac9e0 100644 --- a/db/drop.ts +++ b/db/drop.ts @@ -1,9 +1,11 @@ import { exit } from 'node:process'; import { db } from '@/database.providers'; +import { articles, favoriteArticles } from '@articles/articles.model'; +import dbConfig from '@db/config'; import { userFollows, users } from '@users/users.model'; import { getTableName, sql } from 'drizzle-orm'; -const tables = [users, userFollows]; +const tables = [userFollows, favoriteArticles, articles, users]; console.log('Dropping all tables from the database'); try { @@ -17,6 +19,13 @@ try { ); console.log(`Dropped ${name}`); } + if (dbConfig.migrations?.table) { + // Clean up migrations + console.log('Dropping migrations table'); + await tx.execute( + sql`DROP TABLE IF EXISTS ${sql.identifier(dbConfig.migrations.table)} CASCADE;`, + ); + } }); console.log('All tables dropped'); diff --git a/db/migrations/0007_elite_patriot.sql b/db/migrations/0007_elite_patriot.sql new file mode 100644 index 00000000..ae1fc15c --- /dev/null +++ b/db/migrations/0007_elite_patriot.sql @@ -0,0 +1,24 @@ +CREATE TABLE "articles" ( + "id" serial PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "body" text NOT NULL, + "tag_list" text[] DEFAULT '{}'::text[] NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "author_id" integer NOT NULL, + CONSTRAINT "articles_slug_unique" UNIQUE("slug") +); + +CREATE TABLE "favorite_articles" ( + "article_id" integer NOT NULL, + "user_id" integer NOT NULL, + "created_at" date DEFAULT CURRENT_DATE NOT NULL, + "updated_at" date DEFAULT CURRENT_DATE NOT NULL, + CONSTRAINT "favorite_articles_article_id_user_id_pk" PRIMARY KEY("article_id","user_id") +); + +ALTER TABLE "articles" ADD CONSTRAINT "articles_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "favorite_articles" ADD CONSTRAINT "favorite_articles_article_id_articles_id_fk" FOREIGN KEY ("article_id") REFERENCES "public"."articles"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "favorite_articles" ADD CONSTRAINT "favorite_articles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/db/migrations/meta/0007_snapshot.json b/db/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..b7744079 --- /dev/null +++ b/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,307 @@ +{ + "id": "a387bcf4-fc25-4a7c-9467-3f76e9356261", + "prevId": "c621d843-d221-4084-a3e3-361c92f1c7bf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.articles": { + "name": "articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_list": { + "name": "tag_list", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "articles_author_id_users_id_fk": { + "name": "articles_author_id_users_id_fk", + "tableFrom": "articles", + "tableTo": "users", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "articles_slug_unique": { + "name": "articles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorite_articles": { + "name": "favorite_articles", + "schema": "", + "columns": { + "article_id": { + "name": "article_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_DATE" + }, + "updated_at": { + "name": "updated_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_DATE" + } + }, + "indexes": {}, + "foreignKeys": { + "favorite_articles_article_id_articles_id_fk": { + "name": "favorite_articles_article_id_articles_id_fk", + "tableFrom": "favorite_articles", + "tableTo": "articles", + "columnsFrom": ["article_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorite_articles_user_id_users_id_fk": { + "name": "favorite_articles_user_id_users_id_fk", + "tableFrom": "favorite_articles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "favorite_articles_article_id_user_id_pk": { + "name": "favorite_articles_article_id_user_id_pk", + "columns": ["article_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "followed_id": { + "name": "followed_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_DATE" + }, + "updated_at": { + "name": "updated_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_DATE" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_followed_id_users_id_fk": { + "name": "user_follows_followed_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": ["followed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": ["follower_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_follows_followed_id_follower_id_pk": { + "name": "user_follows_followed_id_follower_id_pk", + "columns": ["followed_id", "follower_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://api.realworld.io/images/smiley-cyrus.jpg'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_DATE" + }, + "updated_at": { + "name": "updated_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_DATE" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index d4cb905c..67474833 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1742050755293, "tag": "0006_goofy_diamondback", "breakpoints": false + }, + { + "idx": 7, + "version": "7", + "when": 1742230377894, + "tag": "0007_elite_patriot", + "breakpoints": false } ] } diff --git a/src/app.module.ts b/src/app.module.ts index 62d2dbc0..298c4547 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,4 @@ +import { articlesPlugin } from '@articles/articles.plugin'; import { swagger } from '@elysiajs/swagger'; import { AuthenticationError, @@ -36,9 +37,29 @@ export const setupApp = () => { swagger({ documentation: { info: { title, version, description }, + components: { + securitySchemes: { + tokenAuth: { + type: 'apiKey', + description: 'Prefix the token with "Token", e.g. "Token xxxx"', + in: 'header', + name: 'Authorization', + }, + }, + }, + security: [ + { + tokenAuth: [], + }, + ], }, exclude: ['/'], + swaggerOptions: { + persistAuthorization: true, + }, }), ) - .group('/api', (app) => app.use(usersPlugin).use(profilesPlugin)); + .group('/api', (app) => + app.use(usersPlugin).use(profilesPlugin).use(articlesPlugin), + ); }; diff --git a/src/articles/articles.model.ts b/src/articles/articles.model.ts new file mode 100644 index 00000000..8072cc93 --- /dev/null +++ b/src/articles/articles.model.ts @@ -0,0 +1,68 @@ +import { relations, sql } from 'drizzle-orm'; +import { + date, + integer, + pgTable, + primaryKey, + serial, + text, + timestamp, +} from 'drizzle-orm/pg-core'; + +import { users } from '@users/users.model'; + +export const articles = pgTable('articles', { + id: serial('id').primaryKey().notNull(), + slug: text('slug').notNull().unique(), + title: text('title').notNull(), + description: text('description').notNull(), + body: text('body').notNull(), + tagList: text('tag_list').array().default(sql`'{}'::text[]`).notNull(), + createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(), + authorId: integer('author_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), +}); + +export const articleRelations = relations(articles, ({ one, many }) => ({ + author: one(users, { + fields: [articles.authorId], + references: [users.id], + relationName: 'author', + }), + favoritedBy: many(favoriteArticles, { + relationName: 'favoriteArticle', + }), +})); + +export const favoriteArticles = pgTable( + 'favorite_articles', + { + articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }) + .notNull(), + userId: integer('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + createdAt: date('created_at').default(sql`CURRENT_DATE`).notNull(), + updatedAt: date('updated_at').default(sql`CURRENT_DATE`).notNull(), + }, + (table) => [primaryKey({ columns: [table.articleId, table.userId] })], +); + +export const favoriteArticleRelations = relations( + favoriteArticles, + ({ one }) => ({ + article: one(articles, { + fields: [favoriteArticles.articleId], + references: [articles.id], + relationName: 'favoriteArticle', + }), + users: one(users, { + fields: [favoriteArticles.userId], + references: [users.id], + relationName: 'favoritedBy', + }), + }), +); diff --git a/src/articles/articles.module.ts b/src/articles/articles.module.ts new file mode 100644 index 00000000..60472595 --- /dev/null +++ b/src/articles/articles.module.ts @@ -0,0 +1,24 @@ +import { db } from '@/database.providers'; +import { ArticlesRepository } from '@articles/articles.repository'; +import { ArticlesService } from '@articles/articles.service'; +import { AuthService } from '@auth/auth.service'; +import { ProfilesRepository } from '@profiles/profiles.repository'; +import { ProfilesService } from '@profiles/profiles.service'; +import { UsersRepository } from '@users/users.repository'; +import { Elysia } from 'elysia'; + +export const setupArticles = () => { + const articlesRepository = new ArticlesRepository(db); + const profilesRepository = new ProfilesRepository(db); + const usersRepository = new UsersRepository(db); + const profilesService = new ProfilesService( + profilesRepository, + usersRepository, + ); + const articlesService = new ArticlesService( + articlesRepository, + profilesService, + ); + const authService = new AuthService(); + return new Elysia().state(() => ({ articlesService, authService })); +}; diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts new file mode 100644 index 00000000..04ed1c0d --- /dev/null +++ b/src/articles/articles.plugin.ts @@ -0,0 +1,116 @@ +import { setupArticles } from '@articles/articles.module'; +import { + ArticleFeedQuerySchema, + DeleteArticleResponse, + InsertArticleSchema, + ListArticlesQuerySchema, + ReturnedArticleListSchema, + ReturnedArticleResponseSchema, + UpdateArticleSchema, +} from '@articles/articles.schema'; +import { Elysia } from 'elysia'; + +export const articlesPlugin = new Elysia().use(setupArticles).group( + '/articles', + { + detail: { + tags: ['Articles'], + }, + }, + (app) => + app + .get( + '', + async ({ query, store, request }) => + store.articlesService.find({ + ...query, + currentUserId: await store.authService.getOptionalUserIdFromHeader( + request.headers, + ), + }), + { + query: ListArticlesQuerySchema, + response: ReturnedArticleListSchema, + detail: { + summary: 'List Articles', + }, + }, + ) + .post( + '', + async ({ body, request, store }) => + store.articlesService.createArticle( + body.article, + await store.authService.getUserIdFromHeader(request.headers), + ), + { + beforeHandle: app.store.authService.requireLogin, + body: InsertArticleSchema, + response: ReturnedArticleResponseSchema, + detail: { + summary: 'Create Article', + }, + }, + ) + .get( + '/feed', + async ({ query, store, request }) => + store.articlesService.find({ + ...query, + currentUserId: await store.authService.getUserIdFromHeader( + request.headers, + ), + }), + { + beforeHandle: app.store.authService.requireLogin, + query: ArticleFeedQuerySchema, + response: ReturnedArticleListSchema, + detail: { + summary: 'Artifle Feed', + }, + }, + ) + .get( + '/:slug', + async ({ params, store }) => + store.articlesService.findBySlug(params.slug), + { + response: ReturnedArticleResponseSchema, + detail: { + summary: 'Get Article', + }, + }, + ) + .put( + '/:slug', + async ({ params, body, store, request }) => + store.articlesService.updateArticle( + params.slug, + body.article, + await store.authService.getUserIdFromHeader(request.headers), + ), + { + beforeHandle: app.store.authService.requireLogin, + body: UpdateArticleSchema, + response: ReturnedArticleResponseSchema, + detail: { + summary: 'Update Article', + }, + }, + ) + .delete( + '/:slug', + async ({ params, store, request }) => + store.articlesService.deleteArticle( + params.slug, + await store.authService.getUserIdFromHeader(request.headers), + ), + { + beforeHandle: app.store.authService.requireLogin, + response: DeleteArticleResponse, + detail: { + summary: 'Delete Article', + }, + }, + ), +); diff --git a/src/articles/articles.repository.ts b/src/articles/articles.repository.ts new file mode 100644 index 00000000..01448f39 --- /dev/null +++ b/src/articles/articles.repository.ts @@ -0,0 +1,176 @@ +import type { + ArticleToCreate, + ArticleToUpdate, +} from '@/articles/articles.schema'; +import type { Database } from '@/database.providers'; +import { userFollows, users } from '@/users/users.model'; +import { articles, favoriteArticles } from '@articles/articles.model'; +import { and, arrayContains, desc, eq, sql } from 'drizzle-orm'; + +export class ArticlesRepository { + constructor(private readonly db: Database) {} + + async find({ + currentUserId, + offset, + limit, + tag, + author, + favorited, + }: { + currentUserId: number | null; + offset: number; + limit: number; + tag?: string; + author?: string; + favorited?: string; + }) { + const authorFilters = []; + if (author) { + authorFilters.push(eq(users.username, author)); + } + + const authorsWithFollowersCTE = this.db.$with('authorsWithFollowers').as( + this.db + .select({ + authorId: users.id, + authorUsername: users.username, + authorBio: users.bio, + authorImage: users.image, + authorFollowing: + sql`coalesce(${currentUserId} = any(array_agg(user_follows.follower_id)), false)`.as( + 'authorFollowing', + ), + }) + .from(users) + .leftJoin(userFollows, eq(users.id, userFollows.followedId)) + .where(and(...authorFilters)) + .groupBy(users.id), + ); + + const articleFilters = []; + if (tag) { + articleFilters.push(arrayContains(articles.tagList, [tag])); + } + if (favorited) { + articleFilters.push(eq(users.username, favorited)); + } + + const articlesWithLikesCTE = this.db.$with('articlesWithLikes').as( + this.db + .select({ + articleId: articles.id, + favorited: + sql`coalesce(${currentUserId} = any(array_agg(favorite_articles.user_id)), false)`.as( + 'favorited', + ), + favoriteCount: sql`count(*)::integer`.as('favoriteCount'), + }) + .from(articles) + .leftJoin(favoriteArticles, eq(favoriteArticles.articleId, articles.id)) + .leftJoin(users, eq(users.id, favoriteArticles.userId)) + .where(and(...articleFilters)) + .groupBy(articles.id), + ); + + const results = await this.db + .with(authorsWithFollowersCTE, articlesWithLikesCTE) + .select({ + slug: articles.slug, + title: articles.title, + description: articles.description, + tagList: articles.tagList, + createdAt: articles.createdAt, + updatedAt: articles.updatedAt, + favorited: articlesWithLikesCTE.favorited, + favoritesCount: articlesWithLikesCTE.favoriteCount, + author: { + username: authorsWithFollowersCTE.authorUsername, + bio: authorsWithFollowersCTE.authorBio, + image: authorsWithFollowersCTE.authorImage, + following: authorsWithFollowersCTE.authorFollowing, + }, + }) + .from(articles) + .innerJoin( + articlesWithLikesCTE, + eq(articlesWithLikesCTE.articleId, articles.id), + ) + .innerJoin( + authorsWithFollowersCTE, + eq(authorsWithFollowersCTE.authorId, articles.authorId), + ) + .limit(limit) + .offset(offset) + .orderBy(desc(articles.createdAt)); + + return results; + } + + async findBySlug(slug: string) { + const result = await this.db.query.articles.findFirst({ + where: eq(articles.slug, slug), + with: { + author: { + with: { + followers: true, + }, + }, + favoritedBy: true, + }, + }); + return result; + } + + async findById(id: number) { + const result = await this.db.query.articles.findFirst({ + where: eq(articles.id, id), + with: { + author: { + with: { + followers: true, + }, + }, + favoritedBy: true, + }, + }); + return result; + } + + async createArticle(article: ArticleToCreate) { + const results = await this.db.insert(articles).values(article).returning(); + const newArticle = results[0]; + return this.findById(newArticle.id); + } + + async updateArticle( + articleId: number, + article: ArticleToUpdate, + currentUserId: number, + ) { + const valuesToSet = Object.entries(article).reduce( + (acc: { [key: string]: string | string[] | Date }, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + return acc; + }, + {}, + ); + valuesToSet.updatedAt = new Date(); + await this.db + .update(articles) + .set(valuesToSet) + .where( + and(eq(articles.id, articleId), eq(articles.authorId, currentUserId)), + ); + } + + async deleteArticle(slug: string, currentUserId: number) { + return await this.db + .delete(articles) + .where( + and(eq(articles.slug, slug), eq(articles.authorId, currentUserId)), + ); + } +} diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts new file mode 100644 index 00000000..a3c0625a --- /dev/null +++ b/src/articles/articles.schema.ts @@ -0,0 +1,99 @@ +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); +export const selectArticleSchemaRaw = createSelectSchema(articles); + +export const InsertArticleSchema = Type.Object({ + article: Type.Pick(insertArticleSchemaRaw, [ + 'title', + 'description', + 'body', + 'tagList', + ]), +}); + +export type ArticleToCreateData = Static['article']; +export type ArticleToCreate = ArticleToCreateData & { + authorId: number; + slug: string; +}; + +export const UpdateArticleSchema = Type.Object({ + article: Type.Partial( + Type.Pick(insertArticleSchemaRaw, [ + 'title', + 'description', + 'body', + 'tagList', + ]), + ), +}); + +export type ArticleToUpdateRequest = Static< + typeof UpdateArticleSchema +>['article']; +export type ArticleToUpdate = ArticleToUpdateRequest & { + slug: string; +}; + +export const ReturnedArticleSchema = Type.Composite([ + Type.Omit(selectArticleSchemaRaw, ['id', 'authorId']), + Type.Object({ + author: Type.Object({ + username: Type.String(), + bio: Type.String(), + image: Type.String(), + following: Type.Boolean(), + }), + favorited: Type.Boolean(), + favoritesCount: Type.Number(), + }), +]); + +export const ReturnedArticleResponseSchema = Type.Object({ + article: ReturnedArticleSchema, +}); + +export type ReturnedArticle = Static; +export type ReturnedArticleResponse = Static< + typeof ReturnedArticleResponseSchema +>; + +export type ArticleInDb = Omit< + typeof articles.$inferSelect, + 'id' | 'authorId' +> & { + author: Profile; + favoritedBy: ArticleFavoritedBy[]; +}; + +type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect; + +export const ArticleFeedQuerySchema = Type.Object({ + limit: Type.Optional(Type.Number({ minimum: 1, default: 20 })), + offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })), +}); +export const ListArticlesQuerySchema = Type.Composite([ + ArticleFeedQuerySchema, + Type.Object({ + tag: Type.Optional(Type.String()), + author: Type.Optional(Type.String()), + favorited: Type.Optional(Type.String()), + }), +]); + +export const ReturnedArticleListSchema = Type.Object({ + articles: Type.Array(Type.Omit(ReturnedArticleSchema, ['body'])), + articlesCount: Type.Number(), +}); + +export type ReturnedArticleList = Static; + +export const DeleteArticleResponse = Type.Object({ + message: Type.String(), + slug: Type.String(), +}); diff --git a/src/articles/articles.service.ts b/src/articles/articles.service.ts new file mode 100644 index 00000000..66ea0431 --- /dev/null +++ b/src/articles/articles.service.ts @@ -0,0 +1,124 @@ +import { AuthorizationError, BadRequestError } from '@/errors'; +import type { ArticlesRepository } from '@articles/articles.repository'; +import type { + ArticleInDb, + ArticleToCreate, + ArticleToCreateData, + ArticleToUpdateRequest, + ReturnedArticleList, + ReturnedArticleResponse, +} from '@articles/articles.schema'; +import type { ProfilesService } from '@profiles/profiles.service'; +import { slugify } from '@utils/slug'; +import { NotFoundError } from 'elysia'; + +export class ArticlesService { + constructor( + private readonly repository: ArticlesRepository, + private readonly profilesService: ProfilesService, + ) {} + + async find(query: { + currentUserId: number | null; + offset?: number; + limit?: number; + tag?: string; + author?: string; + favorited?: string; + }): Promise { + const limit = query.limit || 20; + const offset = query.offset || 0; + const results = await this.repository.find({ ...query, limit, offset }); + return { + articles: results, + articlesCount: results.length, + }; + } + + async findBySlug(slug: string) { + const article = await this.repository.findBySlug(slug); + if (!article) { + throw new NotFoundError('Article not found'); + } + return await this.generateArticleResponse(article, null); + } + + async createArticle(article: ArticleToCreateData, currentUserId: number) { + const articleToCreate: ArticleToCreate = { + ...article, + tagList: article.tagList?.sort() || [], + authorId: currentUserId, + slug: slugify(article.title), + }; + const createdArticle = await this.repository.createArticle(articleToCreate); + if (!createdArticle) { + throw new BadRequestError('Article was not created'); + } + return await this.generateArticleResponse(createdArticle, currentUserId); + } + + async updateArticle( + slug: string, + article: ArticleToUpdateRequest, + currentUserId: number, + ) { + const existingArticle = await this.repository.findBySlug(slug); + if (!existingArticle) { + throw new NotFoundError('Article not found'); + } + if (existingArticle.authorId !== currentUserId) { + throw new AuthorizationError('Only the author can update the article'); + } + + const newSlug = article.title + ? slugify(article.title) + : existingArticle.slug; + await this.repository.updateArticle( + existingArticle.id, + { ...article, slug: newSlug }, + currentUserId, + ); + return this.findBySlug(newSlug); + } + + async deleteArticle(slug: string, currentUserId: number) { + const article = await this.repository.findBySlug(slug); + if (!article) { + throw new NotFoundError('Article not found'); + } + if (article.authorId !== currentUserId) { + throw new AuthorizationError('Only the author can delete the article'); + } + await this.repository.deleteArticle(slug, currentUserId); + return { + message: 'Article deleted', + slug: article.slug, + }; + } + + async generateArticleResponse( + article: ArticleInDb, + currentUserId: number | null, + ): Promise { + const authorProfile = await this.profilesService.generateProfileResponse( + article.author, + currentUserId, + ); + return { + article: { + slug: article.slug, + title: article.title, + description: article.description, + body: article.body, + tagList: article.tagList, + createdAt: article.createdAt, + updatedAt: article.updatedAt, + author: authorProfile.profile, + favorited: !!article.favoritedBy.find( + (user) => user.userId === currentUserId, + ), + favoritesCount: article.favoritedBy.length, + }, + }; + } +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 393adce2..3aeb1cc7 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -91,4 +91,13 @@ export class AuthService { const user = await this.getUserFromHeaders(headers); return user.id; }; + + getOptionalUserIdFromHeader = async (headers: Headers) => { + try { + const user = await this.getUserFromHeaders(headers); + return user.id; + } catch (error) { + return null; + } + }; } diff --git a/src/database.providers.ts b/src/database.providers.ts index dad01378..a4665576 100644 --- a/src/database.providers.ts +++ b/src/database.providers.ts @@ -1,3 +1,4 @@ +import * as articlesSchema from '@articles/articles.model'; import { dbCredentialsString } from '@db/config'; import * as usersSchema from '@users/users.model'; import { drizzle } from 'drizzle-orm/postgres-js'; @@ -8,7 +9,7 @@ export const migrationsClient = postgres(dbCredentialsString, { max: 1 }); export const queryClient = postgres(dbCredentialsString); export const db = drizzle(queryClient, { - schema: { ...usersSchema }, + schema: { ...usersSchema, ...articlesSchema }, logger: true, }); export type Database = typeof db; diff --git a/src/profiles/profiles.schema.ts b/src/profiles/profiles.schema.ts index 5482fbe8..a76b5ae7 100644 --- a/src/profiles/profiles.schema.ts +++ b/src/profiles/profiles.schema.ts @@ -2,7 +2,7 @@ import { type Static, Type } from '@sinclair/typebox'; import { type FollowerSchema, type SelectUserSchema, - insertUserSchemaRaw, + selectUserSchemaRaw, } from '@users/users.schema'; export type Profile = Static & { @@ -11,14 +11,16 @@ export type Profile = Static & { export const ReturnedProfileSchema = Type.Object({ profile: Type.Composite([ - Type.Omit(insertUserSchemaRaw, [ + Type.Omit(selectUserSchemaRaw, [ 'id', 'email', 'password', 'createdAt', 'updatedAt', ]), - Type.Object({ following: Type.Boolean() }), + Type.Object({ + following: Type.Boolean(), + }), ]), }); diff --git a/src/profiles/profiles.service.ts b/src/profiles/profiles.service.ts index 4e9c9037..73d8e1d6 100644 --- a/src/profiles/profiles.service.ts +++ b/src/profiles/profiles.service.ts @@ -54,16 +54,19 @@ export class ProfilesService { async generateProfileResponse( user: Profile, - currentUserId: number, + currentUserId: number | null, ): Promise { return { profile: { bio: user.bio, image: user.image, username: user.username, - following: !!user.followers.find( - (follower) => follower.followerId === currentUserId, - ), + following: + currentUserId == null + ? false + : !!user.followers.find( + (follower) => follower.followerId === currentUserId, + ), }, }; } diff --git a/src/users/users.model.ts b/src/users/users.model.ts index c22b28bd..b43a1e79 100644 --- a/src/users/users.model.ts +++ b/src/users/users.model.ts @@ -1,3 +1,4 @@ +import { articles, favoriteArticles } from '@/articles/articles.model'; import { relations, sql } from 'drizzle-orm'; import { date, @@ -24,6 +25,8 @@ export const users = pgTable('users', { export const userRelations = relations(users, ({ many }) => ({ followers: many(userFollows, { relationName: 'followed' }), following: many(userFollows, { relationName: 'follower' }), + publishedArticles: many(articles, { relationName: 'author' }), + favoriteArticles: many(favoriteArticles, { relationName: 'favoritedBy' }), })); export const userFollows = pgTable( diff --git a/src/users/users.schema.ts b/src/users/users.schema.ts index 5e5d2d25..2241baf1 100644 --- a/src/users/users.schema.ts +++ b/src/users/users.schema.ts @@ -46,7 +46,7 @@ export const UserLoginSchema = Type.Object({ }); // Schema for selecting a user - can be used to validate API responses -const selectUserSchemaRaw = createSelectSchema(users); +export const selectUserSchemaRaw = createSelectSchema(users); export const SelectUserSchema = Type.Omit(selectUserSchemaRaw, ['password']); export type FollowerSchema = typeof userFollows.$inferSelect; diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 00000000..cfe9087b --- /dev/null +++ b/src/utils/slug.ts @@ -0,0 +1,39 @@ +// MIT License + +// Copyright (c) 2023 Max Rogério + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ref: https://gist.github.com/max10rogerio/c67c5d2d7a3ce714c4bc0c114a3ddc6e +// https://stackoverflow.com/a/37511463 + +export const slugify = (...args: string[]): string => { + const value = args.join(' '); + + return ( + value + .normalize('NFD') // split an accented letter in the base letter and the acent + // biome-ignore lint/suspicious/noMisleadingCharacterClass: false alarm for the regex + .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents + .toLowerCase() + .trim() + .replace(/[^a-z0-9 ]/g, '') // remove all chars not letters, numbers and spaces (to be replaced) + .replace(/\s+/g, '-') + ); // separator +}; diff --git a/tsconfig.json b/tsconfig.json index 343b635d..5c9ce2e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ "@auth/*": ["./src/auth/*"], "@profiles/*": ["./src/profiles/*"], "@utils/*": ["./src/utils/*"], + "@articles/*": ["./src/articles/*"], "@errors": ["./src/errors.ts"], "@config": ["./src/config.ts"] }, From b9190175600153854eb0d7f44480b2368665f8af Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Wed, 19 Mar 2025 01:58:10 +0500 Subject: [PATCH 02/28] Implement comments feature for articles --- src/articles/articles.model.ts | 29 +++++ src/articles/articles.module.ts | 14 ++- src/articles/articles.plugin.ts | 78 +++++++++++- src/articles/articles.schema.ts | 51 +++----- src/articles/comments/comments.repository.ts | 52 ++++++++ src/articles/comments/comments.schema.ts | 32 +++++ src/articles/comments/comments.service.ts | 121 +++++++++++++++++++ 7 files changed, 343 insertions(+), 34 deletions(-) create mode 100644 src/articles/comments/comments.repository.ts create mode 100644 src/articles/comments/comments.schema.ts create mode 100644 src/articles/comments/comments.service.ts diff --git a/src/articles/articles.model.ts b/src/articles/articles.model.ts index 8072cc93..acdfb0a5 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 04ed1c0d..79ca2f9a 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', @@ -66,7 +72,7 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( query: ArticleFeedQuerySchema, response: ReturnedArticleListSchema, detail: { - summary: 'Artifle Feed', + summary: 'Article Feed', }, }, ) @@ -112,5 +118,73 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( summary: 'Delete Article', }, }, + ) + // Comment routes + .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..b0eba115 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -1,8 +1,7 @@ +import { articles, type favoriteArticles } from '@articles/articles.model'; 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); export const selectArticleSchemaRaw = createSelectSchema(articles); @@ -24,44 +23,38 @@ export type ArticleToCreate = ArticleToCreateData & { export const UpdateArticleSchema = Type.Object({ article: Type.Partial( - Type.Pick(insertArticleSchemaRaw, [ - 'title', - 'description', - 'body', - 'tagList', - ]), + Type.Pick(insertArticleSchemaRaw, ['title', 'description', 'body']), ), }); -export type ArticleToUpdateRequest = Static< - typeof UpdateArticleSchema ->['article']; -export type ArticleToUpdate = ArticleToUpdateRequest & { - slug: string; -}; +export type ArticleToUpdate = Static['article']; -export const ReturnedArticleSchema = Type.Composite([ - Type.Omit(selectArticleSchemaRaw, ['id', 'authorId']), +const returnArticleSchemaRaw = Type.Composite([ Type.Object({ - author: Type.Object({ - username: Type.String(), - bio: Type.String(), - image: Type.String(), - following: Type.Boolean(), - }), + slug: Type.String(), + title: Type.String(), + description: Type.String(), + body: Type.String(), + tagList: Type.Array(Type.String()), + createdAt: Type.String({ format: 'date-time' }), + updatedAt: Type.String({ format: 'date-time' }), favorited: Type.Boolean(), favoritesCount: Type.Number(), }), + Type.Object({ + author: Type.Any(), + }), ]); +export const ReturnedArticleSchema = returnArticleSchemaRaw; + export const ReturnedArticleResponseSchema = Type.Object({ article: ReturnedArticleSchema, }); +export const DeleteArticleResponse = Type.Object({}); + export type ReturnedArticle = Static; -export type ReturnedArticleResponse = Static< - typeof ReturnedArticleResponseSchema ->; export type ArticleInDb = Omit< typeof articles.$inferSelect, @@ -71,12 +64,13 @@ 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 })), offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })), }); + export const ListArticlesQuerySchema = Type.Composite([ ArticleFeedQuerySchema, Type.Object({ @@ -92,8 +86,3 @@ export const ReturnedArticleListSchema = Type.Object({ }); export type ReturnedArticleList = Static; - -export const DeleteArticleResponse = Type.Object({ - message: Type.String(), - slug: Type.String(), -}); diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts new file mode 100644 index 00000000..ae205df1 --- /dev/null +++ b/src/articles/comments/comments.repository.ts @@ -0,0 +1,52 @@ +import { and, desc, eq } from 'drizzle-orm'; + +import type { Database } from '@/database.providers'; +import { articles, comments } from '@articles/articles.model'; +import type { CommentToCreate } from './comments.schema'; + +export class CommentsRepository { + constructor(private readonly db: Database) {} + + async createComment(commentData: CommentToCreate) { + const [comment] = await this.db + .insert(comments) + .values(commentData) + .returning(); + + return comment; + } + + async getCommentById(id: number) { + const [comment] = await this.db + .select() + .from(comments) + .where(eq(comments.id, id)); + + return comment; + } + + async getCommentsByArticleId(articleId: number) { + const articleComments = await this.db + .select() + .from(comments) + .where(eq(comments.articleId, articleId)) + .orderBy(desc(comments.createdAt)); + + return articleComments; + } + + async getArticleBySlug(slug: string) { + const [article] = await this.db + .select() + .from(articles) + .where(eq(articles.slug, slug)); + + return article; + } + + async deleteComment(commentId: number, authorId: number) { + 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..bc7ac038 --- /dev/null +++ b/src/articles/comments/comments.schema.ts @@ -0,0 +1,32 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const AddCommentSchema = Type.Object({ + comment: Type.Object({ + body: Type.String(), + }), +}); + +export type CommentToCreate = Static['comment'] & { + authorId: number; + articleId: number; +}; + +export const CommentSchema = Type.Object({ + id: Type.Number(), + body: Type.String(), + createdAt: Type.String({ format: 'date-time' }), + updatedAt: Type.String({ format: 'date-time' }), + author: Type.Any(), +}); + +export type ReturnedComment = Static; + +export const ReturnedCommentResponse = Type.Object({ + comment: CommentSchema, +}); + +export const ReturnedCommentsResponse = Type.Object({ + comments: Type.Array(CommentSchema), +}); + +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..39cef287 --- /dev/null +++ b/src/articles/comments/comments.service.ts @@ -0,0 +1,121 @@ +import { AuthorizationError, BadRequestError } from '@errors'; +import type { ProfilesService } from '@profiles/profiles.service'; +import type { UsersRepository } from '@users/users.repository'; +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, + private readonly usersRepository: UsersRepository, + ) {} + + async createComment( + articleSlug: string, + commentBody: { body: string }, + userId: number, + ): Promise { + const article = await this.commentsRepository.getArticleBySlug(articleSlug); + + if (!article) { + throw new BadRequestError(`Article with slug ${articleSlug} not found`); + } + + const commentData: CommentToCreate = { + ...commentBody, + authorId: userId, + articleId: article.id, + }; + + const comment = await this.commentsRepository.createComment(commentData); + const authorUsername = await this.getAuthorUsername(comment.authorId); + const authorProfile = await this.profilesService.findByUsername( + userId, + authorUsername, + ); + + return { + id: comment.id, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + author: authorProfile.profile, + }; + } + + async getComments( + articleSlug: string, + currentUserId?: number, + ): Promise { + const article = await this.commentsRepository.getArticleBySlug(articleSlug); + + if (!article) { + throw new BadRequestError(`Article with slug ${articleSlug} not found`); + } + + const comments = await this.commentsRepository.getCommentsByArticleId( + article.id, + ); + + const returnedComments = await Promise.all( + comments.map(async (comment) => { + const authorUsername = await this.getAuthorUsername(comment.authorId); + const authorProfile = await this.profilesService.findByUsername( + currentUserId || 0, + authorUsername, + ); + + return { + id: comment.id, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + author: authorProfile.profile, + }; + }), + ); + + return returnedComments; + } + + async deleteComment( + articleSlug: string, + commentId: number, + userId: number, + ): Promise { + const article = await this.commentsRepository.getArticleBySlug(articleSlug); + + if (!article) { + throw new BadRequestError(`Article with slug ${articleSlug} not found`); + } + + const comment = await this.commentsRepository.getCommentById(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.deleteComment(commentId, userId); + } + + private async getAuthorUsername(userId: number): Promise { + const user = await this.usersRepository.findById(userId); + if (!user) { + throw new BadRequestError(`User with id ${userId} not found`); + } + return user.username; + } +} From 5c4447feab8fb174e0f4a4696775aa8b62acc10a Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Wed, 19 Mar 2025 02:16:15 +0500 Subject: [PATCH 03/28] improve names and repository --- src/articles/articles.plugin.ts | 1 - src/articles/comments/comments.repository.ts | 42 +++++++++----------- src/articles/comments/comments.service.ts | 14 +++---- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts index 79ca2f9a..57bf3a6c 100644 --- a/src/articles/articles.plugin.ts +++ b/src/articles/articles.plugin.ts @@ -119,7 +119,6 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( }, }, ) - // Comment routes .post( '/:slug/comments', async ({ body, params, store, request }) => { diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts index ae205df1..064159c2 100644 --- a/src/articles/comments/comments.repository.ts +++ b/src/articles/comments/comments.repository.ts @@ -7,44 +7,38 @@ import type { CommentToCreate } from './comments.schema'; export class CommentsRepository { constructor(private readonly db: Database) {} - async createComment(commentData: CommentToCreate) { + async create(commentData: CommentToCreate) { const [comment] = await this.db .insert(comments) .values(commentData) .returning(); - return comment; } - async getCommentById(id: number) { - const [comment] = await this.db - .select() - .from(comments) - .where(eq(comments.id, id)); - - return comment; + async findById(id: number) { + const result = await this.db.query.comments.findFirst({ + where: eq(comments.id, id), + }); + return result; } - async getCommentsByArticleId(articleId: number) { - const articleComments = await this.db - .select() - .from(comments) - .where(eq(comments.articleId, articleId)) - .orderBy(desc(comments.createdAt)); - - return articleComments; + async findManyByArticleId(articleId: number) { + const result = await this.db.query.comments.findMany({ + where: eq(comments.articleId, articleId), + orderBy: [desc(comments.createdAt)], + }); + return result; } - async getArticleBySlug(slug: string) { - const [article] = await this.db - .select() - .from(articles) - .where(eq(articles.slug, slug)); + async findBySlug(slug: string) { + const result = await this.db.query.articles.findFirst({ + where: eq(articles.slug, slug), + }); - return article; + return result; } - async deleteComment(commentId: number, authorId: number) { + async delete(commentId: number, authorId: number) { await this.db .delete(comments) .where(and(eq(comments.id, commentId), eq(comments.authorId, authorId))); diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index 39cef287..92dbde31 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -16,7 +16,7 @@ export class CommentsService { commentBody: { body: string }, userId: number, ): Promise { - const article = await this.commentsRepository.getArticleBySlug(articleSlug); + const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { throw new BadRequestError(`Article with slug ${articleSlug} not found`); @@ -28,7 +28,7 @@ export class CommentsService { articleId: article.id, }; - const comment = await this.commentsRepository.createComment(commentData); + const comment = await this.commentsRepository.create(commentData); const authorUsername = await this.getAuthorUsername(comment.authorId); const authorProfile = await this.profilesService.findByUsername( userId, @@ -48,13 +48,13 @@ export class CommentsService { articleSlug: string, currentUserId?: number, ): Promise { - const article = await this.commentsRepository.getArticleBySlug(articleSlug); + const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { throw new BadRequestError(`Article with slug ${articleSlug} not found`); } - const comments = await this.commentsRepository.getCommentsByArticleId( + const comments = await this.commentsRepository.findManyByArticleId( article.id, ); @@ -84,13 +84,13 @@ export class CommentsService { commentId: number, userId: number, ): Promise { - const article = await this.commentsRepository.getArticleBySlug(articleSlug); + const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { throw new BadRequestError(`Article with slug ${articleSlug} not found`); } - const comment = await this.commentsRepository.getCommentById(commentId); + const comment = await this.commentsRepository.findById(commentId); if (!comment) { throw new BadRequestError(`Comment with id ${commentId} not found`); @@ -108,7 +108,7 @@ export class CommentsService { ); } - await this.commentsRepository.deleteComment(commentId, userId); + await this.commentsRepository.delete(commentId, userId); } private async getAuthorUsername(userId: number): Promise { From 813b6c830a421635cf10374030600962c1ab5bf9 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Wed, 19 Mar 2025 02:18:49 +0500 Subject: [PATCH 04/28] Fix delete method in CommentsRepository to return the result of the database operation --- src/articles/comments/comments.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts index 064159c2..fc5823d3 100644 --- a/src/articles/comments/comments.repository.ts +++ b/src/articles/comments/comments.repository.ts @@ -39,7 +39,7 @@ export class CommentsRepository { } async delete(commentId: number, authorId: number) { - await this.db + return await this.db .delete(comments) .where(and(eq(comments.id, commentId), eq(comments.authorId, authorId))); } From 557f2d2430370b3283a7506a13850b13eb7abe3f Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 11:42:53 +0700 Subject: [PATCH 05/28] Rename to slugify --- src/articles/articles.service.ts | 2 +- src/utils/{slug.ts => slugify.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/utils/{slug.ts => slugify.ts} (100%) diff --git a/src/articles/articles.service.ts b/src/articles/articles.service.ts index 66ea0431..20dd46ec 100644 --- a/src/articles/articles.service.ts +++ b/src/articles/articles.service.ts @@ -9,7 +9,7 @@ import type { ReturnedArticleResponse, } from '@articles/articles.schema'; import type { ProfilesService } from '@profiles/profiles.service'; -import { slugify } from '@utils/slug'; +import { slugify } from '@/utils/slugify'; import { NotFoundError } from 'elysia'; export class ArticlesService { diff --git a/src/utils/slug.ts b/src/utils/slugify.ts similarity index 100% rename from src/utils/slug.ts rename to src/utils/slugify.ts From d3e679f80d6cb31fe4b638d902f9a6cb5d3e5bd5 Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 11:44:01 +0700 Subject: [PATCH 06/28] Add trailing slashes --- src/articles/articles.plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts index 04ed1c0d..d15a5dbc 100644 --- a/src/articles/articles.plugin.ts +++ b/src/articles/articles.plugin.ts @@ -20,7 +20,7 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( (app) => app .get( - '', + '/', async ({ query, store, request }) => store.articlesService.find({ ...query, @@ -37,7 +37,7 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( }, ) .post( - '', + '/', async ({ body, request, store }) => store.articlesService.createArticle( body.article, From aba29d2ee1c30901d6d823ea3eef6d43b47c6d0a Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 11:44:32 +0700 Subject: [PATCH 07/28] Return nulls if no article was found --- src/articles/articles.repository.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/articles/articles.repository.ts b/src/articles/articles.repository.ts index 01448f39..95874edf 100644 --- a/src/articles/articles.repository.ts +++ b/src/articles/articles.repository.ts @@ -119,6 +119,7 @@ export class ArticlesRepository { favoritedBy: true, }, }); + if (!result) return null; return result; } @@ -134,6 +135,7 @@ export class ArticlesRepository { favoritedBy: true, }, }); + if (!result) return null; return result; } From b7714e11800b2417a3a8803642a195000581459b Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 11:50:29 +0700 Subject: [PATCH 08/28] Update models & migrations --- ...ite_patriot.sql => 0007_thin_eternals.sql} | 10 +-- db/migrations/meta/0007_snapshot.json | 80 +++++++++++++------ db/migrations/meta/_journal.json | 6 +- src/articles/articles.model.ts | 14 ++-- 4 files changed, 71 insertions(+), 39 deletions(-) rename db/migrations/{0007_elite_patriot.sql => 0007_thin_eternals.sql} (78%) diff --git a/db/migrations/0007_elite_patriot.sql b/db/migrations/0007_thin_eternals.sql similarity index 78% rename from db/migrations/0007_elite_patriot.sql rename to db/migrations/0007_thin_eternals.sql index ae1fc15c..be236cb9 100644 --- a/db/migrations/0007_elite_patriot.sql +++ b/db/migrations/0007_thin_eternals.sql @@ -4,9 +4,9 @@ CREATE TABLE "articles" ( "title" text NOT NULL, "description" text NOT NULL, "body" text NOT NULL, - "tag_list" text[] DEFAULT '{}'::text[] NOT NULL, - "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, - "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "tag_list" text[] DEFAULT '{}' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, "author_id" integer NOT NULL, CONSTRAINT "articles_slug_unique" UNIQUE("slug") ); @@ -14,8 +14,8 @@ CREATE TABLE "articles" ( CREATE TABLE "favorite_articles" ( "article_id" integer NOT NULL, "user_id" integer NOT NULL, - "created_at" date DEFAULT CURRENT_DATE NOT NULL, - "updated_at" date DEFAULT CURRENT_DATE NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, CONSTRAINT "favorite_articles_article_id_user_id_pk" PRIMARY KEY("article_id","user_id") ); diff --git a/db/migrations/meta/0007_snapshot.json b/db/migrations/meta/0007_snapshot.json index b7744079..2f21b7ef 100644 --- a/db/migrations/meta/0007_snapshot.json +++ b/db/migrations/meta/0007_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a387bcf4-fc25-4a7c-9467-3f76e9356261", + "id": "842ed756-50d5-4cf1-b4dd-cbd31e340af4", "prevId": "c621d843-d221-4084-a3e3-361c92f1c7bf", "version": "7", "dialect": "postgresql", @@ -43,21 +43,21 @@ "type": "text[]", "primaryKey": false, "notNull": true, - "default": "'{}'::text[]" + "default": "'{}'" }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, - "default": "CURRENT_TIMESTAMP" + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, - "default": "CURRENT_TIMESTAMP" + "default": "now()" }, "author_id": { "name": "author_id", @@ -72,8 +72,12 @@ "name": "articles_author_id_users_id_fk", "tableFrom": "articles", "tableTo": "users", - "columnsFrom": ["author_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -83,7 +87,9 @@ "articles_slug_unique": { "name": "articles_slug_unique", "nullsNotDistinct": false, - "columns": ["slug"] + "columns": [ + "slug" + ] } }, "policies": {}, @@ -108,17 +114,17 @@ }, "created_at": { "name": "created_at", - "type": "date", + "type": "timestamp", "primaryKey": false, "notNull": true, - "default": "CURRENT_DATE" + "default": "now()" }, "updated_at": { "name": "updated_at", - "type": "date", + "type": "timestamp", "primaryKey": false, "notNull": true, - "default": "CURRENT_DATE" + "default": "now()" } }, "indexes": {}, @@ -127,8 +133,12 @@ "name": "favorite_articles_article_id_articles_id_fk", "tableFrom": "favorite_articles", "tableTo": "articles", - "columnsFrom": ["article_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -136,8 +146,12 @@ "name": "favorite_articles_user_id_users_id_fk", "tableFrom": "favorite_articles", "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -145,7 +159,10 @@ "compositePrimaryKeys": { "favorite_articles_article_id_user_id_pk": { "name": "favorite_articles_article_id_user_id_pk", - "columns": ["article_id", "user_id"] + "columns": [ + "article_id", + "user_id" + ] } }, "uniqueConstraints": {}, @@ -190,8 +207,12 @@ "name": "user_follows_followed_id_users_id_fk", "tableFrom": "user_follows", "tableTo": "users", - "columnsFrom": ["followed_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "followed_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -199,8 +220,12 @@ "name": "user_follows_follower_id_users_id_fk", "tableFrom": "user_follows", "tableTo": "users", - "columnsFrom": ["follower_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -208,7 +233,10 @@ "compositePrimaryKeys": { "user_follows_followed_id_follower_id_pk": { "name": "user_follows_followed_id_follower_id_pk", - "columns": ["followed_id", "follower_id"] + "columns": [ + "followed_id", + "follower_id" + ] } }, "uniqueConstraints": {}, @@ -280,12 +308,16 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": ["email"] + "columns": [ + "email" + ] }, "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": ["username"] + "columns": [ + "username" + ] } }, "policies": {}, @@ -304,4 +336,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 67474833..7479aece 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -54,9 +54,9 @@ { "idx": 7, "version": "7", - "when": 1742230377894, - "tag": "0007_elite_patriot", + "when": 1742359518221, + "tag": "0007_thin_eternals", "breakpoints": false } ] -} +} \ No newline at end of file diff --git a/src/articles/articles.model.ts b/src/articles/articles.model.ts index 8072cc93..df24d667 100644 --- a/src/articles/articles.model.ts +++ b/src/articles/articles.model.ts @@ -12,14 +12,14 @@ import { import { users } from '@users/users.model'; export const articles = pgTable('articles', { - id: serial('id').primaryKey().notNull(), + id: serial('id').primaryKey(), slug: text('slug').notNull().unique(), title: text('title').notNull(), description: text('description').notNull(), body: text('body').notNull(), - tagList: text('tag_list').array().default(sql`'{}'::text[]`).notNull(), - createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(), + tagList: text('tag_list').array().default([]).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), authorId: integer('author_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), @@ -45,8 +45,8 @@ export const favoriteArticles = pgTable( userId: integer('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), - createdAt: date('created_at').default(sql`CURRENT_DATE`).notNull(), - updatedAt: date('updated_at').default(sql`CURRENT_DATE`).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (table) => [primaryKey({ columns: [table.articleId, table.userId] })], ); @@ -59,7 +59,7 @@ export const favoriteArticleRelations = relations( references: [articles.id], relationName: 'favoriteArticle', }), - users: one(users, { + user: one(users, { fields: [favoriteArticles.userId], references: [users.id], relationName: 'favoritedBy', From c879e3a550f58452ee1d1b4c0a3ba94dfe611720 Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 11:50:41 +0700 Subject: [PATCH 09/28] Fix db:drop utility --- db/drop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/drop.ts b/db/drop.ts index a46ac9e0..4f4c9c85 100644 --- a/db/drop.ts +++ b/db/drop.ts @@ -21,9 +21,9 @@ try { } if (dbConfig.migrations?.table) { // Clean up migrations - console.log('Dropping migrations table'); + console.log('Dropping migrations table: ', dbConfig.migrations.table); await tx.execute( - sql`DROP TABLE IF EXISTS ${sql.identifier(dbConfig.migrations.table)} CASCADE;`, + sql`DROP TABLE IF EXISTS ${sql.identifier(dbConfig.migrations.schema ?? 'public')}.${sql.identifier(dbConfig.migrations.table)} CASCADE;`, ); } }); From 969bcc8f58a263c829ebd643d6e32b80e23007bf Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 12:26:01 +0700 Subject: [PATCH 10/28] Specify error for optional authentication --- src/auth/auth.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3aeb1cc7..c418bb5a 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -97,7 +97,8 @@ export class AuthService { const user = await this.getUserFromHeaders(headers); return user.id; } catch (error) { - return null; + if (error instanceof AuthenticationError) return null; + throw error; } }; } From d811f95c002012ae211119bb59957381c7d7945a Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 12:27:46 +0700 Subject: [PATCH 11/28] Fix returned article count --- src/articles/articles.repository.ts | 16 +++++++++++----- src/articles/articles.service.ts | 6 +----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/articles/articles.repository.ts b/src/articles/articles.repository.ts index 95874edf..205d4825 100644 --- a/src/articles/articles.repository.ts +++ b/src/articles/articles.repository.ts @@ -5,7 +5,7 @@ import type { import type { Database } from '@/database.providers'; import { userFollows, users } from '@/users/users.model'; import { articles, favoriteArticles } from '@articles/articles.model'; -import { and, arrayContains, desc, eq, sql } from 'drizzle-orm'; +import { and, arrayContains, desc, eq, sql, count } from 'drizzle-orm'; export class ArticlesRepository { constructor(private readonly db: Database) {} @@ -73,7 +73,7 @@ export class ArticlesRepository { .groupBy(articles.id), ); - const results = await this.db + const resultsQuery = this.db .with(authorsWithFollowersCTE, articlesWithLikesCTE) .select({ slug: articles.slug, @@ -100,11 +100,17 @@ export class ArticlesRepository { authorsWithFollowersCTE, eq(authorsWithFollowersCTE.authorId, articles.authorId), ) + .orderBy(desc(articles.createdAt)) + .as('results'); + + + const limitedResults = await this.db.select().from(resultsQuery) .limit(limit) - .offset(offset) - .orderBy(desc(articles.createdAt)); + .offset(offset); + + const resultsCount = await this.db.select({count: count()}).from(resultsQuery); - return results; + return {articles: limitedResults, articlesCount: resultsCount[0].count}; } async findBySlug(slug: string) { diff --git a/src/articles/articles.service.ts b/src/articles/articles.service.ts index 20dd46ec..1975e54a 100644 --- a/src/articles/articles.service.ts +++ b/src/articles/articles.service.ts @@ -28,11 +28,7 @@ export class ArticlesService { }): Promise { const limit = query.limit || 20; const offset = query.offset || 0; - const results = await this.repository.find({ ...query, limit, offset }); - return { - articles: results, - articlesCount: results.length, - }; + return await this.repository.find({ ...query, limit, offset }); } async findBySlug(slug: string) { From 100453bcea164be85ce93c16b8909e6b2a090f23 Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 12:38:49 +0700 Subject: [PATCH 12/28] Fix feed by only returning articles from followed users, run bun fix --- db/migrations/meta/0007_snapshot.json | 64 +++++++-------------------- db/migrations/meta/_journal.json | 2 +- src/articles/articles.plugin.ts | 1 + src/articles/articles.repository.ts | 26 ++++++++--- src/articles/articles.service.ts | 3 +- 5 files changed, 41 insertions(+), 55 deletions(-) diff --git a/db/migrations/meta/0007_snapshot.json b/db/migrations/meta/0007_snapshot.json index 2f21b7ef..5c92e8fd 100644 --- a/db/migrations/meta/0007_snapshot.json +++ b/db/migrations/meta/0007_snapshot.json @@ -72,12 +72,8 @@ "name": "articles_author_id_users_id_fk", "tableFrom": "articles", "tableTo": "users", - "columnsFrom": [ - "author_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["author_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -87,9 +83,7 @@ "articles_slug_unique": { "name": "articles_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -133,12 +127,8 @@ "name": "favorite_articles_article_id_articles_id_fk", "tableFrom": "favorite_articles", "tableTo": "articles", - "columnsFrom": [ - "article_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["article_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -146,12 +136,8 @@ "name": "favorite_articles_user_id_users_id_fk", "tableFrom": "favorite_articles", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -159,10 +145,7 @@ "compositePrimaryKeys": { "favorite_articles_article_id_user_id_pk": { "name": "favorite_articles_article_id_user_id_pk", - "columns": [ - "article_id", - "user_id" - ] + "columns": ["article_id", "user_id"] } }, "uniqueConstraints": {}, @@ -207,12 +190,8 @@ "name": "user_follows_followed_id_users_id_fk", "tableFrom": "user_follows", "tableTo": "users", - "columnsFrom": [ - "followed_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["followed_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -220,12 +199,8 @@ "name": "user_follows_follower_id_users_id_fk", "tableFrom": "user_follows", "tableTo": "users", - "columnsFrom": [ - "follower_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["follower_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -233,10 +208,7 @@ "compositePrimaryKeys": { "user_follows_followed_id_follower_id_pk": { "name": "user_follows_followed_id_follower_id_pk", - "columns": [ - "followed_id", - "follower_id" - ] + "columns": ["followed_id", "follower_id"] } }, "uniqueConstraints": {}, @@ -308,16 +280,12 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] }, "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] } }, "policies": {}, @@ -336,4 +304,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 7479aece..8e3ad403 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -59,4 +59,4 @@ "breakpoints": false } ] -} \ No newline at end of file +} diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts index d15a5dbc..a990a1b7 100644 --- a/src/articles/articles.plugin.ts +++ b/src/articles/articles.plugin.ts @@ -60,6 +60,7 @@ export const articlesPlugin = new Elysia().use(setupArticles).group( currentUserId: await store.authService.getUserIdFromHeader( request.headers, ), + followedAuthors: true, }), { beforeHandle: app.store.authService.requireLogin, diff --git a/src/articles/articles.repository.ts b/src/articles/articles.repository.ts index 205d4825..ac3db2f4 100644 --- a/src/articles/articles.repository.ts +++ b/src/articles/articles.repository.ts @@ -5,7 +5,7 @@ import type { import type { Database } from '@/database.providers'; import { userFollows, users } from '@/users/users.model'; import { articles, favoriteArticles } from '@articles/articles.model'; -import { and, arrayContains, desc, eq, sql, count } from 'drizzle-orm'; +import { and, arrayContains, count, desc, eq, inArray, sql } from 'drizzle-orm'; export class ArticlesRepository { constructor(private readonly db: Database) {} @@ -17,6 +17,7 @@ export class ArticlesRepository { tag, author, favorited, + followedAuthors, }: { currentUserId: number | null; offset: number; @@ -24,11 +25,23 @@ export class ArticlesRepository { tag?: string; author?: string; favorited?: string; + followedAuthors?: boolean; }) { const authorFilters = []; if (author) { authorFilters.push(eq(users.username, author)); } + if (followedAuthors && currentUserId) { + authorFilters.push( + inArray( + users.id, + this.db + .select({ followedAuthors: userFollows.followedId }) + .from(userFollows) + .where(eq(userFollows.followerId, currentUserId)), + ), + ); + } const authorsWithFollowersCTE = this.db.$with('authorsWithFollowers').as( this.db @@ -103,14 +116,17 @@ export class ArticlesRepository { .orderBy(desc(articles.createdAt)) .as('results'); - - const limitedResults = await this.db.select().from(resultsQuery) + const limitedResults = await this.db + .select() + .from(resultsQuery) .limit(limit) .offset(offset); - const resultsCount = await this.db.select({count: count()}).from(resultsQuery); + const resultsCount = await this.db + .select({ count: count() }) + .from(resultsQuery); - return {articles: limitedResults, articlesCount: resultsCount[0].count}; + return { articles: limitedResults, articlesCount: resultsCount[0].count }; } async findBySlug(slug: string) { diff --git a/src/articles/articles.service.ts b/src/articles/articles.service.ts index 1975e54a..44b86a7b 100644 --- a/src/articles/articles.service.ts +++ b/src/articles/articles.service.ts @@ -1,4 +1,5 @@ import { AuthorizationError, BadRequestError } from '@/errors'; +import { slugify } from '@/utils/slugify'; import type { ArticlesRepository } from '@articles/articles.repository'; import type { ArticleInDb, @@ -9,7 +10,6 @@ import type { ReturnedArticleResponse, } from '@articles/articles.schema'; import type { ProfilesService } from '@profiles/profiles.service'; -import { slugify } from '@/utils/slugify'; import { NotFoundError } from 'elysia'; export class ArticlesService { @@ -25,6 +25,7 @@ export class ArticlesService { tag?: string; author?: string; favorited?: string; + followedAuthors?: boolean; }): Promise { const limit = query.limit || 20; const offset = query.offset || 0; From e707eb63d9911c24599bf03870cd34646a908459 Mon Sep 17 00:00:00 2001 From: Hajbo Date: Wed, 19 Mar 2025 12:49:20 +0700 Subject: [PATCH 13/28] Simplifiy update logic --- src/articles/articles.repository.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/articles/articles.repository.ts b/src/articles/articles.repository.ts index ac3db2f4..266f810f 100644 --- a/src/articles/articles.repository.ts +++ b/src/articles/articles.repository.ts @@ -172,19 +172,15 @@ export class ArticlesRepository { article: ArticleToUpdate, currentUserId: number, ) { - const valuesToSet = Object.entries(article).reduce( - (acc: { [key: string]: string | string[] | Date }, [key, value]) => { - if (value !== undefined) { - acc[key] = value; - } - return acc; - }, - {}, + const filteredArticle = Object.fromEntries( + Object.entries(article).filter(([_, value]) => value !== undefined), ); - valuesToSet.updatedAt = new Date(); await this.db .update(articles) - .set(valuesToSet) + .set({ + ...filteredArticle, + updatedAt: new Date(), + }) .where( and(eq(articles.id, articleId), eq(articles.authorId, currentUserId)), ); From 08df5540f37cff43e7b0aea00998a5a1ec16e085 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Thu, 20 Mar 2025 02:05:50 +0500 Subject: [PATCH 14/28] edit taglist --- src/articles/articles.schema.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index b0eba115..e6dfd002 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -23,7 +23,12 @@ export type ArticleToCreate = ArticleToCreateData & { export const UpdateArticleSchema = Type.Object({ article: Type.Partial( - Type.Pick(insertArticleSchemaRaw, ['title', 'description', 'body']), + Type.Pick(insertArticleSchemaRaw, [ + 'title', + 'description', + 'body', + 'tagList', + ]), ), }); From 76b0ec860fde643558a66dd6487e9eade63b5202 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Thu, 20 Mar 2025 02:23:17 +0500 Subject: [PATCH 15/28] fix more weird ai stuff --- src/articles/articles.schema.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index e6dfd002..8ff256df 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -34,25 +34,20 @@ export const UpdateArticleSchema = Type.Object({ export type ArticleToUpdate = Static['article']; -const returnArticleSchemaRaw = Type.Composite([ +export const ReturnedArticleSchema = Type.Composite([ + Type.Omit(selectArticleSchemaRaw, ['id', 'authorId']), Type.Object({ - slug: Type.String(), - title: Type.String(), - description: Type.String(), - body: Type.String(), - tagList: Type.Array(Type.String()), - createdAt: Type.String({ format: 'date-time' }), - updatedAt: Type.String({ format: 'date-time' }), + author: Type.Object({ + username: Type.String(), + bio: Type.String(), + image: Type.String(), + following: Type.Boolean(), + }), favorited: Type.Boolean(), favoritesCount: Type.Number(), }), - Type.Object({ - author: Type.Any(), - }), ]); -export const ReturnedArticleSchema = returnArticleSchemaRaw; - export const ReturnedArticleResponseSchema = Type.Object({ article: ReturnedArticleSchema, }); From 4a706676535efe490b9d894066e7d6c46099eb9c Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Thu, 20 Mar 2025 02:26:02 +0500 Subject: [PATCH 16/28] fix missing types --- src/articles/articles.schema.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index 8ff256df..0c6b01b4 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -32,7 +32,12 @@ export const UpdateArticleSchema = Type.Object({ ), }); -export type ArticleToUpdate = Static['article']; +export type ArticleToUpdateRequest = Static< + typeof UpdateArticleSchema +>['article']; +export type ArticleToUpdate = ArticleToUpdateRequest & { + slug: string; +}; export const ReturnedArticleSchema = Type.Composite([ Type.Omit(selectArticleSchemaRaw, ['id', 'authorId']), @@ -52,6 +57,10 @@ export const ReturnedArticleResponseSchema = Type.Object({ article: ReturnedArticleSchema, }); +export type ReturnedArticleResponse = Static< + typeof ReturnedArticleResponseSchema +>; + export const DeleteArticleResponse = Type.Object({}); export type ReturnedArticle = Static; From b8a055aed612e20c48f1fab1617c1444f3a47ee1 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Thu, 20 Mar 2025 02:30:28 +0500 Subject: [PATCH 17/28] fix DeleteArticleResponse schema --- src/articles/articles.schema.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index 0c6b01b4..1a022138 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -61,7 +61,10 @@ export type ReturnedArticleResponse = Static< typeof ReturnedArticleResponseSchema >; -export const DeleteArticleResponse = Type.Object({}); +export const DeleteArticleResponse = Type.Object({ + message: Type.String(), + slug: Type.String(), +}); export type ReturnedArticle = Static; From 23ec6aa666686737ade1a6ad42aa1541b6e84f9b Mon Sep 17 00:00:00 2001 From: Hajbo <35660161+Hajbo@users.noreply.github.com> Date: Thu, 20 Mar 2025 03:39:49 +0100 Subject: [PATCH 18/28] Add article CRUD endpoints and some others (#129) - Article models and related migrations - Article endpoints - create - update - delete - get by slug - list - feed - Fix drop utility by dropping migrations table as well - Swagger id token auth. This closes #107 as well. That PR didn't work out of the box (it uses an invalid token format, Bearer instead of Token as per specified in the [docs](https://realworld-docs.netlify.app/specifications/backend/endpoints/)), but it gave the inspiration. - [x] Read the [CONTRIBUTING]( https://github.com/agnyz/bedstack/blob/main/CONTRIBUTING.md) guide - [x] Title this PR according to the `type(scope): description` or `type: description` format - [x] Provide description sufficient to understand the changes introduced in this PR, and, if necessary, some screenshots - [x] Reference an issue or discussion where the feature or changes have been previously discussed - [x] Add a failing test that passes with the changes introduced in this PR, or explain why it's not feasible - [x] Add documentation for the feature or changes introduced in this PR to the docs; you can run them with `bun docs` - **New Features** - Introduced full article management capabilities, including endpoints for listing, creating, updating, deleting articles, and fetching personalized feeds. - Enhanced API security with token authentication and persistent authorization. - Expanded the database to support new content types and user interactions, such as published and favorited articles. - Improved profile response handling to better reflect user following status. - Added a utility function for generating URL-friendly slugs. - Added a new configuration for database migrations and expanded the database schema to include articles and favorite articles. - **Bug Fixes** - Adjusted error handling in profile response generation to accommodate cases with no current user ID. --- src/articles/articles.schema.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/articles/articles.schema.ts b/src/articles/articles.schema.ts index 1a022138..077d5920 100644 --- a/src/articles/articles.schema.ts +++ b/src/articles/articles.schema.ts @@ -1,7 +1,7 @@ -import { articles, type favoriteArticles } from '@articles/articles.model'; import type { Profile } from '@profiles/profiles.schema'; import { type Static, Type } from '@sinclair/typebox'; import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; +import { articles, type favoriteArticles } from './articles.model'; export const insertArticleSchemaRaw = createInsertSchema(articles); export const selectArticleSchemaRaw = createSelectSchema(articles); @@ -57,17 +57,11 @@ export const ReturnedArticleResponseSchema = Type.Object({ article: ReturnedArticleSchema, }); +export type ReturnedArticle = Static; export type ReturnedArticleResponse = Static< typeof ReturnedArticleResponseSchema >; -export const DeleteArticleResponse = Type.Object({ - message: Type.String(), - slug: Type.String(), -}); - -export type ReturnedArticle = Static; - export type ArticleInDb = Omit< typeof articles.$inferSelect, 'id' | 'authorId' @@ -82,7 +76,6 @@ export const ArticleFeedQuerySchema = Type.Object({ limit: Type.Optional(Type.Number({ minimum: 1, default: 20 })), offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })), }); - export const ListArticlesQuerySchema = Type.Composite([ ArticleFeedQuerySchema, Type.Object({ @@ -98,3 +91,8 @@ export const ReturnedArticleListSchema = Type.Object({ }); export type ReturnedArticleList = Static; + +export const DeleteArticleResponse = Type.Object({ + message: Type.String(), + slug: Type.String(), +}); From a6cc3feb609fe196b175ea5552cc01a6d4229f08 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 00:16:09 +0500 Subject: [PATCH 19/28] fix dates --- src/articles/comments/comments.schema.ts | 36 ++++++++++++++++------- src/articles/comments/comments.service.ts | 8 ++--- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/articles/comments/comments.schema.ts b/src/articles/comments/comments.schema.ts index bc7ac038..5daacc1e 100644 --- a/src/articles/comments/comments.schema.ts +++ b/src/articles/comments/comments.schema.ts @@ -1,4 +1,10 @@ +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({ @@ -11,22 +17,32 @@ export type CommentToCreate = Static['comment'] & { articleId: number; }; -export const CommentSchema = Type.Object({ - id: Type.Number(), - body: Type.String(), - createdAt: Type.String({ format: 'date-time' }), - updatedAt: Type.String({ format: 'date-time' }), - author: Type.Any(), -}); +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 type ReturnedComment = Static; export const ReturnedCommentResponse = Type.Object({ - comment: CommentSchema, + comment: ReturnedCommentSchema, }); export const ReturnedCommentsResponse = Type.Object({ - comments: Type.Array(CommentSchema), + 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 index 92dbde31..f352940c 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -38,8 +38,8 @@ export class CommentsService { return { id: comment.id, body: comment.body, - createdAt: comment.createdAt.toISOString(), - updatedAt: comment.updatedAt.toISOString(), + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, author: authorProfile.profile, }; } @@ -69,8 +69,8 @@ export class CommentsService { return { id: comment.id, body: comment.body, - createdAt: comment.createdAt.toISOString(), - updatedAt: comment.updatedAt.toISOString(), + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, author: authorProfile.profile, }; }), From 91447f6b740a9a375bf08b7fc11d88b9ceedd76f Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 00:29:14 +0500 Subject: [PATCH 20/28] Refactor comment author retrieval and add findByUserId method - Updated CommentsService to use authorId directly when fetching author profile. - Introduced findByUserId method in ProfilesRepository to retrieve user profiles by their ID. - Enhanced ProfilesService with findByUserId method for better error handling and profile response generation. --- src/articles/comments/comments.service.ts | 5 ++--- src/profiles/profiles.repository.ts | 11 +++++++++++ src/profiles/profiles.service.ts | 8 ++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index f352940c..b57e515b 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -29,10 +29,9 @@ export class CommentsService { }; const comment = await this.commentsRepository.create(commentData); - const authorUsername = await this.getAuthorUsername(comment.authorId); - const authorProfile = await this.profilesService.findByUsername( + const authorProfile = await this.profilesService.findByUserId( userId, - authorUsername, + comment.authorId, ); return { 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..3ffb40bd 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, 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); From 83ab1c44d3e713dafb9e248a317f48f1920e7ebd Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 00:33:39 +0500 Subject: [PATCH 21/28] Enhance comment retrieval with author details - Updated the findById method in CommentsRepository to include author information such as id, username, bio, and image. - Added the ability to fetch the author's followers alongside their details. --- src/articles/comments/comments.repository.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts index fc5823d3..0cf45461 100644 --- a/src/articles/comments/comments.repository.ts +++ b/src/articles/comments/comments.repository.ts @@ -18,6 +18,19 @@ export class CommentsRepository { async findById(id: number) { const result = await this.db.query.comments.findFirst({ where: eq(comments.id, id), + with: { + author: { + columns: { + id: true, + username: true, + bio: true, + image: true, + }, + with: { + followers: true, + }, + }, + }, }); return result; } From 41ee8145e55de131a5b5703d190bcbbb02b53e95 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 00:36:02 +0500 Subject: [PATCH 22/28] Refactor comment author profile retrieval in CommentsService - Updated CommentsService to directly use authorId when fetching author profiles, improving clarity and efficiency. - Replaced the getAuthorUsername method with a direct call to findByUserId in ProfilesService. --- src/articles/comments/comments.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index b57e515b..8ae3b2ca 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -59,10 +59,9 @@ export class CommentsService { const returnedComments = await Promise.all( comments.map(async (comment) => { - const authorUsername = await this.getAuthorUsername(comment.authorId); - const authorProfile = await this.profilesService.findByUsername( + const authorProfile = await this.profilesService.findByUserId( currentUserId || 0, - authorUsername, + comment.authorId, ); return { From 1cd32543143a41c14f4d69a8470232c7ebce768b Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 00:40:48 +0500 Subject: [PATCH 23/28] remove unused method --- src/articles/comments/comments.service.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index 8ae3b2ca..5e43b640 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -108,12 +108,4 @@ export class CommentsService { await this.commentsRepository.delete(commentId, userId); } - - private async getAuthorUsername(userId: number): Promise { - const user = await this.usersRepository.findById(userId); - if (!user) { - throw new BadRequestError(`User with id ${userId} not found`); - } - return user.username; - } } From f8e7faa7f41b5d45624bbbc9a8e4ec959a064ea3 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 01:22:35 +0500 Subject: [PATCH 24/28] Enhance CommentsService and ProfilesService with improved type handling and documentation - Added JSDoc comments to the getComments method in CommentsService for better clarity on parameters and return values. - Updated the findByUserId method in ProfilesService to accept a nullable currentUserId, improving type safety. --- src/articles/comments/comments.service.ts | 8 +++++++- src/profiles/profiles.service.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index 5e43b640..e929721d 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -43,6 +43,12 @@ export class CommentsService { }; } + /** + * 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, @@ -60,7 +66,7 @@ export class CommentsService { const returnedComments = await Promise.all( comments.map(async (comment) => { const authorProfile = await this.profilesService.findByUserId( - currentUserId || 0, + currentUserId ?? null, comment.authorId, ); diff --git a/src/profiles/profiles.service.ts b/src/profiles/profiles.service.ts index 3ffb40bd..01bfea60 100644 --- a/src/profiles/profiles.service.ts +++ b/src/profiles/profiles.service.ts @@ -17,7 +17,7 @@ export class ProfilesService { return await this.generateProfileResponse(user, currentUserId); } - async findByUserId(currentUserId: number, targetUserId: number) { + async findByUserId(currentUserId: number | null, targetUserId: number) { const user = await this.repository.findByUserId(targetUserId); if (!user) { throw new NotFoundError('Profile not found'); From 18114bd7713614d4203b44415ba0cc95a9bf052c Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 12:07:36 +0500 Subject: [PATCH 25/28] Refactor CommentsRepository and CommentsService for enhanced comment retrieval - Add a new method in CommentsRepository to find comments by article ID, optimized to include author details and their followers. - Simplify comment mapping in CommentsService to directly include author information, greatly improving performance from O(n*(k+t)) to O(n). --- src/articles/comments/comments.repository.ts | 24 +++++++++++++-- src/articles/comments/comments.service.ts | 32 +++++++++----------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts index 0cf45461..017903eb 100644 --- a/src/articles/comments/comments.repository.ts +++ b/src/articles/comments/comments.repository.ts @@ -1,7 +1,6 @@ -import { and, desc, eq } from 'drizzle-orm'; - 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 { @@ -35,10 +34,31 @@ export class CommentsRepository { 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; } diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index e929721d..70773cd3 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -63,24 +63,20 @@ export class CommentsService { article.id, ); - const returnedComments = await Promise.all( - comments.map(async (comment) => { - const authorProfile = await this.profilesService.findByUserId( - currentUserId ?? null, - comment.authorId, - ); - - return { - id: comment.id, - body: comment.body, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - author: authorProfile.profile, - }; - }), - ); - - return returnedComments; + 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( From 29923e7191fc08e81e9a9d5dd54928146848ddea Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 12:09:36 +0500 Subject: [PATCH 26/28] Simplify findById method in CommentsRepository Doing this since we no longer need the extra fetching (no use case). - Remove unnecessary author details fetching in the findById method. - Add JSDoc. --- src/articles/comments/comments.repository.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/articles/comments/comments.repository.ts b/src/articles/comments/comments.repository.ts index 017903eb..ffc2435a 100644 --- a/src/articles/comments/comments.repository.ts +++ b/src/articles/comments/comments.repository.ts @@ -14,22 +14,14 @@ export class CommentsRepository { 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), - with: { - author: { - columns: { - id: true, - username: true, - bio: true, - image: true, - }, - with: { - followers: true, - }, - }, - }, }); return result; } From dd9b96e6ae728f67e0206891deecc0221d63ff1e Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 12:17:07 +0500 Subject: [PATCH 27/28] Update error handling in CommentsService to use NotFoundError - Replaced BadRequestError with NotFoundError when an article is not found by slug, improving clarity in error reporting. --- src/articles/comments/comments.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index 70773cd3..94d49897 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -1,6 +1,7 @@ import { AuthorizationError, BadRequestError } from '@errors'; import type { ProfilesService } from '@profiles/profiles.service'; import type { UsersRepository } from '@users/users.repository'; +import { NotFoundError } from 'elysia'; import type { CommentsRepository } from './comments.repository'; import type { CommentToCreate, ReturnedComment } from './comments.schema'; @@ -19,7 +20,7 @@ export class CommentsService { const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { - throw new BadRequestError(`Article with slug ${articleSlug} not found`); + throw new NotFoundError(`Article with slug ${articleSlug} not found`); } const commentData: CommentToCreate = { From 581e9db130c7a41f2999486f38cf46680f589803 Mon Sep 17 00:00:00 2001 From: Yam Borodetsky Date: Fri, 21 Mar 2025 12:20:48 +0500 Subject: [PATCH 28/28] Update error handling in CommentsService to throw NotFoundError for missing articles - Replaced BadRequestError with NotFoundError when an article is not found by slug, enhancing clarity in error reporting. --- src/articles/comments/comments.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/articles/comments/comments.service.ts b/src/articles/comments/comments.service.ts index 94d49897..0dea9753 100644 --- a/src/articles/comments/comments.service.ts +++ b/src/articles/comments/comments.service.ts @@ -1,6 +1,5 @@ import { AuthorizationError, BadRequestError } from '@errors'; import type { ProfilesService } from '@profiles/profiles.service'; -import type { UsersRepository } from '@users/users.repository'; import { NotFoundError } from 'elysia'; import type { CommentsRepository } from './comments.repository'; import type { CommentToCreate, ReturnedComment } from './comments.schema'; @@ -9,7 +8,6 @@ export class CommentsService { constructor( private readonly commentsRepository: CommentsRepository, private readonly profilesService: ProfilesService, - private readonly usersRepository: UsersRepository, ) {} async createComment( @@ -57,7 +55,7 @@ export class CommentsService { const article = await this.commentsRepository.findBySlug(articleSlug); if (!article) { - throw new BadRequestError(`Article with slug ${articleSlug} not found`); + throw new NotFoundError(`Article with slug ${articleSlug} not found`); } const comments = await this.commentsRepository.findManyByArticleId(