-
Notifications
You must be signed in to change notification settings - Fork 9
Create backend for notifications #2957
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,120 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import type { DBHandle } from "@dotkomonline/db" | ||||||||||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||||||||||
| Notification, | ||||||||||||||||||||||||||||||||||||||||||||
| NotificationId, | ||||||||||||||||||||||||||||||||||||||||||||
| NotificationWrite, | ||||||||||||||||||||||||||||||||||||||||||||
| NotificationRecipientId, | ||||||||||||||||||||||||||||||||||||||||||||
| NotificationRecipient, | ||||||||||||||||||||||||||||||||||||||||||||
| UserNotification, | ||||||||||||||||||||||||||||||||||||||||||||
| } from "./notification" | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import type { UserId } from "@dotkomonline/types" | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export interface NotificationRepository { | ||||||||||||||||||||||||||||||||||||||||||||
| findById(handle: DBHandle, notificationId: NotificationId): Promise<Notification | null> | ||||||||||||||||||||||||||||||||||||||||||||
| create(handle: DBHandle, notificationData: NotificationWrite): Promise<Notification> | ||||||||||||||||||||||||||||||||||||||||||||
| update( | ||||||||||||||||||||||||||||||||||||||||||||
| handle: DBHandle, | ||||||||||||||||||||||||||||||||||||||||||||
| notificationId: NotificationId, | ||||||||||||||||||||||||||||||||||||||||||||
| notificationData: Partial<NotificationWrite> | ||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<Notification> | ||||||||||||||||||||||||||||||||||||||||||||
| delete(handle: DBHandle, notificationId: NotificationId): Promise<Notification | null> | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| addRecipients(handle: DBHandle, notificationId: NotificationId, recipientIds: UserId[]): Promise<void> | ||||||||||||||||||||||||||||||||||||||||||||
| removeRecipients(handle: DBHandle, notificationId: NotificationId, recipientIds: UserId[]): Promise<void> | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| findRecipient( | ||||||||||||||||||||||||||||||||||||||||||||
| handle: DBHandle, | ||||||||||||||||||||||||||||||||||||||||||||
| recipientId: NotificationRecipientId, | ||||||||||||||||||||||||||||||||||||||||||||
| userId: UserId | ||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<NotificationRecipient | null> | ||||||||||||||||||||||||||||||||||||||||||||
| findAllForUser(handle: DBHandle, userId: UserId): Promise<UserNotification[]> | ||||||||||||||||||||||||||||||||||||||||||||
| getUnreadCountForUser(handle: DBHandle, userId: UserId): Promise<number> | ||||||||||||||||||||||||||||||||||||||||||||
| markAsRead(handle: DBHandle, notificationId: NotificationId, userId: UserId): Promise<void> | ||||||||||||||||||||||||||||||||||||||||||||
| markAllAsRead(handle: DBHandle, userId: UserId): Promise<void> | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function getNotificationRepository(): NotificationRepository { | ||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||
| async findById(handle, notificationId) { | ||||||||||||||||||||||||||||||||||||||||||||
| const notification = await handle.notification.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { id: notificationId }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| return notification | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| async create(handle, notificationData) { | ||||||||||||||||||||||||||||||||||||||||||||
| const { recipientIds, ...data } = notificationData | ||||||||||||||||||||||||||||||||||||||||||||
| const notification = await handle.notification.create({ data }) | ||||||||||||||||||||||||||||||||||||||||||||
| return notification | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| async update(handle, notificationId, notificationData) { | ||||||||||||||||||||||||||||||||||||||||||||
| const { recipientIds, ...data } = notificationData | ||||||||||||||||||||||||||||||||||||||||||||
| const notification = await handle.notification.update({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { id: notificationId }, | ||||||||||||||||||||||||||||||||||||||||||||
| data, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| return notification | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Either remove async update(handle, notificationId, notificationData) {
const { recipientIds, ...data } = notificationData
const notification = await handle.notification.update({
where: { id: notificationId },
data,
})
if (recipientIds !== undefined) {
await this.addRecipients(handle, notification.id, recipientIds)
}
return notification
}
Suggested change
Spotted by Graphite |
||||||||||||||||||||||||||||||||||||||||||||
| async delete(handle, notificationId) { | ||||||||||||||||||||||||||||||||||||||||||||
| const notification = await handle.notification.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { id: notificationId }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| await handle.notification.delete({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { id: notificationId }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| return notification | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| async findRecipient(handle, recipientId, userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| const recipient = await handle.notificationRecipient.findFirst({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||||||||||||||||||
| id: recipientId, | ||||||||||||||||||||||||||||||||||||||||||||
| userId, | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| return recipient | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| async addRecipients(handle, notificationId, recipientIds) { | ||||||||||||||||||||||||||||||||||||||||||||
| await handle.notificationRecipient.createMany({ | ||||||||||||||||||||||||||||||||||||||||||||
| data: recipientIds.map((userId) => ({ | ||||||||||||||||||||||||||||||||||||||||||||
| notificationId, | ||||||||||||||||||||||||||||||||||||||||||||
| userId, | ||||||||||||||||||||||||||||||||||||||||||||
| })), | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| async removeRecipients(handle, notificationId, recipientIds) { | ||||||||||||||||||||||||||||||||||||||||||||
| await handle.notificationRecipient.deleteMany({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||||||||||||||||||
| notificationId, | ||||||||||||||||||||||||||||||||||||||||||||
| userId: { in: recipientIds }, | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| async findAllForUser(handle, userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| return handle.notificationRecipient.findMany({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { userId }, | ||||||||||||||||||||||||||||||||||||||||||||
| include: { notification: true }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| async getUnreadCountForUser(handle, userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| return handle.notificationRecipient.count({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { userId, readAt: null }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| async markAsRead(handle, notificationId, userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| await handle.notificationRecipient.updateMany({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { notificationId, userId, readAt: null }, | ||||||||||||||||||||||||||||||||||||||||||||
| data: { readAt: new Date() }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| async markAllAsRead(handle, userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| await handle.notificationRecipient.updateMany({ | ||||||||||||||||||||||||||||||||||||||||||||
| where: { userId, readAt: null }, | ||||||||||||||||||||||||||||||||||||||||||||
| data: { readAt: new Date() }, | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import type { inferProcedureInput, inferProcedureOutput } from "@trpc/server" | ||
| import { z } from "zod" | ||
| import { isEditor } from "../../authorization" | ||
| import { withAuditLogEntry, withAuthentication, withAuthorization, withDatabaseTransaction } from "../../middlewares" | ||
| import { procedure, t } from "../../trpc" | ||
| import { NotificationSchema, NotificationWriteSchema } from "./notification" | ||
|
|
||
| export type GetNotificationInput = inferProcedureInput<typeof getNotificationProcedure> | ||
| export type GetNotificationOutput = inferProcedureOutput<typeof getNotificationProcedure> | ||
| const getNotificationProcedure = procedure | ||
| .input(NotificationSchema.shape.id) | ||
| .use(withDatabaseTransaction()) | ||
| .query(async ({ input, ctx }) => { | ||
| return ctx.notificationService.findById(ctx.handle, input) | ||
| }) | ||
|
|
||
| export type CreateNotificationInput = inferProcedureInput<typeof createNotificationProcedure> | ||
| export type CreateNotificationOutput = inferProcedureOutput<typeof createNotificationProcedure> | ||
| const createNotificationProcedure = procedure | ||
| .input(NotificationWriteSchema) | ||
| .use(withAuthentication()) | ||
| .use(withAuthorization(isEditor())) | ||
| .use(withDatabaseTransaction()) | ||
| .use(withAuditLogEntry()) | ||
| .mutation(async ({ input, ctx }) => { | ||
| return ctx.notificationService.create(ctx.handle, input) | ||
| }) | ||
|
|
||
| export type EditNotificationInput = inferProcedureInput<typeof editNotificationProcedure> | ||
| export type EditNotificationOutput = inferProcedureOutput<typeof editNotificationProcedure> | ||
| const editNotificationProcedure = procedure | ||
| .input( | ||
| z.object({ | ||
| id: NotificationSchema.shape.id, | ||
| input: NotificationWriteSchema.partial(), | ||
| }) | ||
| ) | ||
| .use(withAuthentication()) | ||
| .use(withAuthorization(isEditor())) | ||
| .use(withDatabaseTransaction()) | ||
| .use(withAuditLogEntry()) | ||
| .mutation(async ({ input: changes, ctx }) => { | ||
| return ctx.notificationService.update(ctx.handle, changes.id, changes.input) | ||
| }) | ||
|
|
||
| export type DeleteNotificationInput = inferProcedureInput<typeof deleteNotificationProcedure> | ||
| export type DeleteNotificationOutput = inferProcedureOutput<typeof deleteNotificationProcedure> | ||
| const deleteNotificationProcedure = procedure | ||
| .input(NotificationSchema.shape.id) | ||
| .use(withAuthentication()) | ||
| .use(withAuthorization(isEditor())) | ||
| .use(withDatabaseTransaction()) | ||
| .use(withAuditLogEntry()) | ||
| .mutation(async ({ input, ctx }) => { | ||
| return ctx.notificationService.delete(ctx.handle, input) | ||
| }) | ||
|
|
||
| export type GetMyNotificationsInput = inferProcedureInput<typeof getMyNotificationsProcedure> | ||
| export type GetMyNotificationsOutput = inferProcedureOutput<typeof getMyNotificationsProcedure> | ||
| const getMyNotificationsProcedure = procedure | ||
| .use(withAuthentication()) | ||
| .use(withDatabaseTransaction()) | ||
| .query(async ({ ctx }) => { | ||
| return ctx.notificationService.findAllForUser(ctx.handle, ctx.principal.subject) | ||
| }) | ||
|
|
||
| export type GetUnreadCountInput = inferProcedureInput<typeof getUnreadCountProcedure> | ||
| export type GetUnreadCountOutput = inferProcedureOutput<typeof getUnreadCountProcedure> | ||
| const getUnreadCountProcedure = procedure | ||
| .use(withAuthentication()) | ||
| .use(withDatabaseTransaction()) | ||
| .query(async ({ ctx }) => { | ||
| return ctx.notificationService.getUnreadCountForUser(ctx.handle, ctx.principal.subject) | ||
| }) | ||
|
|
||
| export type MarkAsReadInput = inferProcedureInput<typeof markAsReadProcedure> | ||
| export type MarkAsReadOutput = inferProcedureOutput<typeof markAsReadProcedure> | ||
| const markAsReadProcedure = procedure | ||
| .input(z.object({ notificationId: NotificationSchema.shape.id })) | ||
| .use(withAuthentication()) | ||
| .use(withDatabaseTransaction()) | ||
| .mutation(async ({ input, ctx }) => { | ||
| return ctx.notificationService.markAsRead(ctx.handle, input.notificationId, ctx.principal.subject) | ||
| }) | ||
|
|
||
| export type MarkAllAsReadInput = inferProcedureInput<typeof markAllAsReadProcedure> | ||
| export type MarkAllAsReadOutput = inferProcedureOutput<typeof markAllAsReadProcedure> | ||
| const markAllAsReadProcedure = procedure | ||
| .use(withAuthentication()) | ||
| .use(withDatabaseTransaction()) | ||
| .mutation(async ({ ctx }) => { | ||
| return ctx.notificationService.markAllAsRead(ctx.handle, ctx.principal.subject) | ||
| }) | ||
|
|
||
| export const notificationRouter = t.router({ | ||
| get: getNotificationProcedure, | ||
| create: createNotificationProcedure, | ||
| edit: editNotificationProcedure, | ||
| delete: deleteNotificationProcedure, | ||
| getMyNotifications: getMyNotificationsProcedure, | ||
| getUnreadCount: getUnreadCountProcedure, | ||
| markAsRead: markAsReadProcedure, | ||
| markAllAsRead: markAllAsReadProcedure, | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import type { DBHandle } from "@dotkomonline/db" | ||
| import type { | ||
| Notification, | ||
| NotificationId, | ||
| NotificationRecipient, | ||
| NotificationRecipientId, | ||
| NotificationWrite, | ||
| UserNotification, | ||
| } from "./notification" | ||
| import type { UserId } from "@dotkomonline/types" | ||
| import type { NotificationRepository } from "./notification-repository" | ||
|
|
||
| export interface NotificationService { | ||
| findById(handle: DBHandle, notificationId: NotificationId): Promise<Notification | null> | ||
| create(handle: DBHandle, notificationData: NotificationWrite): Promise<Notification> | ||
| update( | ||
| handle: DBHandle, | ||
| notificationId: NotificationId, | ||
| notificationData: Partial<NotificationWrite> | ||
| ): Promise<Notification> | ||
| delete(handle: DBHandle, notificationId: NotificationId): Promise<Notification | null> | ||
|
|
||
| addRecipients(handle: DBHandle, notificationId: NotificationId, recipientIds: UserId[]): Promise<void> | ||
| removeRecipients(handle: DBHandle, notificationId: NotificationId, recipientIds: UserId[]): Promise<void> | ||
|
|
||
| findRecipient( | ||
| handle: DBHandle, | ||
| recipientId: NotificationRecipientId, | ||
| userId: UserId | ||
| ): Promise<NotificationRecipient | null> | ||
| findAllForUser(handle: DBHandle, userId: UserId): Promise<UserNotification[]> | ||
| getUnreadCountForUser(handle: DBHandle, userId: UserId): Promise<number> | ||
| markAsRead(handle: DBHandle, notificationId: NotificationId, userId: UserId): Promise<void> | ||
| markAllAsRead(handle: DBHandle, userId: UserId): Promise<void> | ||
| } | ||
|
|
||
| export function getNotificationService(notificationRepository: NotificationRepository): NotificationService { | ||
| return { | ||
| async findById(handle, notificationId) { | ||
| return await notificationRepository.findById(handle, notificationId) | ||
| }, | ||
| async create(handle, notificationData) { | ||
| return await notificationRepository.create(handle, notificationData) | ||
| }, | ||
|
|
||
| async update(handle, notificationId, notificationData) { | ||
| return await notificationRepository.update(handle, notificationId, notificationData) | ||
| }, | ||
|
|
||
| async delete(handle, notificationId) { | ||
| return await notificationRepository.delete(handle, notificationId) | ||
| }, | ||
|
|
||
| async addRecipients(handle, notificationId, recipientIds) { | ||
| await notificationRepository.addRecipients(handle, notificationId, recipientIds) | ||
| }, | ||
|
|
||
| async removeRecipients(handle, notificationId, recipientIds) { | ||
| await notificationRepository.removeRecipients(handle, notificationId, recipientIds) | ||
| }, | ||
|
|
||
| async findRecipient(handle, recipientId, userId) { | ||
| return await notificationRepository.findRecipient(handle, recipientId, userId) | ||
| }, | ||
|
|
||
| async findAllForUser(handle, userId) { | ||
| return await notificationRepository.findAllForUser(handle, userId) | ||
| }, | ||
|
|
||
| async getUnreadCountForUser(handle, userId) { | ||
| return await notificationRepository.getUnreadCountForUser(handle, userId) | ||
| }, | ||
|
|
||
| async markAsRead(handle, notificationId, userId) { | ||
| await notificationRepository.markAsRead(handle, notificationId, userId) | ||
| }, | ||
|
|
||
| async markAllAsRead(handle, userId) { | ||
| await notificationRepository.markAllAsRead(handle, userId) | ||
| }, | ||
| } | ||
| } |
|
brage-andreas marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { schemas } from "@dotkomonline/db/schemas" | ||
| import { z } from "zod" | ||
|
|
||
| export type Notification = z.infer<typeof NotificationSchema> | ||
| export const NotificationSchema = schemas.NotificationSchema | ||
| export type NotificationId = Notification["id"] | ||
| export type NotificationType = Notification["type"] | ||
| export type NotificationPayloadType = Notification["payloadType"] | ||
|
|
||
| export const NotificationTypeSchema = schemas.NotificationTypeSchema | ||
| export const NotificationPayloadTypeSchema = schemas.NotificationPayloadTypeSchema | ||
|
|
||
| export const NotificationRecipientSchema = schemas.NotificationRecipientSchema | ||
| export type NotificationRecipient = z.infer<typeof NotificationRecipientSchema> | ||
|
|
||
| export type NotificationRecipientId = NotificationRecipient["id"] | ||
|
|
||
| export type UserNotification = z.infer<typeof UserNotificationSchema> | ||
| export const UserNotificationSchema = schemas.NotificationRecipientSchema.extend({ | ||
| notification: schemas.NotificationSchema, | ||
| }) | ||
|
|
||
| export type NotificationWrite = z.infer<typeof NotificationWriteSchema> | ||
| export const NotificationWriteSchema = NotificationSchema.pick({ | ||
| title: true, | ||
| shortDescription: true, | ||
| content: true, | ||
| type: true, | ||
| payload: true, | ||
| payloadType: true, | ||
| actorGroupId: true, | ||
| taskId: true, | ||
| }).extend({ | ||
| recipientIds: z.array(z.string().uuid()).min(1), | ||
| }) |
Large diffs are not rendered by default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical bug:
recipientIdsare extracted fromnotificationDatabut never used to create the recipient records. When a notification is created, no recipients will be added to the database, making the notification invisible to users.Fix:
Spotted by Graphite

Is this helpful? React 👍 or 👎 to let us know.