Notifications interface (#5354)

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
This commit is contained in:
Tom Moor
2023-05-20 10:47:32 -04:00
committed by GitHub
parent b1e2ff0713
commit ea885133ac
49 changed files with 1918 additions and 163 deletions

View File

@@ -1,10 +1,10 @@
import crypto from "crypto";
import Router from "koa-router";
import env from "@server/env";
import { AuthenticationError } from "@server/errors";
import validate from "@server/middlewares/validate";
import tasks from "@server/queues/tasks";
import { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
import * as T from "./schema";
const router = new Router();
@@ -13,13 +13,7 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
const token = (ctx.input.body.token ?? ctx.input.query.token) as string;
const limit = ctx.input.body.limit ?? ctx.input.query.limit;
if (
token.length !== env.UTILS_SECRET.length ||
!crypto.timingSafeEqual(
Buffer.from(env.UTILS_SECRET),
Buffer.from(String(token))
)
) {
if (!safeEqual(env.UTILS_SECRET, token)) {
throw AuthenticationError("Invalid secret token");
}

View File

@@ -0,0 +1,543 @@
import { randomElement } from "@shared/random";
import { NotificationEventType } from "@shared/types";
import {
buildCollection,
buildDocument,
buildNotification,
buildTeam,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#notifications.list", () => {
it("should return notifications in reverse chronological order", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
viewedAt: new Date(),
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.notifications.length).toBe(3);
expect(body.pagination.total).toBe(3);
expect(body.data.unseen).toBe(2);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.UpdateDocument);
expect(events).toContain(NotificationEventType.CreateComment);
expect(events).toContain(NotificationEventType.MentionedInComment);
});
it("should return notifications filtered by event type", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.list", {
body: {
token: user.getJwtToken(),
eventType: NotificationEventType.MentionedInComment,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.notifications.length).toBe(1);
expect(body.pagination.total).toBe(1);
expect(body.data.unseen).toBe(1);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.MentionedInComment);
});
it("should return archived notifications", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.list", {
body: {
token: user.getJwtToken(),
archived: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.notifications.length).toBe(2);
expect(body.pagination.total).toBe(2);
expect(body.data.unseen).toBe(2);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.CreateComment);
expect(events).toContain(NotificationEventType.UpdateDocument);
});
});
describe("#notifications.update", () => {
it("should mark notification as viewed", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const actor = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
createdById: actor.id,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
teamId: team.id,
documentId: document.id,
collectionId: collection.id,
userId: user.id,
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
});
expect(notification.viewedAt).toBeNull();
const res = await server.post("/api/notifications.update", {
body: {
token: user.getJwtToken(),
id: notification.id,
viewedAt: new Date(),
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe(notification.id);
expect(body.data.viewedAt).not.toBeNull();
});
it("should archive the notification", async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
});
const actor = await buildUser({
teamId: team.id,
});
const collection = await buildCollection({
teamId: team.id,
createdById: actor.id,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
teamId: team.id,
documentId: document.id,
collectionId: collection.id,
userId: user.id,
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
});
expect(notification.archivedAt).toBeNull();
const res = await server.post("/api/notifications.update", {
body: {
token: user.getJwtToken(),
id: notification.id,
archivedAt: new Date(),
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe(notification.id);
expect(body.data.archivedAt).not.toBeNull();
});
});
describe("#notifications.update_all", () => {
it("should perform no updates", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
viewedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.update_all", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.total).toBe(0);
});
it("should mark all notifications as viewed", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
viewedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.update_all", {
body: {
token: user.getJwtToken(),
viewedAt: new Date(),
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.total).toBe(2);
});
it("should mark all seen notifications as unseen", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
viewedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
viewedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.update_all", {
body: {
token: user.getJwtToken(),
viewedAt: null,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.total).toBe(2);
});
it("should archive all notifications", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.update_all", {
body: {
token: user.getJwtToken(),
archivedAt: new Date(),
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.total).toBe(2);
});
it("should unarchive all archived notifications", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.update_all", {
body: {
token: user.getJwtToken(),
archivedAt: null,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.total).toBe(2);
});
});

View File

