Compare commits

...

12 Commits

Author SHA1 Message Date
Tom Moor a7f0d206bf Merge branch 'main' of github.com:outline/outline into feat/5166/in-app-notifications 2023-05-15 21:42:56 -04:00
Apoorv Mishra c1c030423c fix: test for unseen 2023-04-22 20:39:47 +05:30
Apoorv Mishra fb6e2748ef fix: allow marking unseen 2023-04-22 20:34:12 +05:30
Apoorv Mishra bebde97bdc fix: return both total and unseen separately 2023-04-22 20:10:46 +05:30
Apoorv Mishra 83e0038716 fix: review 2023-04-22 16:19:39 +05:30
Apoorv Mishra 2e0a1c7be1 feat: notifications.update_all 2023-04-20 22:32:12 +05:30
Apoorv Mishra 8bd8ef848a feat: return archived notifications 2023-04-20 20:07:11 +05:30
Apoorv Mishra df3e9112af feat: notifications.update 2023-04-19 23:42:01 +05:30
Apoorv Mishra c18b24564f feat: add indexes 2023-04-19 19:04:26 +05:30
Apoorv Mishra 5a9af5fa15 feat: add archivedAt 2023-04-19 18:43:52 +05:30
Apoorv Mishra cbff931907 fix: complete user profile in unnecessary 2023-04-19 18:43:52 +05:30
Apoorv Mishra 007cbeaab6 feat: notifications.list 2023-04-19 18:43:52 +05:30
12 changed files with 1085 additions and 22 deletions
+187
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);
});
});
+56
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;
}
@@ -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,
});
});
},
};
@@ -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,
});
});
},
};
+7 -3
View File
@@ -11,8 +11,8 @@ import {
DataType,
Default,
AllowNull,
AfterSave,
Scopes,
AfterCreate,
} from "sequelize-typescript";
import { NotificationEventType } from "@shared/types";
import Collection from "./Collection";
@@ -66,7 +66,11 @@ class Notification extends Model {
@AllowNull
@Column
viewedAt: Date;
viewedAt: Date | null;
@AllowNull
@Column
archivedAt: Date | null;
@CreatedAt
createdAt: Date;
@@ -130,7 +134,7 @@ class Notification extends Model {
@Column(DataType.UUID)
teamId: string;
@AfterSave
@AfterCreate
static async createEvent(
model: Notification,
options: SaveOptions<Notification>
+3
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 = {};
+9
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;
});
+19
View File
@@ -0,0 +1,19 @@
import { Notification } from "@server/models";
import presentUser from "./user";
export default 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,
documentId: notification.documentId,
revisionId: notification.revisionId,
collectionId: notification.collectionId,
};
}
@@ -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);
});
});
@@ -1,11 +1,19 @@
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 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 pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
@@ -49,4 +57,122 @@ 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.scope(["withUser", "withActor"]).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: notifications.map((notification) =>
presentNotification(notification)
),
unseen,
},
};
}
);
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: 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;
+47 -18
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,51 @@ 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
>;
+24
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);
}