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

@@ -0,0 +1,187 @@
import { NotificationEventType } from "@shared/types";
import { sequelize } from "@server/database/sequelize";
import { Event } from "@server/models";
import {
buildUser,
buildNotification,
buildDocument,
buildCollection,
} from "@server/test/factories";
import { setupTestDatabase } from "@server/test/support";
import notificationUpdater from "./notificationUpdater";
setupTestDatabase();
describe("notificationUpdater", () => {
const ip = "127.0.0.1";
it("should mark the notification as viewed", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
});
expect(notification.archivedAt).toBe(null);
expect(notification.viewedAt).toBe(null);
await sequelize.transaction(async (transaction) =>
notificationUpdater({
notification,
viewedAt: new Date(),
ip,
transaction,
})
);
const event = await Event.findOne();
expect(notification.viewedAt).not.toBe(null);
expect(notification.archivedAt).toBe(null);
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
it("should mark the notification as unseen", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
viewedAt: new Date(),
});
expect(notification.archivedAt).toBe(null);
expect(notification.viewedAt).not.toBe(null);
await sequelize.transaction(async (transaction) =>
notificationUpdater({
notification,
viewedAt: null,
ip,
transaction,
})
);
const event = await Event.findOne();
expect(notification.viewedAt).toBe(null);
expect(notification.archivedAt).toBe(null);
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
it("should archive the notification", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
});
expect(notification.archivedAt).toBe(null);
expect(notification.viewedAt).toBe(null);
await sequelize.transaction(async (transaction) =>
notificationUpdater({
notification,
archivedAt: new Date(),
ip,
transaction,
})
);
const event = await Event.findOne();
expect(notification.viewedAt).toBe(null);
expect(notification.archivedAt).not.toBe(null);
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
it("should unarchive the notification", async () => {
const user = await buildUser();
const actor = await buildUser({
teamId: user.teamId,
});
const collection = await buildCollection({
teamId: user.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
createdById: actor.id,
});
const notification = await buildNotification({
actorId: actor.id,
event: NotificationEventType.UpdateDocument,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
collectionId: collection.id,
archivedAt: new Date(),
});
expect(notification.archivedAt).not.toBe(null);
expect(notification.viewedAt).toBe(null);
await sequelize.transaction(async (transaction) =>
notificationUpdater({
notification,
archivedAt: null,
ip,
transaction,
})
);
const event = await Event.findOne();
expect(notification.viewedAt).toBe(null);
expect(notification.archivedAt).toBeNull();
expect(event!.name).toEqual("notifications.update");
expect(event!.modelId).toEqual(notification.id);
});
});

View File

@@ -0,0 +1,56 @@
import { isUndefined } from "lodash";
import { Transaction } from "sequelize";
import { Event, Notification } from "@server/models";
type Props = {
/** Notification to be updated */
notification: Notification;
/** Time at which notification was viewed */
viewedAt?: Date | null;
/** Time at which notification was archived */
archivedAt?: Date | null;
/** The IP address of the user updating the notification */
ip: string;
/** The database transaction to run within */
transaction: Transaction;
};
/**
* This command updates notification properties.
*
* @param Props The properties of the notification to update
* @returns Notification The updated notification
*/
export default async function notificationUpdater({
notification,
viewedAt,
archivedAt,
ip,
transaction,
}: Props): Promise<Notification> {
if (!isUndefined(viewedAt)) {
notification.viewedAt = viewedAt;
}
if (!isUndefined(archivedAt)) {
notification.archivedAt = archivedAt;
}
const changed = notification.changed();
if (changed) {
await notification.save({ transaction });
await Event.create(
{
name: "notifications.update",
userId: notification.userId,
modelId: notification.id,
teamId: notification.teamId,
documentId: notification.documentId,
actorId: notification.actorId,
ip,
},
{ transaction }
);
}
return notification;
}

View File

@@ -0,0 +1,27 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.addColumn("notifications", "archivedAt", {
type: Sequelize.DATE,
allowNull: true,
transaction,
});
await queryInterface.addIndex("notifications", ["archivedAt"], {
transaction,
});
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.removeIndex("notifications", ["archivedAt"], {
transaction,
});
await queryInterface.removeColumn("notifications", "archivedAt", {
transaction,
});
});
},
};

View File

@@ -0,0 +1,36 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.addIndex("notifications", ["createdAt"], {
transaction,
});
await queryInterface.addIndex("notifications", ["event"], {
transaction,
});
await queryInterface.addIndex("notifications", ["viewedAt"], {
where: {
viewedAt: {
[Sequelize.Op.is]: null,
},
},
transaction,
});
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.removeIndex("notifications", ["createdAt"], {
transaction,
});
await queryInterface.removeIndex("notifications", ["event"], {
transaction,
});
await queryInterface.removeIndex("notifications", ["viewedAt"], {
transaction,
});
});
},
};

View File