@@ -1,14 +1,28 @@
import Router from "koa-router";
import { isNull, isUndefined } from "lodash";
import { WhereOptions, Op } from "sequelize";
import { NotificationEventType } from "@shared/types";
import notificationUpdater from "@server/commands/notificationUpdater";
import env from "@server/env";
import { AuthenticationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { User } from "@server/models";
import { Notification, User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import { authorize } from "@server/policies";
import { presentPolicies } from "@server/presenters";
import presentNotification from "@server/presenters/notification";
import { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
const pixel = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
"base64"
);
const handleUnsubscribe = async (
ctx: APIContext<T.NotificationsUnsubscribeReq>
@@ -49,4 +63,145 @@ router.post(
handleUnsubscribe
);
router.post(
"notifications.list",
auth(),
pagination(),
validate(T.NotificationsListSchema),
transaction(),
async (ctx: APIContext<T.NotificationsListReq>) => {
const { eventType, archived } = ctx.input.body;
const user = ctx.state.auth.user;
let where: WhereOptions<Notification> = {
userId: user.id,
};
if (eventType) {
where = { ...where, event: eventType };
}
if (archived) {
where = {
...where,
archivedAt: {
[Op.ne]: null,
},
};
}
const [notifications, total, unseen] = await Promise.all([
Notification.findAll({
where,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Notification.count({
where,
}),
Notification.count({
where: {
...where,
viewedAt: {
[Op.is]: null,
},
},
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: {
notifications: await Promise.all(
notifications.map(presentNotification)
),
unseen,
},
};
}
);
router.get(
"notifications.pixel",
transaction(),
async (ctx: APIContext<T.NotificationsPixelReq>) => {
const { id, token } = ctx.input.query;
const notification = await Notification.findByPk(id);
if (!notification || !safeEqual(token, notification.pixelToken)) {
throw AuthenticationError();
}
await notificationUpdater({
notification,
viewedAt: new Date(),
ip: ctx.request.ip,
transaction: ctx.state.transaction,
});
ctx.response.set("Content-Type", "image/gif");
ctx.body = pixel;
}
);
router.post(
"notifications.update",
auth(),
validate(T.NotificationsUpdateSchema),
transaction(),
async (ctx: APIContext<T.NotificationsUpdateReq>) => {
const { id, viewedAt, archivedAt } = ctx.input.body;
const { user } = ctx.state.auth;
const notification = await Notification.findByPk(id);
authorize(user, "update", notification);
await notificationUpdater({
notification,
viewedAt,
archivedAt,
ip: ctx.request.ip,
transaction: ctx.state.transaction,
});
ctx.body = {
data: await presentNotification(notification),
policies: presentPolicies(user, [notification]),
};
}
);
router.post(
"notifications.update_all",
auth(),
validate(T.NotificationsUpdateAllSchema),
async (ctx: APIContext<T.NotificationsUpdateAllReq>) => {
const { viewedAt, archivedAt } = ctx.input.body;
const { user } = ctx.state.auth;
const values: { [x: string]: any } = {};
let where: WhereOptions<Notification> = {
userId: user.id,
};
if (!isUndefined(viewedAt)) {
values.viewedAt = viewedAt;
where = {
...where,
viewedAt: !isNull(viewedAt) ? { [Op.is]: null } : { [Op.ne]: null },
};
}
if (!isUndefined(archivedAt)) {
values.archivedAt = archivedAt;
where = {
...where,
archivedAt: !isNull(archivedAt) ? { [Op.is]: null } : { [Op.ne]: null },
};
}
const [total] = await Notification.update(values, { where });
ctx.body = {
success: true,
data: { total },
};
}
);
export default router;

View File

@@ -1,8 +1,9 @@
import { isEmpty } from "lodash";
import { z } from "zod";
import { NotificationEventType } from "@shared/types";
import BaseSchema from "../BaseSchema";
export const NotificationSettingsCreateSchema = z.object({
export const NotificationSettingsCreateSchema = BaseSchema.extend({
body: z.object({
eventType: z.nativeEnum(NotificationEventType),
}),
@@ -12,7 +13,7 @@ export type NotificationSettingsCreateReq = z.infer<
typeof NotificationSettingsCreateSchema
>;
export const NotificationSettingsDeleteSchema = z.object({
export const NotificationSettingsDeleteSchema = BaseSchema.extend({
body: z.object({
eventType: z.nativeEnum(NotificationEventType),
}),
@@ -22,23 +23,60 @@ export type NotificationSettingsDeleteReq = z.infer<
typeof NotificationSettingsDeleteSchema
>;
export const NotificationsUnsubscribeSchema = z
.object({
body: z.object({
userId: z.string().uuid().optional(),
token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(),
}),
query: z.object({
userId: z.string().uuid().optional(),
token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(),
}),
})
.refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), {
message: "userId is required",
});
export const NotificationsUnsubscribeSchema = BaseSchema.extend({
body: z.object({
userId: z.string().uuid().optional(),
token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(),
}),
query: z.object({
userId: z.string().uuid().optional(),
token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(),
}),
}).refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), {
message: "userId is required",
});
export type NotificationsUnsubscribeReq = z.infer<
typeof NotificationsUnsubscribeSchema
>;
export const NotificationsListSchema = BaseSchema.extend({
body: z.object({
eventType: z.nativeEnum(NotificationEventType).nullish(),
archived: z.boolean().nullish(),
}),
});
export type NotificationsListReq = z.infer<typeof NotificationsListSchema>;
export const NotificationsUpdateSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
viewedAt: z.coerce.date().nullish(),
archivedAt: z.coerce.date().nullish(),
}),
});
export type NotificationsUpdateReq = z.infer<typeof NotificationsUpdateSchema>;
export const NotificationsUpdateAllSchema = BaseSchema.extend({
body: z.object({
viewedAt: z.coerce.date().nullish(),
archivedAt: z.coerce.date().nullish(),
}),
});
export type NotificationsUpdateAllReq = z.infer<
typeof NotificationsUpdateAllSchema
>;
export const NotificationsPixelSchema = BaseSchema.extend({
query: z.object({
id: z.string(),
token: z.string(),
}),
});
export type NotificationsPixelReq = z.infer<typeof NotificationsPixelSchema>;

View File

@@ -1,4 +1,3 @@
import crypto from "crypto";
import Router from "koa-router";
import { Op, WhereOptions } from "sequelize";
import { UserPreference } from "@shared/types";
@@ -23,6 +22,7 @@ import { can, authorize } from "@server/policies";
import { presentUser, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { safeEqual } from "@server/utils/crypto";
import {
assertIn,
assertSort,
@@ -469,14 +469,7 @@ router.post(
if ((!id || id === actor.id) && emailEnabled) {
const deleteConfirmationCode = user.deleteConfirmationCode;
if (
!code ||
code.length !== deleteConfirmationCode.length ||
!crypto.timingSafeEqual(
Buffer.from(code),
Buffer.from(deleteConfirmationCode)
)
) {
if (!safeEqual(code, deleteConfirmationCode)) {
throw ValidationError("The confirmation code was incorrect");
}
}