Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ 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.

- 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
Expand Down
31 changes: 24 additions & 7 deletions app/controllers/api/v1/PostsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
},
]);

Expand All @@ -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 } };
},
]);

Expand Down
12 changes: 8 additions & 4 deletions app/controllers/api/v2/CommentLikesController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
]);

Expand All @@ -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 };
},
]);

Expand Down
4 changes: 4 additions & 0 deletions app/models/auth-tokens/app-tokens-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,12 @@ 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',
'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',
Expand Down
12 changes: 8 additions & 4 deletions app/models/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>}
*/
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;
Expand All @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions app/models/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down
36 changes: 30 additions & 6 deletions app/pubsub-listener.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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,
Expand All @@ -784,16 +786,38 @@ 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);
}

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);
}

Expand Down
35 changes: 27 additions & 8 deletions app/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -126,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);
}

Expand Down
3 changes: 3 additions & 0 deletions app/routes/api/v1/PostsRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions app/routes/api/v2/CommentLikesRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 2 additions & 0 deletions app/support/DbAdapter/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export class DbAdapter {
): Promise<Map<UUID, { userId: UUID; createdAt: string; pinnedBy: UUID }[]>>;

// Likes
likePost(postId: UUID, userId: UUID): Promise<boolean>;
unlikePost(postId: UUID, userId: UUID): Promise<boolean>;

// Comments
Expand All @@ -278,6 +279,7 @@ export class DbAdapter {
getCommentBySeqNumber(postId: UUID, seqNumber: number): Promise<Comment | null>;

// Comment likes
createCommentLike(commentUUID: UUID, likerUUID: UUID): Promise<boolean>;
deleteCommentLike(commentUUID: UUID, likerUUID: UUID): Promise<boolean>;
getLikesInfoForComments(
commentsUUIDs: UUID[],
Expand Down
Loading
Loading