Skip to content

Commit

Permalink
Notify comment author about change of subscription to post comments
Browse files Browse the repository at this point in the history
The 'post:update' realtime event can now be fired to a comment author when comment is created or deleted. It happens when the action changes the user's subscription to comments state (the 'notifyOfAllComments' field of the serialized post).
  • Loading branch information
davidmz committed Dec 14, 2023
1 parent f2679db commit 0c4b8e6
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 3 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
purpose one can set the `notifyOfCommentsOnCommentedPosts` flag to _true_ in
their preferences.

### Changed
- The 'post:update' realtime event can now be fired to a comment author when
comment is created or deleted. It happens when the action changes the user's
subscription to comments state (the 'notifyOfAllComments' field of the
serialized post).

## [2.16.1] - 2023-11-25
### Fixed
- Handle 'post_comment' event in notifications digest.
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/api/v1/CommentsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
monitored,
commentAccessRequired,
} from '../../middlewares';
import { postMayUpdateForUser } from '../../middlewares/post-may-update-for-user';

import { commentCreateInputSchema, commentUpdateInputSchema } from './data-schemes';
import { getCommentsByIdsInputSchema } from './data-schemes/comments';
Expand All @@ -33,6 +34,7 @@ export const create = compose([
await next();
},
postAccessRequired(),
postMayUpdateForUser(),
monitored('comments.create'),
async (ctx) => {
const { user: author, post } = ctx.state;
Expand Down Expand Up @@ -83,6 +85,7 @@ export const destroy = compose([
authRequired(),
// Post owner or group admin can delete hidden comments
commentAccessRequired({ mustBeVisible: false }),
postMayUpdateForUser((s) => s.comment.getCreatedBy()),
monitored('comments.destroy'),
async (ctx) => {
const { user, post, comment } = ctx.state;
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/middlewares/post-may-update-for-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Middleware } from 'koa';
import { isEqual } from 'lodash';

import { type User, type Post, PubSub as pubSub, dbAdapter } from '../../models';
import { ServerErrorException } from '../../support/exceptions';
import { List } from '../../support/open-lists';

type State = { post?: Post; user?: User };

/**
* Middleware that checks if user specific properties of the post was changed
* during the request and emits an RT message if so.
*/
export function postMayUpdateForUser(
selectUser = (s: State) => Promise.resolve(s.user),
): Middleware<State> {
return async (ctx, next) => {
let { post } = ctx.state;
const user = await selectUser(ctx.state);

if (!post) {
throw new ServerErrorException(
`Server misconfiguration: the required parameter 'postId' is missing`,
);
}

if (!user) {
return await next();
}

const propsBefore = await post.getUserSpecificProps(user);

const result = await next();

// Re-read updated post from DB
post = (await dbAdapter.getPostById(post.id))!;
const propsAfter = await post.getUserSpecificProps(user);

if (!isEqual(propsBefore, propsAfter)) {
// Emit RT message
await pubSub.updatePost(post.id, { onlyForUsers: List.from([user.id]) });
}

return result;
};
}
7 changes: 7 additions & 0 deletions app/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export class Group {
unblockUser(userId: UUID, adminId: UUID): Promise<boolean>;
}

type PostUserState = {
subscribedToComments: boolean;
saved: boolean;
hidden: boolean;
};

export class Post {
id: UUID;
intId: number;
Expand All @@ -156,6 +162,7 @@ export class Post {
removeDirectRecipient(user: User): Promise<boolean>;
isVisibleFor(viewer: Nullable<User>): Promise<boolean>;
getCommentsListeners(): Promise<UUID[]>;
getUserSpecificProps(user: User): Promise<PostUserState>;
}

export class Timeline {
Expand Down
3 changes: 0 additions & 3 deletions app/models/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,6 @@ export function addModel(dbAdapter) {
if (this.hideType === Comment.VISIBLE) {
return;
}

this.body = Comment.hiddenBody(this.hideType);
this.userId = null;
}

/**
Expand Down
26 changes: 26 additions & 0 deletions app/models/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,32 @@ export function addModel(dbAdapter) {

return [...listeners];
}

/**
* Returns collection of properties that represents user-specific state of
* this post (how this post looks for this user). For now, the following
* properties are supported:
*
* - subscribedToComments
* - saved
* - hidden
*
* @typedef { {subscribedToComments: boolean, saved: boolean, hidden:
* boolean} } State
* @param {User} user
* @returns {Promise<State>}
*/
async getUserSpecificProps(user) {
const [subscribers, feeds] = await Promise.all([
this.getCommentsListeners(),
this.getTimelines(),
]);
return {
subscribedToComments: subscribers.includes(user.id),
saved: feeds.some((f) => f.userId === user.id && f.isSaves()),
hidden: feeds.some((f) => f.userId === user.id && f.isHides()),
};
}
}

return Post;
Expand Down
131 changes: 131 additions & 0 deletions test/functional/realtime-post-comment-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/* eslint-env node, mocha */
import expect from 'unexpected';

import { getSingleton } from '../../app/app';
import { PubSub } from '../../app/models';
import { PubSubAdapter, eventNames as ev } from '../../app/support/PubSubAdapter';
import redisDb from '../../app/setup/database';
import { connect as pgConnect } from '../../app/setup/postgres';
import cleanDB from '../dbCleaner';

import Session from './realtime-session';
import {
createTestUsers,
createAndReturnPost,
updateUserAsync,
createCommentAsync,
performJSONRequest,
authHeaders,
} from './functional_test_helper';

describe('Realtime of post comment events', () => {
let port;

before(async () => {
const app = await getSingleton();
port = process.env.PEPYATKA_SERVER_PORT || app.context.config.port;
const pubsubAdapter = new PubSubAdapter(redisDb);
PubSub.setPublisher(pubsubAdapter);
});

let luna, mars, lunaSession, marsSession;

beforeEach(async () => {
await cleanDB(pgConnect());

[luna, mars] = await createTestUsers(['luna', 'mars']);

[lunaSession, marsSession] = await Promise.all([
Session.create(port, 'Luna session'),
Session.create(port, 'Mars session'),
]);

await Promise.all([
lunaSession.sendAsync('auth', { authToken: luna.authToken }),
marsSession.sendAsync('auth', { authToken: mars.authToken }),
]);
});

describe(`Mars wants to receive comments on commented post`, () => {
beforeEach(async () => {
await updateUserAsync(mars, { preferences: { notifyOfCommentsOnCommentedPosts: true } });
});

describe(`Luna creates post, Luna & Mars subscribes to it`, () => {
let post;
beforeEach(async () => {
post = await createAndReturnPost(luna, 'Luna post');
await Promise.all([
lunaSession.sendAsync('subscribe', { post: [post.id] }),
marsSession.sendAsync('subscribe', { post: [post.id] }),
]);
});

it(`should not deliver ${ev.POST_UPDATED} event to Luna after Luna's comment`, async () => {
const test = lunaSession.notReceiveWhile(ev.POST_UPDATED, () =>
createCommentAsync(luna, post.id, 'Hello'),
);
await expect(test, 'to be fulfilled');
});

it(`should not deliver ${ev.POST_UPDATED} event to Mars after Luna's comment`, async () => {
const test = marsSession.notReceiveWhile(ev.POST_UPDATED, () =>
createCommentAsync(luna, post.id, 'Hello'),
);
await expect(test, 'to be fulfilled');
});

it(`should not deliver ${ev.POST_UPDATED} event to Luna after Mars' comment`, async () => {
const test = lunaSession.notReceiveWhile(ev.POST_UPDATED, () =>
createCommentAsync(mars, post.id, 'Hello'),
);
await expect(test, 'to be fulfilled');
});

it(`should deliver ${ev.POST_UPDATED} event with 'notifyOfAllComments: true' to Mars after Mars' comment`, async () => {
const test = marsSession.receiveWhile(ev.POST_UPDATED, () =>
createCommentAsync(mars, post.id, 'Hello'),
);
await expect(test, 'to be fulfilled with', { posts: { notifyOfAllComments: true } });
});

it(`should deliver ${ev.POST_UPDATED} event with 'notifyOfAllComments: false' to Mars after Mars removes their comment`, async () => {
let commentId;

{
const test = marsSession.receiveWhile(ev.POST_UPDATED, async () => {
const resp = await createCommentAsync(mars, post.id, 'Hello').then((r) => r.json());
commentId = resp.comments.id;
});
await expect(test, 'to be fulfilled with', { posts: { notifyOfAllComments: true } });
}

{
const test = marsSession.receiveWhile(ev.POST_UPDATED, () =>
performJSONRequest('DELETE', `/v2/comments/${commentId}`, null, authHeaders(mars)),
);
await expect(test, 'to be fulfilled with', { posts: { notifyOfAllComments: false } });
}
});

it(`should deliver ${ev.POST_UPDATED} event with 'notifyOfAllComments: false' to Mars after Luna removes Mars' comment`, async () => {
let commentId;

{
const test = marsSession.receiveWhile(ev.POST_UPDATED, async () => {
const resp = await createCommentAsync(mars, post.id, 'Hello').then((r) => r.json());
commentId = resp.comments.id;
});
await expect(test, 'to be fulfilled with', { posts: { notifyOfAllComments: true } });
}

{
const test = marsSession.receiveWhile(ev.POST_UPDATED, () =>
performJSONRequest('DELETE', `/v2/comments/${commentId}`, null, authHeaders(luna)),
);
await expect(test, 'to be fulfilled with', { posts: { notifyOfAllComments: false } });
}
});
});
});
});

0 comments on commit 0c4b8e6

Please sign in to comment.