Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/rpc/src/modules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import { getWorkspaceService } from "./workspace-sync/workspace-service"
import { Auth0JwtService } from "../lib/auth0-jwt"
import { getContestRepository } from "./contest/contest-repository"
import { getContestService } from "./contest/contest-service"
import { getNotificationRepository } from "./notification/notification-repository"
import { getNotificationService } from "./notification/notification-service"

export type ServiceLayer = Awaited<ReturnType<typeof createServiceLayer>>

Expand Down Expand Up @@ -187,6 +189,7 @@ export async function createServiceLayer(
const feedbackFormRepository = getFeedbackFormRepository()
const feedbackFormAnswerRepository = getFeedbackFormAnswerRepository()
const contestRepository = getContestRepository()
const notificationRepository = getNotificationRepository()

const membershipService = getMembershipService()
const emailService = isAmazonSesEmailFeatureEnabled(configuration)
Expand Down Expand Up @@ -217,6 +220,7 @@ export async function createServiceLayer(
attendanceRepository
)
const feedbackFormAnswerService = getFeedbackFormAnswerService(feedbackFormAnswerRepository, feedbackFormService)
const notificationService = getNotificationService(notificationRepository)
const taskDiscoveryService = getLocalTaskDiscoveryService(clients.prisma, taskService, recurringTaskService)
const attendanceService = getAttendanceService(
eventEmitter,
Expand Down Expand Up @@ -292,6 +296,7 @@ export async function createServiceLayer(
contestService,
recurringTaskService,
workspaceService,
notificationService,

rpcJwtService: clients.rpcJwtService,
webJwtService: clients.webJwtService,
Expand Down
120 changes: 120 additions & 0 deletions apps/rpc/src/modules/notification/notification-repository.ts
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
},
Comment on lines +46 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical bug: recipientIds are extracted from notificationData but 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:

async create(handle, notificationData) {
  const { recipientIds, ...data } = notificationData
  const notification = await handle.notification.create({ data })
  await this.addRecipients(handle, notification.id, recipientIds)
  return notification
}
Suggested change
async create(handle, notificationData) {
const { recipientIds, ...data } = notificationData
const notification = await handle.notification.create({ data })
return notification
},
async create(handle, notificationData) {
const { recipientIds, ...data } = notificationData
const notification = await handle.notification.create({ data })
await this.addRecipients(handle, notification.id, recipientIds)
return notification
},

Spotted by Graphite

Fix in Graphite


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

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: recipientIds are extracted from notificationData but ignored during update. If the API allows passing recipientIds in updates, they will be silently ignored, causing unexpected behavior.

Either remove recipientIds from the destructuring if updates shouldn't modify recipients, or implement the recipient update logic:

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
async update(handle, notificationId, notificationData) {
const { recipientIds, ...data } = notificationData
const notification = await handle.notification.update({
where: { id: notificationId },
data,
})
return notification
},
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
},

Spotted by Graphite

Fix in Graphite


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

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() },
})
},
}
}
104 changes: 104 additions & 0 deletions apps/rpc/src/modules/notification/notification-router.ts
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,
})
82 changes: 82 additions & 0 deletions apps/rpc/src/modules/notification/notification-service.ts
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)
},
}
}
35 changes: 35 additions & 0 deletions apps/rpc/src/modules/notification/notification.ts
Comment thread
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),
})
4 changes: 2 additions & 2 deletions packages/db/generated/prisma/internal/class.ts

Large diffs are not rendered by default.

Loading
Loading