@@ -1,3 +1,4 @@
import crypto from "crypto";
import type { SaveOptions } from "sequelize";
import {
Table,
@@ -11,10 +12,12 @@ import {
DataType,
Default,
AllowNull,
AfterSave,
Scopes,
AfterCreate,
DefaultScope,
} from "sequelize-typescript";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import Collection from "./Collection";
import Comment from "./Comment";
import Document from "./Document";
@@ -32,10 +35,17 @@ import Fix from "./decorators/Fix";
},
],
},
withUser: {
withDocument: {
include: [
{
association: "user",
association: "document",
},
],
},
withComment: {
include: [
{
association: "comment",
},
],
},
@@ -47,6 +57,19 @@ import Fix from "./decorators/Fix";
],
},
}))
@DefaultScope(() => ({
include: [
{
association: "document",
},
{
association: "comment",
},
{
association: "actor",
},
],
}))
@Table({
tableName: "notifications",
modelName: "notification",
@@ -66,7 +89,11 @@ class Notification extends Model {
@AllowNull
@Column
viewedAt: Date;
viewedAt: Date | null;
@AllowNull
@Column
archivedAt: Date | null;
@CreatedAt
createdAt: Date;
@@ -130,7 +157,7 @@ class Notification extends Model {
@Column(DataType.UUID)
teamId: string;
@AfterSave
@AfterCreate
static async createEvent(
model: Notification,
options: SaveOptions<Notification>
@@ -150,6 +177,18 @@ class Notification extends Model {
}
await Event.schedule(params);
}
/**
* Returns a token that can be used to mark this notification as read
* without being logged in.
*
* @returns A string token
*/
public get pixelToken() {
const hash = crypto.createHash("sha256");
hash.update(`${this.id}-${env.SECRET_KEY}`);
return hash.digest("hex");
}
}
export default Notification;

View File

@@ -7,6 +7,7 @@ import {
Comment,
Document,
Group,
Notification,
} from "@server/models";
import { _abilities, _can, _cannot, _authorize } from "./cancan";
import "./apiKey";
@@ -26,6 +27,7 @@ import "./user";
import "./team";
import "./group";
import "./webhookSubscription";
import "./notification";
type Policy = Record<string, boolean>;
@@ -55,6 +57,7 @@ export function serialize(
| Document
| User
| Group
| Notification
| null
): Policy {
const output = {};

View File

@@ -0,0 +1,9 @@
import { Notification, User } from "@server/models";
import { allow } from "./cancan";
allow(User, ["read", "update"], Notification, (user, notification) => {
if (!notification) {
return false;
}
return user?.id === notification.userId;
});

View File

@@ -0,0 +1,26 @@
import { Notification } from "@server/models";
import presentUser from "./user";
import { presentComment, presentDocument } from ".";
export default async function presentNotification(notification: Notification) {
return {
id: notification.id,
viewedAt: notification.viewedAt,
archivedAt: notification.archivedAt,
createdAt: notification.createdAt,
event: notification.event,
userId: notification.userId,
actorId: notification.actorId,
actor: notification.actor ? presentUser(notification.actor) : undefined,
commentId: notification.commentId,
comment: notification.comment
? presentComment(notification.comment)
: undefined,
documentId: notification.documentId,
document: notification.document
? await presentDocument(notification.document)
: undefined,
revisionId: notification.revisionId,
collectionId: notification.collectionId,
};
}

View File

@@ -13,6 +13,7 @@ import {
Star,
Team,
Subscription,
Notification,
} from "@server/models";
import {
presentComment,
@@ -25,6 +26,7 @@ import {
presentSubscription,
presentTeam,
} from "@server/presenters";
import presentNotification from "@server/presenters/notification";
import { Event } from "../../types";
export default class WebsocketsProcessor {
@@ -390,6 +392,17 @@ export default class WebsocketsProcessor {
});
}
case "notifications.create":
case "notifications.update": {
const notification = await Notification.findByPk(event.modelId);
if (!notification) {
return;
}
const data = await presentNotification(notification);
return socketio.to(`user-${event.userId}`).emit(event.name, data);
}
case "stars.create":
case "stars.update": {
const star = await Star.findByPk(event.modelId);

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

View File

@@ -6,6 +6,7 @@ import {
FileOperationType,
IntegrationService,
IntegrationType,
NotificationEventType,
} from "@shared/types";
import {
Share,
@@ -26,6 +27,7 @@ import {
WebhookDelivery,
ApiKey,
Subscription,
Notification,
} from "@server/models";
let count = 1;
@@ -493,3 +495,25 @@ export async function buildWebhookDelivery(
return WebhookDelivery.create(overrides);
}
export async function buildNotification(
overrides: Partial<Notification> = {}
): Promise<Notification> {
if (!overrides.event) {
overrides.event = NotificationEventType.UpdateDocument;
}
if (!overrides.teamId) {
const team = await buildTeam();
overrides.teamId = team.id;
}
if (!overrides.userId) {
const user = await buildUser({
teamId: overrides.teamId,
});
overrides.userId = user.id;
}
return Notification.create(overrides);
}

View File

@@ -358,7 +358,7 @@ export type WebhookSubscriptionEvent = BaseEvent & {
};
export type NotificationEvent = BaseEvent & {
name: "notifications.create";
name: "notifications.create" | "notifications.update";
modelId: string;
teamId: string;
userId: string;

19
server/utils/crypto.ts Normal file
View File

@@ -0,0 +1,19 @@
import crypto from "crypto";
/**
* Compare two strings in constant time to prevent timing attacks.
*
* @param a The first string to compare
* @param b The second string to compare
* @returns Whether the strings are equal
*/
export function safeEqual(a?: string, b?: string) {
if (!a || !b) {
return false;
}
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}