From 01d4feffe16ff8c6c8b5b80338fc20272571c878 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Apr 2026 13:59:47 +0300 Subject: [PATCH 1/5] Introduce idempotent like/unlike API endpoints Adds `PUT /posts/:postId/like` and `DELETE /posts/:postId/like` to provide idempotent actions for liking and unliking posts --- app/controllers/api/v1/PostsController.js | 31 ++++++++++++++++++----- app/routes/api/v1/PostsRoute.js | 3 +++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/v1/PostsController.js b/app/controllers/api/v1/PostsController.js index cd8d56305..5c4c8e91c 100644 --- a/app/controllers/api/v1/PostsController.js +++ b/app/controllers/api/v1/PostsController.js @@ -121,19 +121,27 @@ export default class PostsController { monitored('posts.likes'), async (ctx) => { const { user, post } = ctx.state; + const { 'operation-id': operationId = null } = ctx.request.headers; if (post.userId === user.id) { throw new ForbiddenException("You can't like your own post"); } - const success = await post.addLike(user); + const success = await post.addLike(user, { operationId }); - if (!success) { + if ( + !success && + // POST requests are legacy, and we don't allow user to like post that + // he has already liked (by the legacy API contract). The new PUT/DELETE + // idempotent API allows user to like/unlike post multiple times without + // error. + ctx.method === 'POST' + ) { throw new ForbiddenException("You can't like post that you have already liked"); } monitor.increment('posts.reactions'); - ctx.body = {}; + ctx.body = { meta: { operationId } }; }, ]); @@ -143,14 +151,23 @@ export default class PostsController { monitored('posts.unlikes'), async (ctx) => { const { user, post } = ctx.state; - const success = await post.removeLike(user); - - if (!success) { + const { 'operation-id': operationId = null } = ctx.request.headers; + + const success = await post.removeLike(user, { operationId }); + + if ( + !success && + // POST requests are legacy, and we don't allow user to like post that + // he has already liked (by the legacy API contract). The new PUT/DELETE + // idempotent API allows user to like/unlike post multiple times without + // error. + ctx.method === 'POST' + ) { throw new ForbiddenException("You can't un-like post that you haven't yet liked"); } monitor.decrement('posts.reactions'); - ctx.body = {}; + ctx.body = { meta: { operationId } }; }, ]); diff --git a/app/routes/api/v1/PostsRoute.js b/app/routes/api/v1/PostsRoute.js index f944f82f0..04e55195d 100644 --- a/app/routes/api/v1/PostsRoute.js +++ b/app/routes/api/v1/PostsRoute.js @@ -12,4 +12,7 @@ export default function addRoutes(app) { app.delete('/posts/:postId/save', PostsController.unsave); app.post('/posts/:postId/disableComments', PostsController.disableComments); app.post('/posts/:postId/enableComments', PostsController.enableComments); + // Idempotent likes and unlikes + app.put('/posts/:postId/like', PostsController.like); + app.delete('/posts/:postId/like', PostsController.unlike); } From 1c7d47d0326ed12d759437c4629ebb4ca9df2e8b Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Apr 2026 14:01:03 +0300 Subject: [PATCH 2/5] Adds operation ID to like/unlike events Introduces an `operationId` parameter to the `addLike` and `removeLike` methods, which is then propagated through the pubsub system. --- app/models/post.js | 12 ++++++++---- app/pubsub-listener.js | 30 ++++++++++++++++++++++++------ app/pubsub.ts | 17 +++++++++++++---- app/support/DbAdapter/index.d.ts | 1 + 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/models/post.js b/app/models/post.js index 386d9252c..e3e128e2d 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -826,9 +826,11 @@ export function addModel(dbAdapter) { * liked by this user. * * @param {User} user + * @param {Object} options + * @param {string} options.operationId - Operation ID for client-side deduplication * @returns {boolean} */ - async addLike(user) { + async addLike(user, { operationId = null } = {}) { const success = await dbAdapter.likePost(this.id, user.id); if (!success) { @@ -853,7 +855,7 @@ export function addModel(dbAdapter) { await dbAdapter.insertPostIntoFeeds([likesTimeline.intId], this.id); // Send realtime notifications - await pubSub.newLike(this, user.id); + await pubSub.newLike(this, user.id, { operationId }); return true; } @@ -864,9 +866,11 @@ export function addModel(dbAdapter) { * liked by this user. * * @param {User} user + * @param {Object} options + * @param {string} options.operationId - Operation ID for client-side deduplication * @returns {boolean} */ - async removeLike(user) { + async removeLike(user, { operationId = null } = {}) { const success = await dbAdapter.unlikePost(this.id, user.id); if (!success) { @@ -880,7 +884,7 @@ export function addModel(dbAdapter) { await dbAdapter.withdrawPostFromFeeds([timelineId], this.id); // Send realtime notifications - await pubSub.removeLike(this.id, user.id, realtimeRooms); + await pubSub.removeLike(this.id, user.id, realtimeRooms, { operationId }); return true; } diff --git a/app/pubsub-listener.js b/app/pubsub-listener.js index 79097b053..5ff621482 100644 --- a/app/pubsub-listener.js +++ b/app/pubsub-listener.js @@ -526,22 +526,22 @@ export default class PubsubListener { await this.broadcastMessage(rooms, type, json, { post }); }; - onLikeNew = async ({ userId, postId }) => { + onLikeNew = async ({ userId, postId, operationId }) => { const post = await dbAdapter.getPostById(postId); const json = { users: { id: userId }, // will be filled by _likeEventEmitter - meta: { postId }, + meta: { postId, operationId }, }; const type = eventNames.LIKE_ADDED; const rooms = await getRoomsOfPost(post); await this.broadcastMessage(rooms, type, json, { post, emitter: this._likeEventEmitter }); }; - onLikeRemove = async ({ userId, postId, rooms }) => { - const json = { meta: { userId, postId } }; + onLikeRemove = async ({ userId, postId, rooms, operationId }) => { + const json = { meta: { userId, postId, operationId } }; const post = await dbAdapter.getPostById(postId); const type = eventNames.LIKE_REMOVED; - await this.broadcastMessage(rooms, type, json, { post }); + await this.broadcastMessage(rooms, type, json, { post, emitter: this._unlikeEventEmitter }); }; onPostHide = async ({ postId, userId }) => { @@ -788,12 +788,30 @@ export default class PubsubListener { } async _likeEventEmitter(socket, type, json) { - const viewerId = socket.userId; + const { userId: viewerId } = socket; const userId = json.users.id; + // We need to re-serialize users according to the viewerId const users = await serializeUsersByIds([userId], viewerId, false); // eslint-disable-next-line prefer-destructuring json.users = users[0]; + + // Show operationId only for the user who performed the action + if (viewerId !== userId) { + json.meta.operationId = null; + } + + defaultEmitter(socket, type, json); + } + + _unlikeEventEmitter(socket, type, json) { + const { userId: viewerId } = socket; + + // Show operationId only for the user who performed the action + if (viewerId !== json.meta.userId) { + json.meta.operationId = null; + } + defaultEmitter(socket, type, json); } diff --git a/app/pubsub.ts b/app/pubsub.ts index b1dc1a44b..6b2ce5f58 100644 --- a/app/pubsub.ts +++ b/app/pubsub.ts @@ -100,13 +100,22 @@ export default class pubSub { await this.publisher.commentUpdated(payload); } - async newLike(post: Post, userId: UUID) { - const payload = JSON.stringify({ userId, postId: post.id }); + async newLike( + post: Post, + userId: UUID, + { operationId = null }: { operationId?: string | null } = {}, + ) { + const payload = JSON.stringify({ userId, postId: post.id, operationId }); await this.publisher.likeAdded(payload); } - async removeLike(postId: UUID, userId: UUID, rooms: string[]) { - const payload = JSON.stringify({ userId, postId, rooms }); + async removeLike( + postId: UUID, + userId: UUID, + rooms: string[], + { operationId = null }: { operationId?: string | null } = {}, + ) { + const payload = JSON.stringify({ userId, postId, rooms, operationId }); await this.publisher.likeRemoved(payload); } diff --git a/app/support/DbAdapter/index.d.ts b/app/support/DbAdapter/index.d.ts index 0a32f99cb..8a09aced1 100644 --- a/app/support/DbAdapter/index.d.ts +++ b/app/support/DbAdapter/index.d.ts @@ -267,6 +267,7 @@ export class DbAdapter { ): Promise>; // Likes + likePost(postId: UUID, userId: UUID): Promise; unlikePost(postId: UUID, userId: UUID): Promise; // Comments From d9fcd441a5ee711c45b90fe0c415f954eb433437 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Apr 2026 14:01:17 +0300 Subject: [PATCH 3/5] Add tests for the new likes system --- app/models/auth-tokens/app-tokens-scopes.ts | 2 + test/functional/functional_test_helper.js | 36 +++++ test/functional/posts/posts.js | 138 +++++++++++++++++++- test/functional/realtime2.js | 48 +++++++ test/integration/models/post/main.js | 46 +++++++ 5 files changed, 268 insertions(+), 2 deletions(-) diff --git a/app/models/auth-tokens/app-tokens-scopes.ts b/app/models/auth-tokens/app-tokens-scopes.ts index 85693e925..04803dc7f 100644 --- a/app/models/auth-tokens/app-tokens-scopes.ts +++ b/app/models/auth-tokens/app-tokens-scopes.ts @@ -165,6 +165,8 @@ export const appTokensScopes = [ 'DELETE /vN/comments/:commentId', 'POST /vN/posts/:postId/like', 'POST /vN/posts/:postId/unlike', + 'PUT /vN/posts/:postId/like', + 'DELETE /vN/posts/:postId/like', 'POST /vN/comments/:commentId/like', 'POST /vN/comments/:commentId/unlike', 'POST /vN/posts/:postId/leave', diff --git a/test/functional/functional_test_helper.js b/test/functional/functional_test_helper.js index d57ed41d9..c20a3dbcb 100644 --- a/test/functional/functional_test_helper.js +++ b/test/functional/functional_test_helper.js @@ -517,6 +517,42 @@ export function unlike(postId, authToken) { return postJson(`/v1/posts/${postId}/unlike`, { authToken }); } +export async function putLike(postId, authToken, operationId = null) { + const headers = { 'Content-Type': 'application/json' }; + + if (operationId) { + headers['operation-id'] = operationId; + } + + if (authToken) { + headers['authorization'] = `Bearer ${authToken}`; + } + + return fetch(await apiUrl(`/v1/posts/${postId}/like`), { + agent, + method: 'PUT', + headers, + }); +} + +export async function deleteLike(postId, authToken, operationId = null) { + const headers = { 'Content-Type': 'application/json' }; + + if (operationId) { + headers['operation-id'] = operationId; + } + + if (authToken) { + headers['authorization'] = `Bearer ${authToken}`; + } + + return fetch(await apiUrl(`/v1/posts/${postId}/like`), { + agent, + method: 'DELETE', + headers, + }); +} + export function updateUserAsync(userContext, user) { return postJson(`/v1/users/${userContext.user.id}`, { authToken: userContext.authToken, diff --git a/test/functional/posts/posts.js b/test/functional/posts/posts.js index 37e461476..fd1ffc7d1 100644 --- a/test/functional/posts/posts.js +++ b/test/functional/posts/posts.js @@ -800,14 +800,14 @@ describe('PostsController', () => { .post(`${app.context.config.host}/v1/posts/${context.post.id}/like`) .send({ authToken: otherUserAuthToken }) .end((err, res) => { - res.body.should.be.empty; + res.body.should.eql({ meta: { operationId: null } }); $should.not.exist(err); request .post(`${app.context.config.host}/v1/posts/${context.post.id}/unlike`) .send({ authToken: otherUserAuthToken }) .end((err, res) => { - res.body.should.be.empty; + res.body.should.eql({ meta: { operationId: null } }); $should.not.exist(err); done(); @@ -841,6 +841,140 @@ describe('PostsController', () => { }); }); + describe('PUT /posts/:postId/like (idempotent)', () => { + let context = {}; + let otherUserAuthToken; + + beforeEach(async () => { + context = await funcTestHelper.createUserAsync('Luna', 'password'); + + const [marsCtx, post] = await Promise.all([ + funcTestHelper.createUserAsync('mars', 'password2'), + funcTestHelper.justCreatePost(context, 'Post body'), + ]); + + context.post = post; + otherUserAuthToken = marsCtx.authToken; + }); + + it('should like post and return 200 with operationId in meta', async () => { + const response = await funcTestHelper.putLike(context.post.id, otherUserAuthToken, 'op-123'); + response.status.should.eql(200); + + const data = await response.json(); + data.should.eql({ meta: { operationId: 'op-123' } }); + }); + + it('should return 200 when liking already liked post (idempotent, no 403)', async () => { + await funcTestHelper.putLike(context.post.id, otherUserAuthToken, 'op-1'); + const response = await funcTestHelper.putLike(context.post.id, otherUserAuthToken, 'op-2'); + response.status.should.eql(200); + + const data = await response.json(); + data.should.eql({ meta: { operationId: 'op-2' } }); + }); + + it('should handle parallel idempotent likes without errors', async () => { + const responses = await Promise.all([ + funcTestHelper.putLike(context.post.id, otherUserAuthToken, 'op-1'), + funcTestHelper.putLike(context.post.id, otherUserAuthToken, 'op-2'), + funcTestHelper.putLike(context.post.id, otherUserAuthToken, 'op-3'), + ]); + + responses.forEach((r) => r.status.should.eql(200)); + }); + + it('should return operationId: null when header is missing', async () => { + const response = await funcTestHelper.putLike(context.post.id, otherUserAuthToken); + response.status.should.eql(200); + + const data = await response.json(); + data.should.eql({ meta: { operationId: null } }); + }); + + it('should not allow liking own post', async () => { + const response = await funcTestHelper.putLike(context.post.id, context.authToken, 'op-1'); + response.status.should.eql(403); + }); + + describe('Interaction with banned user', () => { + let postOfMars; + let marsCtx; + + beforeEach(async () => { + marsCtx = await funcTestHelper.createUserAsync('mars2', 'password3'); + postOfMars = await funcTestHelper.justCreatePost(marsCtx, 'I am mars!'); + await funcTestHelper.banUser(context, marsCtx); + }); + + it(`should not allow like on banned user's post`, async () => { + const response = await funcTestHelper.putLike(postOfMars.id, context.authToken, 'op-1'); + response.status.should.eql(403); + }); + }); + }); + + describe('DELETE /posts/:postId/like (idempotent)', () => { + let context = {}; + let otherUserAuthToken; + + beforeEach(async () => { + context = await funcTestHelper.createUserAsync('Luna', 'password'); + + const [marsCtx, post] = await Promise.all([ + funcTestHelper.createUserAsync('mars', 'password2'), + funcTestHelper.justCreatePost(context, 'Post body'), + ]); + + context.post = post; + otherUserAuthToken = marsCtx.authToken; + }); + + it('should unlike post and return 200 with operationId in meta', async () => { + await funcTestHelper.like(context.post.id, otherUserAuthToken); + const response = await funcTestHelper.deleteLike( + context.post.id, + otherUserAuthToken, + 'op-unlike-1', + ); + response.status.should.eql(200); + + const data = await response.json(); + data.should.eql({ meta: { operationId: 'op-unlike-1' } }); + }); + + it('should return 200 when unliking not-liked post (idempotent, no 403)', async () => { + const response = await funcTestHelper.deleteLike( + context.post.id, + otherUserAuthToken, + 'op-unlike-1', + ); + response.status.should.eql(200); + + const data = await response.json(); + data.should.eql({ meta: { operationId: 'op-unlike-1' } }); + }); + + it('should handle parallel idempotent unlikes without errors', async () => { + await funcTestHelper.like(context.post.id, otherUserAuthToken); + const responses = await Promise.all([ + funcTestHelper.deleteLike(context.post.id, otherUserAuthToken, 'op-1'), + funcTestHelper.deleteLike(context.post.id, otherUserAuthToken, 'op-2'), + ]); + + responses.forEach((r) => r.status.should.eql(200)); + }); + + it('should return operationId: null when header is missing', async () => { + await funcTestHelper.like(context.post.id, otherUserAuthToken); + const response = await funcTestHelper.deleteLike(context.post.id, otherUserAuthToken); + response.status.should.eql(200); + + const data = await response.json(); + data.should.eql({ meta: { operationId: null } }); + }); + }); + describe('#disableComments()', () => { let context = {}; let otherUserAuthToken; diff --git a/test/functional/realtime2.js b/test/functional/realtime2.js index a922ed0cb..9a2f9870e 100644 --- a/test/functional/realtime2.js +++ b/test/functional/realtime2.js @@ -306,6 +306,54 @@ describe('Realtime #2', () => { }); }); + describe('operationId in realtime events (using idempotent API)', () => { + beforeEach(async () => { + // Clean up any existing like and clear collected events + await funcTestHelper.deleteLike(post.id, mars.authToken); + await Promise.all([lunaSession.clearCollected(), marsSession.clearCollected()]); + + await Promise.all([ + lunaSession.sendAsync('subscribe', { post: [post.id] }), + marsSession.sendAsync('subscribe', { post: [post.id] }), + ]); + }); + + it(`should include operationId in like:new event for action performer only`, async () => { + const lunaEvent = lunaSession.receive('like:new'); + const marsEvent = marsSession.receive('like:new'); + + await Promise.all([ + funcTestHelper.putLike(post.id, mars.authToken, 'mars-op-123'), + lunaEvent, + marsEvent, + ]); + + // Mars should see his operationId + expect(await marsEvent, 'to satisfy', { meta: { operationId: 'mars-op-123' } }); + // Luna should not see operationId (null) + expect(await lunaEvent, 'to satisfy', { meta: { operationId: null } }); + }); + + it(`should include operationId in like:remove event for action performer only`, async () => { + // First, like the post + await funcTestHelper.putLike(post.id, mars.authToken, 'op-like'); + + const lunaEvent = lunaSession.receive('like:remove'); + const marsEvent = marsSession.receive('like:remove'); + + await Promise.all([ + funcTestHelper.deleteLike(post.id, mars.authToken, 'mars-unlike-456'), + lunaEvent, + marsEvent, + ]); + + // Mars should see his operationId + expect(await marsEvent, 'to satisfy', { meta: { operationId: 'mars-unlike-456' } }); + // Luna should not see operationId (null) + expect(await lunaEvent, 'to satisfy', { meta: { operationId: null } }); + }); + }); + it(`Mars should not be able to subscribe to Luna's RiverOfNews`, async () => { const lunaRoNFeed = await dbAdapter.getUserNamedFeed(luna.user.id, 'RiverOfNews'); const promise = marsSession.sendAsync('subscribe', { timeline: [lunaRoNFeed.id] }); diff --git a/test/integration/models/post/main.js b/test/integration/models/post/main.js index 0bb073b5c..33eb161f4 100644 --- a/test/integration/models/post/main.js +++ b/test/integration/models/post/main.js @@ -482,6 +482,29 @@ describe('Post', () => { likes[2].id.should.eql(users[4].id); } }); + + it('should accept operationId option and return true on success', async () => { + const result = await post.addLike(userA, { operationId: 'test-op-id' }); + result.should.be.true; + + const likers = await post.getLikes(); + likers.length.should.eql(1); + likers[0].id.should.eql(userA.id); + }); + + it('should accept null operationId and return true on success', async () => { + const result = await post.addLike(userA, { operationId: null }); + result.should.be.true; + + const likers = await post.getLikes(); + likers.length.should.eql(1); + }); + + it('should return false when liking already liked post', async () => { + await post.addLike(userA, { operationId: 'first-op' }); + const result = await post.addLike(userA, { operationId: 'second-op' }); + result.should.be.false; + }); }); describe('#removeLike()', () => { @@ -543,6 +566,29 @@ describe('Post', () => { done(e); }); }); + + it('should accept operationId option and return true on success', async () => { + await post.addLike(userA); + const result = await post.removeLike(userA, { operationId: 'test-unlike-op' }); + result.should.be.true; + + const likers = await post.getLikes(); + likers.should.be.empty; + }); + + it('should accept null operationId and return true on success', async () => { + await post.addLike(userA); + const result = await post.removeLike(userA, { operationId: null }); + result.should.be.true; + + const likers = await post.getLikes(); + likers.should.be.empty; + }); + + it('should return false when unliking not-liked post', async () => { + const result = await post.removeLike(userA, { operationId: 'unlike-op' }); + result.should.be.false; + }); }); describe('#addComment()', () => { From b20564b238e15e263ef1c4caac89daff6f4cd6a8 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Apr 2026 17:06:30 +0300 Subject: [PATCH 4/5] Document idempotent likes in CHANGELOG Adds documentation for the new PUT and DELETE like endpoints, the operation-id header, and related changes to realtime events. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02613b841..045334774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.30.0] - Not released +### Added +- New idempotent endpoints for post likes: + - `PUT /v1/posts/:postId/like` likes a post and returns `200 OK` even if the + post is already liked. + - `DELETE /v1/posts/:postId/like` removes a like and returns `200 OK` even if + the post is not liked. + + Both endpoints accept an optional `operation-id` header for client-side + deduplication and return it as `meta.operationId`. + + Existing `POST /v1/posts/:postId/like` and `POST /v1/posts/:postId/unlike` + responses now also include `meta.operationId`, with `null` when the header is + not provided. + + Realtime `like:new` and `like:remove` events now include `meta.operationId`; + it is visible only to the user who performed the action and is `null` for + other subscribers. ## [2.29.4] - 2026-03-25 ### Added From e48f5cceb37f9563ebf37e6668449717253fc9ae Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Apr 2026 22:27:41 +0300 Subject: [PATCH 5/5] Introduce idempotent comment like/unlike endpoints Adds `PUT /v2/comments/:commentId/like` and `DELETE /v2/comments/:commentId/like` to provide idempotent actions for comment likes. Like the post-level equivalents, these endpoints accept an `operation-id` header for client-side deduplication and propagate it through realtime events to the action performer. --- CHANGELOG.md | 29 +++--- .../api/v2/CommentLikesController.js | 12 ++- app/models/auth-tokens/app-tokens-scopes.ts | 2 + app/models/comment.js | 12 ++- app/pubsub-listener.js | 6 ++ app/pubsub.ts | 18 +++- app/routes/api/v2/CommentLikesRoute.js | 3 + app/support/DbAdapter/index.d.ts | 1 + test/functional/comment_likes.js | 88 +++++++++++++++++++ test/functional/functional_test_helper.js | 42 ++++++++- test/functional/realtime2.js | 44 ++++++++++ 11 files changed, 233 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 045334774..2a89c80b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,16 +13,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DELETE /v1/posts/:postId/like` removes a like and returns `200 OK` even if the post is not liked. - Both endpoints accept an optional `operation-id` header for client-side - deduplication and return it as `meta.operationId`. - - Existing `POST /v1/posts/:postId/like` and `POST /v1/posts/:postId/unlike` - responses now also include `meta.operationId`, with `null` when the header is - not provided. - - Realtime `like:new` and `like:remove` events now include `meta.operationId`; - it is visible only to the user who performed the action and is `null` for - other subscribers. +- New idempotent endpoints for comment likes: + - `PUT /v2/comments/:commentId/like` likes a comment and returns `200 OK` even + if the comment is already liked. + - `DELETE /v2/comments/:commentId/like` removes a comment like and returns + `200 OK` even if the comment is not liked. + + It is recommended to use these new endpoints instead of the existing ones to + improve reliability of like operations. Existing like-related endpoints + (`POST /v1/posts/:postId/like`, `POST /v1/posts/:postId/unlike`, `POST + /v2/comments/:commentId/like`, and `POST /v2/comments/:commentId/unlike`) are + still available but treats as legacy. + + Both new and old like-related endpoints accept an optional `operation-id` + header for client-side deduplication and return it as `meta.operationId` in + their responses, with `null` when the header is not provided. + + Realtime events `like:new`, `like:remove`, `comment_like:new`, and + `comment_like:remove` now include `meta.operationId`; it is visible only to + the user who performed the action and is `null` for other subscribers. ## [2.29.4] - 2026-03-25 ### Added diff --git a/app/controllers/api/v2/CommentLikesController.js b/app/controllers/api/v2/CommentLikesController.js index 9a8dc235f..c2050cace 100644 --- a/app/controllers/api/v2/CommentLikesController.js +++ b/app/controllers/api/v2/CommentLikesController.js @@ -12,19 +12,21 @@ export default class CommentLikesController { monitored('comments.like'), async (ctx) => { const { comment, user } = ctx.state; + const { 'operation-id': operationId = null } = ctx.request.headers; if (comment.userId === user.id) { throw new ForbiddenException("You can't like your own comment"); } - const ok = await comment.addLike(user); + const ok = await comment.addLike(user, { operationId }); - if (!ok) { + if (!ok && ctx.method === 'POST') { throw new ForbiddenException("You can't like comment that you have already liked"); } // Return likes list await CommentLikesController.likes(ctx); + ctx.body.meta = { operationId }; }, ]); @@ -34,19 +36,21 @@ export default class CommentLikesController { monitored('comments.unlike'), async (ctx) => { const { comment, user } = ctx.state; + const { 'operation-id': operationId = null } = ctx.request.headers; if (comment.userId === user.id) { throw new ForbiddenException("You can't un-like your own comment"); } - const ok = await comment.removeLike(user); + const ok = await comment.removeLike(user, { operationId }); - if (!ok) { + if (!ok && ctx.method === 'POST') { throw new ForbiddenException("You can't un-like comment that you haven't yet liked"); } // Return likes list await CommentLikesController.likes(ctx); + ctx.body.meta = { operationId }; }, ]); diff --git a/app/models/auth-tokens/app-tokens-scopes.ts b/app/models/auth-tokens/app-tokens-scopes.ts index 04803dc7f..dccd4d162 100644 --- a/app/models/auth-tokens/app-tokens-scopes.ts +++ b/app/models/auth-tokens/app-tokens-scopes.ts @@ -169,6 +169,8 @@ export const appTokensScopes = [ 'DELETE /vN/posts/:postId/like', 'POST /vN/comments/:commentId/like', 'POST /vN/comments/:commentId/unlike', + 'PUT /vN/comments/:commentId/like', + 'DELETE /vN/comments/:commentId/like', 'POST /vN/posts/:postId/leave', 'POST /vN/posts/:postId/notifyOfAllComments', 'POST /vN/posts/:postId/pin', diff --git a/app/models/comment.js b/app/models/comment.js index 73e6be8c5..7acc438e6 100644 --- a/app/models/comment.js +++ b/app/models/comment.js @@ -335,13 +335,15 @@ export function addModel(dbAdapter) { * liked by this user. * * @param {User} user + * @param {Object} options + * @param {string} options.operationId - Operation ID for client-side deduplication * @returns {Promise} */ - async addLike(user) { + async addLike(user, { operationId = null } = {}) { const ok = await dbAdapter.createCommentLike(this.id, user.id); if (ok) { - await pubSub.newCommentLike(this.id, this.postId, user.id); + await pubSub.newCommentLike(this.id, this.postId, user.id, { operationId }); } return ok; @@ -353,13 +355,15 @@ export function addModel(dbAdapter) { * liked by this user. * * @param {User} user + * @param {Object} options + * @param {string} options.operationId - Operation ID for client-side deduplication * @returns {boolean} */ - async removeLike(user) { + async removeLike(user, { operationId = null } = {}) { const ok = await dbAdapter.deleteCommentLike(this.id, user.id); if (ok) { - await pubSub.removeCommentLike(this.id, this.postId, user.id); + await pubSub.removeCommentLike(this.id, this.postId, user.id, { operationId }); } return ok; diff --git a/app/pubsub-listener.js b/app/pubsub-listener.js index 5ff621482..29b87d47f 100644 --- a/app/pubsub-listener.js +++ b/app/pubsub-listener.js @@ -762,6 +762,8 @@ export default class PubsubListener { json.comments.userId = data.unlikerUUID; } + json.meta = { operationId: data.operationId }; + const rooms = await getRoomsOfPost(post); await this.broadcastMessage(rooms, msgType, json, { post, @@ -784,6 +786,10 @@ export default class PubsubListener { json.comments.likes = parseInt(commentLikesData.c_likes); json.comments.hasOwnLike = commentLikesData.has_own_like; + if (viewerId !== json.comments.userId) { + json.meta.operationId = null; + } + defaultEmitter(socket, type, json); } diff --git a/app/pubsub.ts b/app/pubsub.ts index 6b2ce5f58..9214c197d 100644 --- a/app/pubsub.ts +++ b/app/pubsub.ts @@ -135,13 +135,23 @@ export default class pubSub { await this.publisher.postUnsaved(JSON.stringify({ userId, postId })); } - async newCommentLike(commentId: UUID, postId: UUID, likerUUID: UUID) { - const payload = JSON.stringify({ commentId, postId, likerUUID }); + async newCommentLike( + commentId: UUID, + postId: UUID, + likerUUID: UUID, + { operationId = null }: { operationId?: string | null } = {}, + ) { + const payload = JSON.stringify({ commentId, postId, likerUUID, operationId }); await this.publisher.commentLikeAdded(payload); } - async removeCommentLike(commentId: UUID, postId: UUID, unlikerUUID: UUID) { - const payload = JSON.stringify({ commentId, postId, unlikerUUID }); + async removeCommentLike( + commentId: UUID, + postId: UUID, + unlikerUUID: UUID, + { operationId = null }: { operationId?: string | null } = {}, + ) { + const payload = JSON.stringify({ commentId, postId, unlikerUUID, operationId }); await this.publisher.commentLikeRemoved(payload); } diff --git a/app/routes/api/v2/CommentLikesRoute.js b/app/routes/api/v2/CommentLikesRoute.js index eac9d100c..a1c49cfa1 100644 --- a/app/routes/api/v2/CommentLikesRoute.js +++ b/app/routes/api/v2/CommentLikesRoute.js @@ -4,4 +4,7 @@ export default function addRoutes(app) { app.post('/comments/:commentId/like', CommentLikesController.like); app.post('/comments/:commentId/unlike', CommentLikesController.unlike); app.get('/comments/:commentId/likes', CommentLikesController.likes); + // Idempotent like/unlike endpoints + app.put('/comments/:commentId/like', CommentLikesController.like); + app.delete('/comments/:commentId/like', CommentLikesController.unlike); } diff --git a/app/support/DbAdapter/index.d.ts b/app/support/DbAdapter/index.d.ts index 8a09aced1..9d631988b 100644 --- a/app/support/DbAdapter/index.d.ts +++ b/app/support/DbAdapter/index.d.ts @@ -279,6 +279,7 @@ export class DbAdapter { getCommentBySeqNumber(postId: UUID, seqNumber: number): Promise; // Comment likes + createCommentLike(commentUUID: UUID, likerUUID: UUID): Promise; deleteCommentLike(commentUUID: UUID, likerUUID: UUID): Promise; getLikesInfoForComments( commentsUUIDs: UUID[], diff --git a/test/functional/comment_likes.js b/test/functional/comment_likes.js index ee662a0d2..1c31ad6aa 100644 --- a/test/functional/comment_likes.js +++ b/test/functional/comment_likes.js @@ -13,8 +13,10 @@ import { banUser, createAndReturnPost, likeComment, + putCommentLike, createUserAsync, unlikeComment, + deleteCommentLike, getCommentLikes, like, mutualSubscriptions, @@ -115,6 +117,49 @@ describe('Comment likes', () => { ); }); + describe('PUT /comments/:commentId/like', () => { + it('should like comment and return operationId in meta', async () => { + const marsComment = await justCreateComment(mars, lunaPost.id, 'Mars comment'); + const res = await putCommentLike(marsComment.id, luna, 'op-123'); + const body = await res.json(); + + expect(res, 'to satisfy', { status: 200 }); + expect(body, 'to satisfy', { + likes: expect.it('to have length', 1), + meta: { operationId: 'op-123' }, + }); + expect(body.likes[0].userId, 'to be', luna.user.id); + }); + + it('should return 200 when comment is already liked', async () => { + const marsComment = await justCreateComment(mars, lunaPost.id, 'Mars comment'); + await putCommentLike(marsComment.id, luna, 'op-1'); + + const res = await putCommentLike(marsComment.id, luna, 'op-2'); + const body = await res.json(); + + expect(res, 'to satisfy', { status: 200 }); + expect(body, 'to satisfy', { + likes: expect.it('to have length', 1), + meta: { operationId: 'op-2' }, + }); + expect(body.likes[0].userId, 'to be', luna.user.id); + }); + + it('should return operationId: null when header is missing', async () => { + const marsComment = await justCreateComment(mars, lunaPost.id, 'Mars comment'); + const res = await putCommentLike(marsComment.id, luna); + const body = await res.json(); + + expect(res, 'to satisfy', { status: 200 }); + expect(body, 'to satisfy', { + likes: expect.it('to have length', 1), + meta: { operationId: null }, + }); + expect(body.likes[0].userId, 'to be', luna.user.id); + }); + }); + describe('comment likes sorting', () => { let pluto; @@ -359,6 +404,49 @@ describe('Comment likes', () => { ); }); + describe('DELETE /comments/:commentId/like', () => { + it('should unlike comment and return operationId in meta', async () => { + const marsComment = await justCreateComment(mars, lunaPost.id, 'Mars comment'); + await likeComment(marsComment.id, luna); + + const res = await deleteCommentLike(marsComment.id, luna, 'op-unlike-1'); + const body = await res.json(); + + expect(res, 'to satisfy', { status: 200 }); + expect(body, 'to satisfy', { + likes: expect.it('to be empty'), + meta: { operationId: 'op-unlike-1' }, + }); + }); + + it('should return 200 when comment is not liked', async () => { + const marsComment = await justCreateComment(mars, lunaPost.id, 'Mars comment'); + + const res = await deleteCommentLike(marsComment.id, luna, 'op-unlike-2'); + const body = await res.json(); + + expect(res, 'to satisfy', { status: 200 }); + expect(body, 'to satisfy', { + likes: expect.it('to be empty'), + meta: { operationId: 'op-unlike-2' }, + }); + }); + + it('should return operationId: null when header is missing', async () => { + const marsComment = await justCreateComment(mars, lunaPost.id, 'Mars comment'); + await likeComment(marsComment.id, luna); + + const res = await deleteCommentLike(marsComment.id, luna); + const body = await res.json(); + + expect(res, 'to satisfy', { status: 200 }); + expect(body, 'to satisfy', { + likes: expect.it('to be empty'), + meta: { operationId: null }, + }); + }); + }); + describe('comment likes sorting', () => { let pluto; diff --git a/test/functional/functional_test_helper.js b/test/functional/functional_test_helper.js index c20a3dbcb..1888dd5aa 100644 --- a/test/functional/functional_test_helper.js +++ b/test/functional/functional_test_helper.js @@ -980,9 +980,13 @@ export async function markAllNotificationsAsRead(user) { // Comment likes // ************************ -export async function likeComment(commentId, likerContext = null) { +export async function likeComment(commentId, likerContext = null, operationId = null) { const headers = {}; + if (operationId) { + headers['operation-id'] = operationId; + } + if (likerContext) { headers['X-Authentication-Token'] = likerContext.authToken; } @@ -991,9 +995,28 @@ export async function likeComment(commentId, likerContext = null) { return fetch(url, { agent, method: 'POST', headers }); } -export async function unlikeComment(commentId, unlikerContext = null) { +export async function putCommentLike(commentId, likerContext = null, operationId = null) { + const headers = {}; + + if (operationId) { + headers['operation-id'] = operationId; + } + + if (likerContext) { + headers['X-Authentication-Token'] = likerContext.authToken; + } + + const url = await apiUrl(`/v2/comments/${commentId}/like`); + return fetch(url, { agent, method: 'PUT', headers }); +} + +export async function unlikeComment(commentId, unlikerContext = null, operationId = null) { const headers = {}; + if (operationId) { + headers['operation-id'] = operationId; + } + if (unlikerContext) { headers['X-Authentication-Token'] = unlikerContext.authToken; } @@ -1002,6 +1025,21 @@ export async function unlikeComment(commentId, unlikerContext = null) { return fetch(url, { agent, method: 'POST', headers }); } +export async function deleteCommentLike(commentId, unlikerContext = null, operationId = null) { + const headers = {}; + + if (operationId) { + headers['operation-id'] = operationId; + } + + if (unlikerContext) { + headers['X-Authentication-Token'] = unlikerContext.authToken; + } + + const url = await apiUrl(`/v2/comments/${commentId}/like`); + return fetch(url, { agent, method: 'DELETE', headers }); +} + export async function getCommentLikes(commentId, viewerContext = null) { const headers = {}; diff --git a/test/functional/realtime2.js b/test/functional/realtime2.js index 9a2f9870e..3e5d63c1b 100644 --- a/test/functional/realtime2.js +++ b/test/functional/realtime2.js @@ -51,6 +51,50 @@ describe('Realtime #2', () => { afterEach(() => [lunaSession, marsSession, anonSession].forEach((s) => s.disconnect())); + describe('operationId in comment like realtime events', () => { + let post, comment; + + beforeEach(async () => { + post = await funcTestHelper.createAndReturnPost(luna, 'Luna post'); + comment = await funcTestHelper.justCreateComment(luna, post.id, 'Luna comment'); + + await Promise.all([ + lunaSession.sendAsync('subscribe', { post: [post.id] }), + marsSession.sendAsync('subscribe', { post: [post.id] }), + ]); + }); + + it(`should include operationId in comment_like:new event for action performer only`, async () => { + const lunaEvent = lunaSession.receive('comment_like:new'); + const marsEvent = marsSession.receive('comment_like:new'); + + await Promise.all([ + funcTestHelper.putCommentLike(comment.id, mars, 'comment-like-op-1'), + lunaEvent, + marsEvent, + ]); + + expect(await marsEvent, 'to satisfy', { meta: { operationId: 'comment-like-op-1' } }); + expect(await lunaEvent, 'to satisfy', { meta: { operationId: null } }); + }); + + it(`should include operationId in comment_like:remove event for action performer only`, async () => { + await funcTestHelper.putCommentLike(comment.id, mars, 'comment-like-op-2'); + + const lunaEvent = lunaSession.receive('comment_like:remove'); + const marsEvent = marsSession.receive('comment_like:remove'); + + await Promise.all([ + funcTestHelper.deleteCommentLike(comment.id, mars, 'comment-unlike-op-1'), + lunaEvent, + marsEvent, + ]); + + expect(await marsEvent, 'to satisfy', { meta: { operationId: 'comment-unlike-op-1' } }); + expect(await lunaEvent, 'to satisfy', { meta: { operationId: null } }); + }); + }); + describe('Socket status', () => { it(`should return status of anonSession`, async () => { const resp = await anonSession.sendAsync('status', null);