From 8aff6511c82eb7358729e11938a007a8fff2d6a3 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Wed, 22 Apr 2026 10:50:45 +0700 Subject: [PATCH 01/14] test: add failing test for CI --- .github/workflows/cd.yml | 32 ++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ tests/ci.test.js | 5 +++++ 3 files changed, 75 insertions(+) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml create mode 100644 tests/ci.test.js diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..289e5c4 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,32 @@ +name: Continuous Deployment + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy to Server via SSH + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + port: 22 + script: | + cd forum-api + + echo "Pull latest code..." + git pull origin main + + echo "Install dependencies..." + npm install + + echo "Run migration..." + npm run migrate:prod + + echo "Restart app..." + pm2 restart forum-api || pm2 start npm --name "forum-api" -- start \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd30d03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: Continuous Integration + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: forumapi_test + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Install dependencies + run: npm install + + - name: Run migration + run: npm run migrate:test + + - name: Run test + run: npm test \ No newline at end of file diff --git a/tests/ci.test.js b/tests/ci.test.js new file mode 100644 index 0000000..d53fc1a --- /dev/null +++ b/tests/ci.test.js @@ -0,0 +1,5 @@ +describe('CI Scenario - Fail First', () => { + it('should fail intentionally', () => { + expect(1 + 1).toBe(3); // ❌ sengaja salah + }); +}); \ No newline at end of file From 609c73a59e52208377bd9c7f0d495a044bf059e6 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Wed, 22 Apr 2026 10:56:37 +0700 Subject: [PATCH 02/14] fix: correct failing test --- tests/ci.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ci.test.js b/tests/ci.test.js index d53fc1a..582a435 100644 --- a/tests/ci.test.js +++ b/tests/ci.test.js @@ -1,5 +1,5 @@ -describe('CI Scenario - Fail First', () => { - it('should fail intentionally', () => { - expect(1 + 1).toBe(3); // ❌ sengaja salah +describe('CI Scenario - Fix', () => { + it('should pass after fix', () => { + expect(1 + 1).toBe(2); // ✅ benar }); }); \ No newline at end of file From 5dd11213bcd669a40a2414d41a1175d011661a04 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Wed, 22 Apr 2026 11:12:51 +0700 Subject: [PATCH 03/14] fix: setup CI postgres and migration --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd30d03..f6a2ed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,18 @@ jobs: POSTGRES_DB: forumapi_test ports: - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + PGHOST: localhost + PGUSER: postgres + PGPASSWORD: password + PGDATABASE: forumapi_test + PGPORT: 5432 steps: - name: Checkout code @@ -31,6 +43,14 @@ jobs: - name: Install dependencies run: npm install + - name: Wait for PostgreSQL + run: | + for i in {1..10}; do + pg_isready -h localhost -p 5432 && break + echo "Waiting for postgres..." + sleep 2 + done + - name: Run migration run: npm run migrate:test From 2d1a7e97f11a600d13087e233339937a9c59d7f2 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Wed, 22 Apr 2026 11:26:12 +0700 Subject: [PATCH 04/14] fix: db config --- config/database/process.env | 7 +++++++ src/Commons/config.js | 9 +-------- 2 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 config/database/process.env diff --git a/config/database/process.env b/config/database/process.env new file mode 100644 index 0000000..29dbe9d --- /dev/null +++ b/config/database/process.env @@ -0,0 +1,7 @@ +const config = { + user: process.env.PGUSER, + password: process.env.PGPASSWORD, + host: process.env.PGHOST, + port: process.env.PGPORT, + database: process.env.PGDATABASE, +}; \ No newline at end of file diff --git a/src/Commons/config.js b/src/Commons/config.js index 70df9e4..117f6f4 100644 --- a/src/Commons/config.js +++ b/src/Commons/config.js @@ -1,14 +1,7 @@ /* istanbul ignore file */ import dotenv from 'dotenv'; -import path from 'path'; -if (process.env.NODE_ENV === 'test') { - dotenv.config({ - path: path.resolve(process.cwd(), '.test.env'), - }); -} else { - dotenv.config(); -} +dotenv.config(); const config = { app: { From 40312c2036603ca3e5209936898d62e6e2202418 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Wed, 22 Apr 2026 11:29:44 +0700 Subject: [PATCH 05/14] fix: correct migration command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ba201fa..3d0e2fd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", "migrate": "node-pg-migrate", - "migrate:test": "node-pg-migrate --envPath .test.env", + "migrate:test": "node-pg-migrate up", "lint": "eslint ./" }, "keywords": [], From e828c0cb850bb3d481052518aa4038d2736ed132 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Wed, 22 Apr 2026 12:46:06 +0700 Subject: [PATCH 06/14] Specify 'up' for migrate npm script Update package.json migrate script to run `node-pg-migrate up` instead of invoking the binary without a subcommand. This ensures the npm script applies migrations (consistent with migrate:test) when executed. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d0e2fd..d211447 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test": "vitest --run", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", - "migrate": "node-pg-migrate", + "migrate": "node-pg-migrate up", "migrate:test": "node-pg-migrate up", "lint": "eslint ./" }, From 9eb2c86fffffa1a889e1f7fd38e0cba6d13f16b1 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 05:22:35 +0700 Subject: [PATCH 07/14] Add comment like feature and integrate likes Implement comment-like functionality across the stack: add a DB migration to create user_comment_likes with FK constraints and a uniqueness constraint; introduce UserCommentLikeRepository interface and Postgres implementation (add/verify/delete like). Add ToggleLikeCommentUseCase to toggle likes after verifying thread/comment existence. Expose a new HTTP endpoint (PUT /threads/:threadId/comments/:commentId/likes) with handler, route, and JWT auth middleware. Update ThreadRepositoryPostgres to fetch comments with aggregated like counts and return comments as part of the thread, and adjust GetThreadDetailUseCase to use thread.comments and include likeCount. Update DetailComment entity and its tests to require and validate likeCount. Also rename error middleware path to Interfaces/http/middlewares/errorMiddleware.js. --- ...2637934_create-table-user-comment-likes.js | 54 +++++++++++++++++++ .../use_case/GetThreadDetailUseCase.js | 4 +- .../use_case/ToggleLikeCommentUseCase.js | 36 +++++++++++++ .../_test/GetThreadDetailUseCase.test.js | 47 ++++++++-------- .../comments/entities/DetailComment.js | 8 +-- .../entities/_test/DetailComment.test.js | 20 ++++++- src/Domains/threads/entities/DetailThread.js | 9 +++- .../UserCommentLikeRepository.js | 16 ++++++ .../repository/ThreadRepositoryPostgres.js | 40 +++++++++++++- .../UserCommentLikeRepositoryPostgres.js | 51 ++++++++++++++++++ .../http/api/comments/likes/handler.js | 32 +++++++++++ .../http/api/comments/likes/index.js | 5 ++ .../http/api/comments/likes/routes.js | 18 +++++++ .../http/middlewares/authMiddleware.js | 39 ++++++++++++++ .../http/middlewares}/errorMiddleware.js | 0 15 files changed, 346 insertions(+), 33 deletions(-) create mode 100644 migrations/1776882637934_create-table-user-comment-likes.js create mode 100644 src/Applications/use_case/ToggleLikeCommentUseCase.js create mode 100644 src/Domains/userCommentLikes/UserCommentLikeRepository.js create mode 100644 src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js create mode 100644 src/Interfaces/http/api/comments/likes/handler.js create mode 100644 src/Interfaces/http/api/comments/likes/index.js create mode 100644 src/Interfaces/http/api/comments/likes/routes.js create mode 100644 src/Interfaces/http/middlewares/authMiddleware.js rename src/{Infrastructures/http/_middleware => Interfaces/http/middlewares}/errorMiddleware.js (100%) diff --git a/migrations/1776882637934_create-table-user-comment-likes.js b/migrations/1776882637934_create-table-user-comment-likes.js new file mode 100644 index 0000000..e71ff88 --- /dev/null +++ b/migrations/1776882637934_create-table-user-comment-likes.js @@ -0,0 +1,54 @@ +export const up = (pgm) => { + pgm.createTable('user_comment_likes', { + id: { + type: 'VARCHAR(50)', + primaryKey: true, + }, + // eslint-disable-next-line camelcase + user_id: { + type: 'VARCHAR(50)', + notNull: true, + }, + // eslint-disable-next-line camelcase + comment_id: { + type: 'VARCHAR(50)', + notNull: true, + }, + }); + + pgm.addConstraint( + 'user_comment_likes', + 'fk_user_comment_likes.user_id_users.id', + { + foreignKeys: { + columns: 'user_id', + references: 'users(id)', + onDelete: 'CASCADE', + }, + } + ); + + pgm.addConstraint( + 'user_comment_likes', + 'fk_user_comment_likes.comment_id_comments.id', + { + foreignKeys: { + columns: 'comment_id', + references: 'comments(id)', + onDelete: 'CASCADE', + }, + } + ); + + pgm.addConstraint( + 'user_comment_likes', + 'unique_user_comment_like', + { + unique: ['user_id', 'comment_id'], + } + ); +}; + +export const down = (pgm) => { + pgm.dropTable('user_comment_likes'); +}; \ No newline at end of file diff --git a/src/Applications/use_case/GetThreadDetailUseCase.js b/src/Applications/use_case/GetThreadDetailUseCase.js index d5d5011..c568b98 100644 --- a/src/Applications/use_case/GetThreadDetailUseCase.js +++ b/src/Applications/use_case/GetThreadDetailUseCase.js @@ -18,8 +18,7 @@ class GetThreadDetailUseCase { const thread = await this._threadRepository.getThreadById(threadId); - const comments = - (await this._commentRepository.getCommentsByThreadId(threadId)) || []; + const comments = thread.comments || []; const commentIds = comments.map((c) => c.id); @@ -55,6 +54,7 @@ class GetThreadDetailUseCase { date: this._normalizeDate(comment.date), content: comment.content, isDelete: comment.is_delete, + likeCount: comment.likeCount, replies: mappedReplies, }); }); diff --git a/src/Applications/use_case/ToggleLikeCommentUseCase.js b/src/Applications/use_case/ToggleLikeCommentUseCase.js new file mode 100644 index 0000000..1aa6b9a --- /dev/null +++ b/src/Applications/use_case/ToggleLikeCommentUseCase.js @@ -0,0 +1,36 @@ +class ToggleLikeCommentUseCase { + constructor({ + userCommentLikeRepository, + commentRepository, + threadRepository, + }) { + this._userCommentLikeRepository = userCommentLikeRepository; + this._commentRepository = commentRepository; + this._threadRepository = threadRepository; + } + + async execute({ userId, threadId, commentId }) { + await this._threadRepository.verifyThreadExists(threadId); + + await this._commentRepository.verifyCommentExists(commentId); + + const isLiked = await this._userCommentLikeRepository.verifyLike( + userId, + commentId + ); + + if (isLiked) { + await this._userCommentLikeRepository.deleteLike( + userId, + commentId + ); + } else { + await this._userCommentLikeRepository.addLike( + userId, + commentId + ); + } + } +} + +export default ToggleLikeCommentUseCase; \ No newline at end of file diff --git a/src/Applications/use_case/_test/GetThreadDetailUseCase.test.js b/src/Applications/use_case/_test/GetThreadDetailUseCase.test.js index 683c300..8c12aaf 100644 --- a/src/Applications/use_case/_test/GetThreadDetailUseCase.test.js +++ b/src/Applications/use_case/_test/GetThreadDetailUseCase.test.js @@ -14,30 +14,29 @@ describe('GetThreadDetailUseCase', () => { body: 'body', date: new Date().toISOString(), username: 'john', + comments: [ + { + id: 'comment-1', + content: 'comment 1', + date: new Date().toISOString(), + username: 'john', + // eslint-disable-next-line camelcase + is_delete: false, + likeCount: 1, + }, + { + id: 'comment-2', + content: 'comment 2', + date: new Date().toISOString(), + username: 'doe', + // eslint-disable-next-line camelcase + is_delete: true, + likeCount: 0, + }, + ], }), }; - const mockCommentRepo = { - getCommentsByThreadId: vi.fn().mockResolvedValue([ - { - id: 'comment-1', - content: 'comment 1', - date: new Date().toISOString(), - username: 'john', - // eslint-disable-next-line camelcase - is_delete: false, - }, - { - id: 'comment-2', - content: 'comment 2', - date: new Date().toISOString(), - username: 'doe', - // eslint-disable-next-line camelcase - is_delete: true, - }, - ]), - }; - const mockReplyRepo = { getRepliesByCommentIds: vi.fn().mockResolvedValue([ { @@ -63,7 +62,6 @@ describe('GetThreadDetailUseCase', () => { const useCase = new GetThreadDetailUseCase({ threadRepository: mockThreadRepo, - commentRepository: mockCommentRepo, replyRepository: mockReplyRepo, }); @@ -79,9 +77,6 @@ describe('GetThreadDetailUseCase', () => { expect(mockThreadRepo.getThreadById) .toHaveBeenCalledWith(threadId); - expect(mockCommentRepo.getCommentsByThreadId) - .toHaveBeenCalledWith(threadId); - expect(mockReplyRepo.getRepliesByCommentIds) .toHaveBeenCalledWith(['comment-1', 'comment-2']); @@ -98,6 +93,7 @@ describe('GetThreadDetailUseCase', () => { username: 'john', date: normalized.comments[0].date, content: 'comment 1', + likeCount: 1, replies: [ { id: 'reply-1', @@ -118,6 +114,7 @@ describe('GetThreadDetailUseCase', () => { username: 'doe', date: normalized.comments[1].date, content: '**komentar telah dihapus**', + likeCount: 0, replies: [], }, ], diff --git a/src/Domains/comments/entities/DetailComment.js b/src/Domains/comments/entities/DetailComment.js index 8460b1c..02b3ea8 100644 --- a/src/Domains/comments/entities/DetailComment.js +++ b/src/Domains/comments/entities/DetailComment.js @@ -2,17 +2,18 @@ class DetailComment { constructor(payload) { this._verify(payload); - const { id, username, date, content, isDelete, replies } = payload; + const { id, username, date, content, isDelete, likeCount, replies } = payload; this.id = id; this.username = username; this.date = date; this.content = isDelete ? '**komentar telah dihapus**' : content; + this.likeCount = likeCount; this.replies = replies; } - _verify({ id, username, date, content, isDelete, replies }) { - if (id === undefined || username === undefined || date === undefined || content === undefined || isDelete === undefined || replies === undefined) { + _verify({ id, username, date, content, isDelete, likeCount, replies }) { + if (id === undefined || username === undefined || date === undefined || content === undefined || isDelete === undefined || likeCount === undefined || replies === undefined) { throw new Error('DETAIL_COMMENT.NOT_CONTAIN_NEEDED_PROPERTY'); } @@ -21,6 +22,7 @@ class DetailComment { typeof date !== 'string' || typeof content !== 'string' || typeof isDelete !== 'boolean' || + typeof likeCount !== 'number' || !Array.isArray(replies)) { throw new Error('DETAIL_COMMENT.NOT_MEET_DATA_TYPE_SPECIFICATION'); } diff --git a/src/Domains/comments/entities/_test/DetailComment.test.js b/src/Domains/comments/entities/_test/DetailComment.test.js index 84fce37..8260866 100644 --- a/src/Domains/comments/entities/_test/DetailComment.test.js +++ b/src/Domains/comments/entities/_test/DetailComment.test.js @@ -22,7 +22,8 @@ describe('DetailComment entity', () => { date: '2024-01-01', content: 'comment', isDelete: false, - replies: 'not-array', // wrong + likeCount: 0, + replies: 'not-array', }; expect(() => new DetailComment(payload)) @@ -36,6 +37,7 @@ describe('DetailComment entity', () => { date: '2024-01-01', content: 'comment', isDelete: false, + likeCount: 0, replies: [], }; @@ -52,6 +54,7 @@ describe('DetailComment entity', () => { date: '2024-01-01', content: 'comment', isDelete: true, + likeCount: 0, replies: [], }; @@ -60,4 +63,19 @@ describe('DetailComment entity', () => { expect(detailComment.content) .toBe('**komentar telah dihapus**'); }); + + it('should throw error when likeCount is not number', () => { + const payload = { + id: 'comment-1', + username: 'john', + date: '2024-01-01', + content: 'comment', + isDelete: false, + likeCount: '2', + replies: [], + }; + + expect(() => new DetailComment(payload)) + .toThrowError('DETAIL_COMMENT.NOT_MEET_DATA_TYPE_SPECIFICATION'); + }); }); \ No newline at end of file diff --git a/src/Domains/threads/entities/DetailThread.js b/src/Domains/threads/entities/DetailThread.js index 5f3574c..103c7d2 100644 --- a/src/Domains/threads/entities/DetailThread.js +++ b/src/Domains/threads/entities/DetailThread.js @@ -15,7 +15,14 @@ class DetailThread { _verifyPayload(payload) { const { id, title, body, date, username, comments } = payload; - if (!id || !title || !body || !date || !username || !comments) { + if ( + id === undefined || + title === undefined || + body === undefined || + date === undefined || + username === undefined || + comments === undefined + ) { throw new Error('DETAIL_THREAD.NOT_CONTAIN_NEEDED_PROPERTY'); } diff --git a/src/Domains/userCommentLikes/UserCommentLikeRepository.js b/src/Domains/userCommentLikes/UserCommentLikeRepository.js new file mode 100644 index 0000000..08448cb --- /dev/null +++ b/src/Domains/userCommentLikes/UserCommentLikeRepository.js @@ -0,0 +1,16 @@ +/* eslint-disable no-unused-vars */ +class UserCommentLikeRepository { + async verifyLike(userId, commentId) { + throw new Error('USER_COMMENT_LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } + + async addLike(userId, commentId) { + throw new Error('USER_COMMENT_LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } + + async deleteLike(userId, commentId) { + throw new Error('USER_COMMENT_LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } +} + +export default UserCommentLikeRepository; \ No newline at end of file diff --git a/src/Infrastructures/repository/ThreadRepositoryPostgres.js b/src/Infrastructures/repository/ThreadRepositoryPostgres.js index e309361..bd0fc1b 100644 --- a/src/Infrastructures/repository/ThreadRepositoryPostgres.js +++ b/src/Infrastructures/repository/ThreadRepositoryPostgres.js @@ -56,7 +56,45 @@ class ThreadRepositoryPostgres extends ThreadRepository { throw new NotFoundError('THREAD.NOT_FOUND'); } - return rows[0]; + const thread = rows[0]; + + const commentsResult = await this._pool.query({ + text: ` + SELECT + c.id, + c.content, + c.date, + c.is_delete, + u.username, + ( + SELECT COUNT(*) + FROM user_comment_likes l + WHERE l.comment_id = c.id + ) AS like_count + FROM comments c + LEFT JOIN users u ON u.id = c.owner + WHERE c.thread_id = $1 + ORDER BY c.date ASC + `, + values: [threadId], + }); + + const comments = commentsResult.rows.map((comment) => ({ + id: comment.id, + username: comment.username, + date: comment.date, + content: comment.is_delete + ? '**komentar telah dihapus**' + : comment.content, + // eslint-disable-next-line camelcase + is_delete: comment.is_delete, + likeCount: Number(comment.like_count) || 0 + })); + + return { + ...thread, + comments, + }; } } diff --git a/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js b/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js new file mode 100644 index 0000000..fee476a --- /dev/null +++ b/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js @@ -0,0 +1,51 @@ +import UserCommentLikeRepository from '../../Domains/userCommentLikes/UserCommentLikeRepository.js'; + +class UserCommentLikeRepositoryPostgres extends UserCommentLikeRepository { + constructor(pool, idGenerator) { + super(); + this._pool = pool; + this._idGenerator = idGenerator; + } + + async verifyLike(userId, commentId) { + const query = { + text: ` + SELECT 1 + FROM user_comment_likes + WHERE user_id = $1 AND comment_id = $2 + `, + values: [userId, commentId], + }; + + const result = await this._pool.query(query); + return result.rowCount > 0; + } + + async addLike(userId, commentId) { + const id = `like-${this._idGenerator()}`; + + const query = { + text: ` + INSERT INTO user_comment_likes (id, user_id, comment_id) + VALUES ($1, $2, $3) + `, + values: [id, userId, commentId], + }; + + await this._pool.query(query); + } + + async deleteLike(userId, commentId) { + const query = { + text: ` + DELETE FROM user_comment_likes + WHERE user_id = $1 AND comment_id = $2 + `, + values: [userId, commentId], + }; + + await this._pool.query(query); + } +} + +export default UserCommentLikeRepositoryPostgres; \ No newline at end of file diff --git a/src/Interfaces/http/api/comments/likes/handler.js b/src/Interfaces/http/api/comments/likes/handler.js new file mode 100644 index 0000000..8a44f7d --- /dev/null +++ b/src/Interfaces/http/api/comments/likes/handler.js @@ -0,0 +1,32 @@ +class LikeCommentHandler { + constructor(container) { + this._container = container; + + this.putLikeCommentHandler = this.putLikeCommentHandler.bind(this); + } + + async putLikeCommentHandler(req, res, next) { + try { + const userId = req.user.id; + const { threadId, commentId } = req.params; + + const toggleLikeCommentUseCase = this._container.getInstance( + 'ToggleLikeCommentUseCase' + ); + + await toggleLikeCommentUseCase.execute({ + userId, + threadId, + commentId, + }); + + return res.status(200).json({ + status: 'success', + }); + } catch (error) { + next(error); + } + } +} + +export default LikeCommentHandler; \ No newline at end of file diff --git a/src/Interfaces/http/api/comments/likes/index.js b/src/Interfaces/http/api/comments/likes/index.js new file mode 100644 index 0000000..38a9fd3 --- /dev/null +++ b/src/Interfaces/http/api/comments/likes/index.js @@ -0,0 +1,5 @@ +import routes from './routes.js'; + +const likesRoutes = (container) => routes(container); + +export default likesRoutes; \ No newline at end of file diff --git a/src/Interfaces/http/api/comments/likes/routes.js b/src/Interfaces/http/api/comments/likes/routes.js new file mode 100644 index 0000000..2ceed30 --- /dev/null +++ b/src/Interfaces/http/api/comments/likes/routes.js @@ -0,0 +1,18 @@ +import express from 'express'; +import LikeCommentHandler from './handler.js'; +import authMiddleware from '../../../middlewares/authMiddleware.js'; + +const routes = (container) => { + const router = express.Router(); + const handler = new LikeCommentHandler(container); + + router.put( + '/threads/:threadId/comments/:commentId/likes', + authMiddleware, + handler.putLikeCommentHandler + ); + + return router; +}; + +export default routes; \ No newline at end of file diff --git a/src/Interfaces/http/middlewares/authMiddleware.js b/src/Interfaces/http/middlewares/authMiddleware.js new file mode 100644 index 0000000..e9aefa5 --- /dev/null +++ b/src/Interfaces/http/middlewares/authMiddleware.js @@ -0,0 +1,39 @@ +import jwt from 'jsonwebtoken'; + +const authMiddleware = (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.status(401).json({ + status: 'fail', + message: 'Missing authentication', + }); + } + + const token = authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ + status: 'fail', + message: 'Invalid authentication format', + }); + } + + const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_KEY); + + req.user = { + id: decoded.id, + }; + + next(); + // eslint-disable-next-line no-unused-vars + } catch (error) { + return res.status(401).json({ + status: 'fail', + message: 'Invalid token', + }); + } +}; + +export default authMiddleware; \ No newline at end of file diff --git a/src/Infrastructures/http/_middleware/errorMiddleware.js b/src/Interfaces/http/middlewares/errorMiddleware.js similarity index 100% rename from src/Infrastructures/http/_middleware/errorMiddleware.js rename to src/Interfaces/http/middlewares/errorMiddleware.js From c9780a4a48bf2755e0ac9a04b4f94b08a8badb0c Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 05:42:25 +0700 Subject: [PATCH 08/14] Register likes API and rename comments/likes Move likes handlers/routes from src/Interfaces/http/api/comments/likes to src/Interfaces/http/api/likes, adjust middleware import paths accordingly, and register the likes router in createServer (mounted under /threads). This reorganizes the likes endpoint into a top-level API module and ensures it is wired into the server. --- src/Infrastructures/http/createServer.js | 2 ++ src/Interfaces/http/api/{comments => }/likes/handler.js | 0 src/Interfaces/http/api/{comments => }/likes/index.js | 0 src/Interfaces/http/api/{comments => }/likes/routes.js | 2 +- 4 files changed, 3 insertions(+), 1 deletion(-) rename src/Interfaces/http/api/{comments => }/likes/handler.js (100%) rename src/Interfaces/http/api/{comments => }/likes/index.js (100%) rename src/Interfaces/http/api/{comments => }/likes/routes.js (84%) diff --git a/src/Infrastructures/http/createServer.js b/src/Infrastructures/http/createServer.js index 5035b39..478e5b0 100644 --- a/src/Infrastructures/http/createServer.js +++ b/src/Infrastructures/http/createServer.js @@ -26,6 +26,7 @@ import comments from '../../Interfaces/http/api/comments/index.js'; import replies from '../../Interfaces/http/api/replies/index.js'; import users from '../../Interfaces/http/api/users/index.js'; import authentications from '../../Interfaces/http/api/authentications/index.js'; +import likes from '../../Interfaces/http/api/likes/index.js'; // Security import JwtTokenManager from '../security/JwtTokenManager.js'; @@ -151,6 +152,7 @@ const createServer = () => { app.use('/threads', threads(container)); app.use('/threads', comments(container)); app.use('/threads', replies(container)); + app.use('/threads', likes); // ====================== // 404 Handler diff --git a/src/Interfaces/http/api/comments/likes/handler.js b/src/Interfaces/http/api/likes/handler.js similarity index 100% rename from src/Interfaces/http/api/comments/likes/handler.js rename to src/Interfaces/http/api/likes/handler.js diff --git a/src/Interfaces/http/api/comments/likes/index.js b/src/Interfaces/http/api/likes/index.js similarity index 100% rename from src/Interfaces/http/api/comments/likes/index.js rename to src/Interfaces/http/api/likes/index.js diff --git a/src/Interfaces/http/api/comments/likes/routes.js b/src/Interfaces/http/api/likes/routes.js similarity index 84% rename from src/Interfaces/http/api/comments/likes/routes.js rename to src/Interfaces/http/api/likes/routes.js index 2ceed30..21a7596 100644 --- a/src/Interfaces/http/api/comments/likes/routes.js +++ b/src/Interfaces/http/api/likes/routes.js @@ -1,6 +1,6 @@ import express from 'express'; import LikeCommentHandler from './handler.js'; -import authMiddleware from '../../../middlewares/authMiddleware.js'; +import authMiddleware from '../../middlewares/authMiddleware.js'; const routes = (container) => { const router = express.Router(); From bb191229d7629b66649eb33cb01b8beb5c08885b Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 06:03:24 +0700 Subject: [PATCH 09/14] Add toggle-like use case & refactor likes/auth Register ToggleLikeCommentUseCase and UserCommentLikeRepository in createServer, and inject the use case into the likes route. Replace the old JWT-based auth middleware with a simpler authMiddleware that checks req.auth.credentials, update LikeCommentHandler to read user id from req.auth.credentials, and adapt likes routes to be mounted under /threads (relative path) and accept a handler instance. Also relocate error middleware to _middlewares. These changes wire the like-toggle feature and harmonize authentication routing/injection. --- src/Infrastructures/http/createServer.js | 11 +++++- .../http/_middlewares/authMiddleware.js | 12 ++++++ .../errorMiddleware.js | 0 src/Interfaces/http/api/likes/handler.js | 2 +- src/Interfaces/http/api/likes/routes.js | 14 +++---- .../http/middlewares/authMiddleware.js | 39 ------------------- 6 files changed, 28 insertions(+), 50 deletions(-) create mode 100644 src/Interfaces/http/_middlewares/authMiddleware.js rename src/Interfaces/http/{middlewares => _middlewares}/errorMiddleware.js (100%) delete mode 100644 src/Interfaces/http/middlewares/authMiddleware.js diff --git a/src/Infrastructures/http/createServer.js b/src/Infrastructures/http/createServer.js index 478e5b0..7920f7e 100644 --- a/src/Infrastructures/http/createServer.js +++ b/src/Infrastructures/http/createServer.js @@ -7,6 +7,7 @@ import CommentRepositoryPostgres from '../repository/CommentRepositoryPostgres.j import ReplyRepositoryPostgres from '../repository/ReplyRepositoryPostgres.js'; import UserRepositoryPostgres from '../repository/UserRepositoryPostgres.js'; import AuthenticationRepositoryPostgres from '../repository/AuthenticationRepositoryPostgres.js'; +import UserCommentLikeRepositoryPostgres from '../repository/UserCommentLikeRepositoryPostgres.js'; // Use Cases import AddThreadUseCase from '../../Applications/use_case/AddThreadUseCase.js'; @@ -19,6 +20,7 @@ import AddUserUseCase from '../../Applications/use_case/AddUserUseCase.js'; import LoginUserUseCase from '../../Applications/use_case/LoginUserUseCase.js'; import LogoutUserUseCase from '../../Applications/use_case/LogoutUserUseCase.js'; import RefreshAuthenticationUseCase from '../../Applications/use_case/RefreshAuthenticationUseCase.js'; +import ToggleLikeCommentUseCase from '../../Applications/use_case/ToggleLikeCommentUseCase.js'; // Routes import threads from '../../Interfaces/http/api/threads/index.js'; @@ -52,6 +54,7 @@ const createServer = () => { const replyRepository = new ReplyRepositoryPostgres(pool); const userRepository = new UserRepositoryPostgres(pool, nanoid); const authenticationRepository = new AuthenticationRepositoryPostgres(pool); + const userCommentLikeRepository = new UserCommentLikeRepositoryPostgres(pool); const passwordHash = new BcryptPasswordHash(); const tokenManager = new JwtTokenManager(); @@ -112,6 +115,12 @@ const createServer = () => { authenticationRepository, }), + ToggleLikeCommentUseCase: new ToggleLikeCommentUseCase({ + userCommentLikeRepository, + commentRepository, + threadRepository, + }), + JwtTokenManager: tokenManager, }; @@ -152,7 +161,7 @@ const createServer = () => { app.use('/threads', threads(container)); app.use('/threads', comments(container)); app.use('/threads', replies(container)); - app.use('/threads', likes); + app.use('/threads', likes(container)); // ====================== // 404 Handler diff --git a/src/Interfaces/http/_middlewares/authMiddleware.js b/src/Interfaces/http/_middlewares/authMiddleware.js new file mode 100644 index 0000000..af3ccd8 --- /dev/null +++ b/src/Interfaces/http/_middlewares/authMiddleware.js @@ -0,0 +1,12 @@ +const authMiddleware = (req, res, next) => { + if (!req.auth || !req.auth.credentials) { + return res.status(401).json({ + status: 'fail', + message: 'Missing authentication', + }); + } + + return next(); +}; + +export default authMiddleware; \ No newline at end of file diff --git a/src/Interfaces/http/middlewares/errorMiddleware.js b/src/Interfaces/http/_middlewares/errorMiddleware.js similarity index 100% rename from src/Interfaces/http/middlewares/errorMiddleware.js rename to src/Interfaces/http/_middlewares/errorMiddleware.js diff --git a/src/Interfaces/http/api/likes/handler.js b/src/Interfaces/http/api/likes/handler.js index 8a44f7d..b6898b7 100644 --- a/src/Interfaces/http/api/likes/handler.js +++ b/src/Interfaces/http/api/likes/handler.js @@ -7,7 +7,7 @@ class LikeCommentHandler { async putLikeCommentHandler(req, res, next) { try { - const userId = req.user.id; + const userId = req.auth.credentials.id; const { threadId, commentId } = req.params; const toggleLikeCommentUseCase = this._container.getInstance( diff --git a/src/Interfaces/http/api/likes/routes.js b/src/Interfaces/http/api/likes/routes.js index 21a7596..f823f08 100644 --- a/src/Interfaces/http/api/likes/routes.js +++ b/src/Interfaces/http/api/likes/routes.js @@ -1,18 +1,14 @@ import express from 'express'; -import LikeCommentHandler from './handler.js'; -import authMiddleware from '../../middlewares/authMiddleware.js'; +import authMiddleware from '../../_middleware/authMiddleware.js'; -const routes = (container) => { - const router = express.Router(); - const handler = new LikeCommentHandler(container); +const router = express.Router(); +export default (handler) => { router.put( - '/threads/:threadId/comments/:commentId/likes', + '/:threadId/comments/:commentId/likes', authMiddleware, handler.putLikeCommentHandler ); return router; -}; - -export default routes; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/Interfaces/http/middlewares/authMiddleware.js b/src/Interfaces/http/middlewares/authMiddleware.js deleted file mode 100644 index e9aefa5..0000000 --- a/src/Interfaces/http/middlewares/authMiddleware.js +++ /dev/null @@ -1,39 +0,0 @@ -import jwt from 'jsonwebtoken'; - -const authMiddleware = (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader) { - return res.status(401).json({ - status: 'fail', - message: 'Missing authentication', - }); - } - - const token = authHeader.split(' ')[1]; - - if (!token) { - return res.status(401).json({ - status: 'fail', - message: 'Invalid authentication format', - }); - } - - const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_KEY); - - req.user = { - id: decoded.id, - }; - - next(); - // eslint-disable-next-line no-unused-vars - } catch (error) { - return res.status(401).json({ - status: 'fail', - message: 'Invalid token', - }); - } -}; - -export default authMiddleware; \ No newline at end of file From f3badacb13c4a03acd61a6b04a4ed17b189751eb Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 06:11:06 +0700 Subject: [PATCH 10/14] Pass LikeCommentHandler instance to routes Instantiate LikeCommentHandler and pass the handler to the likes routes instead of forwarding the container. Adds an import for LikeCommentHandler and updates the default export to create the handler from the container and supply it to routes, ensuring the routes receive the proper handler instance. --- src/Interfaces/http/api/likes/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Interfaces/http/api/likes/index.js b/src/Interfaces/http/api/likes/index.js index 38a9fd3..69bbd97 100644 --- a/src/Interfaces/http/api/likes/index.js +++ b/src/Interfaces/http/api/likes/index.js @@ -1,5 +1,7 @@ +import LikeCommentHandler from './handler.js'; import routes from './routes.js'; -const likesRoutes = (container) => routes(container); - -export default likesRoutes; \ No newline at end of file +export default (container) => { + const handler = new LikeCommentHandler(container); + return routes(handler); +}; \ No newline at end of file From eec5e378c41d14fcb8c3d21dc275205450bf6612 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 06:17:13 +0700 Subject: [PATCH 11/14] Create router inside exported handler Move express.Router() creation into the exported function so a fresh router instance is created per handler invocation instead of sharing a single router at module scope. This prevents cross-request/handler conflicts and allows handler-specific wiring. --- src/Interfaces/http/api/likes/routes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Interfaces/http/api/likes/routes.js b/src/Interfaces/http/api/likes/routes.js index f823f08..98692d8 100644 --- a/src/Interfaces/http/api/likes/routes.js +++ b/src/Interfaces/http/api/likes/routes.js @@ -1,9 +1,9 @@ import express from 'express'; import authMiddleware from '../../_middleware/authMiddleware.js'; -const router = express.Router(); - export default (handler) => { + const router = express.Router(); + router.put( '/:threadId/comments/:commentId/likes', authMiddleware, From c302dba19830b7c68e9882b8a190032a3b07615f Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 06:44:45 +0700 Subject: [PATCH 12/14] Fix middleware import path in likes routes Correct the import path for authMiddleware in src/Interfaces/http/api/likes/routes.js from '../../_middleware/authMiddleware.js' to '../../_middlewares/authMiddleware.js' to match the actual directory name and avoid module resolution errors. No functional changes made. --- src/Interfaces/http/api/likes/routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Interfaces/http/api/likes/routes.js b/src/Interfaces/http/api/likes/routes.js index 98692d8..2bb9575 100644 --- a/src/Interfaces/http/api/likes/routes.js +++ b/src/Interfaces/http/api/likes/routes.js @@ -1,5 +1,5 @@ import express from 'express'; -import authMiddleware from '../../_middleware/authMiddleware.js'; +import authMiddleware from '../../_middlewares/authMiddleware.js'; export default (handler) => { const router = express.Router(); From 67fd46dcd7c5521e8d06d77980e5bb422b56fbdd Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 08:23:06 +0700 Subject: [PATCH 13/14] Use nanoid in UserCommentLikeRepositoryPostgres Import nanoid and set it as the default id generator for UserCommentLikeRepositoryPostgres (constructor defaults to nanoid). Removed the previous domain repository inheritance/import and updated createServer to pass nanoid when instantiating the repository so the id generator is explicit and available. --- src/Infrastructures/http/createServer.js | 2 +- .../repository/UserCommentLikeRepositoryPostgres.js | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Infrastructures/http/createServer.js b/src/Infrastructures/http/createServer.js index 7920f7e..3b60dbe 100644 --- a/src/Infrastructures/http/createServer.js +++ b/src/Infrastructures/http/createServer.js @@ -54,7 +54,7 @@ const createServer = () => { const replyRepository = new ReplyRepositoryPostgres(pool); const userRepository = new UserRepositoryPostgres(pool, nanoid); const authenticationRepository = new AuthenticationRepositoryPostgres(pool); - const userCommentLikeRepository = new UserCommentLikeRepositoryPostgres(pool); + const userCommentLikeRepository = new UserCommentLikeRepositoryPostgres(pool, nanoid); const passwordHash = new BcryptPasswordHash(); const tokenManager = new JwtTokenManager(); diff --git a/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js b/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js index fee476a..c58ec1d 100644 --- a/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js +++ b/src/Infrastructures/repository/UserCommentLikeRepositoryPostgres.js @@ -1,8 +1,7 @@ -import UserCommentLikeRepository from '../../Domains/userCommentLikes/UserCommentLikeRepository.js'; +import { nanoid } from 'nanoid'; -class UserCommentLikeRepositoryPostgres extends UserCommentLikeRepository { - constructor(pool, idGenerator) { - super(); +class UserCommentLikeRepositoryPostgres { + constructor(pool, idGenerator = nanoid) { this._pool = pool; this._idGenerator = idGenerator; } From 936d842c01cc149ee76bf3067917c84e2e557ba6 Mon Sep 17 00:00:00 2001 From: tommoriaren Date: Thu, 23 Apr 2026 13:50:28 +0700 Subject: [PATCH 14/14] Check auth and return 401 in like handler Safely destructure userId from req.auth using optional chaining and handle missing authentication: if userId is absent, respond with 401 and a JSON fail message ('Missing authentication'). This prevents runtime errors when auth credentials are not present before invoking ToggleLikeCommentUseCase. --- src/Interfaces/http/api/likes/handler.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Interfaces/http/api/likes/handler.js b/src/Interfaces/http/api/likes/handler.js index b6898b7..9ea6fb7 100644 --- a/src/Interfaces/http/api/likes/handler.js +++ b/src/Interfaces/http/api/likes/handler.js @@ -7,9 +7,16 @@ class LikeCommentHandler { async putLikeCommentHandler(req, res, next) { try { - const userId = req.auth.credentials.id; + const { id: userId } = req.auth?.credentials || {}; const { threadId, commentId } = req.params; + if (!userId) { + return res.status(401).json({ + status: 'fail', + message: 'Missing authentication', + }); + } + const toggleLikeCommentUseCase = this._container.getInstance( 'ToggleLikeCommentUseCase' );