diff --git a/apps/rpc/src/bin/server.ts b/apps/rpc/src/bin/server.ts index 2d9bbc0c03..3284639dc8 100644 --- a/apps/rpc/src/bin/server.ts +++ b/apps/rpc/src/bin/server.ts @@ -59,10 +59,24 @@ controller.signal.addEventListener("abort", () => { }) export async function createFastifyContext({ req }: CreateFastifyContextOptions) { + let rawToken: string | undefined + const bearer = req.headers.authorization if (bearer !== undefined) { - const token = bearer.substring("Bearer ".length) - const principal = await dependencies.rpcJwtService.verify(token) + rawToken = bearer.substring("Bearer ".length) + } else if (typeof req.query === "object" && req.query !== null && "connectionParams" in req.query) { + try { + const params = JSON.parse(req.query.connectionParams as string) + if (typeof params.token === "string") { + rawToken = params.token + } + } catch { + // malformed connectionParams — treat as unauthenticated + } + } + + if (rawToken !== undefined) { + const principal = await dependencies.rpcJwtService.verify(rawToken) const subject = principal.payload.sub if (subject === undefined) { return createTrpcContext(null, serviceLayer) diff --git a/apps/rpc/src/modules/article/article-service.ts b/apps/rpc/src/modules/article/article-service.ts index fb9a3ab452..aea1c4b02b 100644 --- a/apps/rpc/src/modules/article/article-service.ts +++ b/apps/rpc/src/modules/article/article-service.ts @@ -19,6 +19,7 @@ import type { Pageable } from "@dotkomonline/utils" import type { ArticleRepository } from "./article-repository" import type { ArticleTagLinkRepository } from "./article-tag-link-repository" import type { ArticleTagRepository } from "./article-tag-repository" +import type { NotificationService } from "../notification/notification-service" export interface ArticleService { create(handle: DBHandle, data: ArticleWrite): Promise
@@ -61,6 +62,7 @@ export function getArticleService( articleRepository: ArticleRepository, articleTagRepository: ArticleTagRepository, articleTagLinkRepository: ArticleTagLinkRepository, + notificationService: NotificationService, s3Client: S3Client, s3BucketName: string ): ArticleService { @@ -72,7 +74,21 @@ export function getArticleService( throw new AlreadyExistsError(`Article(Slug=${data.slug}) already exists`) } - return await articleRepository.create(handle, data) + const createdArticle = await articleRepository.create(handle, data) + + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "NEW_ARTICLE") + await notificationService.create( + handle, + recipients, + "NEW_ARTICLE", + `Ny artikkel: ${data.title}`, + `En ny artikkel "${data.title}" har blitt publisert.`, + null, + "ARTICLE", + data.slug + ) + + return createdArticle }, async update(handle, articleId, data) { diff --git a/apps/rpc/src/modules/core.ts b/apps/rpc/src/modules/core.ts index 530aefb860..a608bd7cd6 100644 --- a/apps/rpc/src/modules/core.ts +++ b/apps/rpc/src/modules/core.ts @@ -59,6 +59,7 @@ import { getContestRepository } from "./contest/contest-repository" import { getContestService } from "./contest/contest-service" import { getNotificationRepository } from "./notification/notification-repository" import { getNotificationService } from "./notification/notification-service" +import { getNotificationTaskService } from "./notification/notification-task-service" export type ServiceLayer = Awaited> @@ -191,6 +192,7 @@ export async function createServiceLayer( const contestRepository = getContestRepository() const notificationRepository = getNotificationRepository() + const notificationService = getNotificationService(notificationRepository, userRepository, attendanceRepository, eventEmitter) const membershipService = getMembershipService() const emailService = isAmazonSesEmailFeatureEnabled(configuration) ? getEmailService(clients.sesClient, clients.sqsClient, configuration) @@ -203,15 +205,35 @@ export async function createServiceLayer( clients.s3Client, configuration.AWS_S3_BUCKET ) - const groupService = getGroupService(groupRepository, userService, clients.s3Client, configuration.AWS_S3_BUCKET) - const jobListingService = getJobListingService(jobListingRepository) + const notificationTaskService = getNotificationTaskService(notificationService, attendanceRepository, eventRepository) + const groupService = getGroupService( + groupRepository, + userService, + notificationService, + clients.s3Client, + configuration.AWS_S3_BUCKET + ) + const jobListingService = getJobListingService(jobListingRepository, taskSchedulingService, notificationService) const markService = getMarkService(markRepository) - const personalMarkService = getPersonalMarkService(personalMarkRepository, markService, userService, emailService) + const personalMarkService = getPersonalMarkService( + personalMarkRepository, + markService, + userService, + emailService, + notificationService + ) const paymentService = getPaymentService(clients.stripe) const paymentProductsService = getPaymentProductsService(clients.stripe) const paymentWebhookService = getPaymentWebhookService(clients.stripe) const auditLogService = getAuditLogService(auditLogRepository) - const eventService = getEventService(eventRepository, clients.s3Client, configuration.AWS_S3_BUCKET) + const eventService = getEventService( + eventRepository, + attendanceRepository, + taskSchedulingService, + notificationService, + clients.s3Client, + configuration.AWS_S3_BUCKET + ) const feedbackFormService = getFeedbackFormService( feedbackFormRepository, feedbackFormAnswerRepository, @@ -220,7 +242,6 @@ export async function createServiceLayer( attendanceRepository ) const feedbackFormAnswerService = getFeedbackFormAnswerService(feedbackFormAnswerRepository, feedbackFormService) - const notificationService = getNotificationService(notificationRepository, userRepository) const taskDiscoveryService = getLocalTaskDiscoveryService(clients.prisma, taskService, recurringTaskService) const attendanceService = getAttendanceService( eventEmitter, @@ -235,14 +256,21 @@ export async function createServiceLayer( feedbackFormService, feedbackFormAnswerService, configuration, - emailService + emailService, + notificationService ) const companyService = getCompanyService(companyRepository, clients.s3Client, configuration.AWS_S3_BUCKET) - const offlineService = getOfflineService(offlineRepository, clients.s3Client, configuration.AWS_S3_BUCKET) + const offlineService = getOfflineService( + offlineRepository, + notificationService, + clients.s3Client, + configuration.AWS_S3_BUCKET + ) const articleService = getArticleService( articleRepository, articleTagRepository, articleTagLinkRepository, + notificationService, clients.s3Client, configuration.AWS_S3_BUCKET ) @@ -261,6 +289,7 @@ export async function createServiceLayer( taskDiscoveryService, taskSchedulingService, attendanceService, + notificationTaskService, configuration ) diff --git a/apps/rpc/src/modules/event/attendance-service.ts b/apps/rpc/src/modules/event/attendance-service.ts index 51acc49cb3..d25e5351a0 100644 --- a/apps/rpc/src/modules/event/attendance-service.ts +++ b/apps/rpc/src/modules/event/attendance-service.ts @@ -79,6 +79,7 @@ import type { UserService } from "../user/user-service" import type { AttendanceRepository } from "./attendance-repository" import type { EventService } from "./event-service" import { validateTurnstileToken } from "../../turnstile" +import type { NotificationService } from "../notification/notification-service" type EventRegistrationOptions = { /** Should the user be registered regardless of if registration is closed? */ @@ -247,7 +248,7 @@ export interface AttendanceService { handle: DBHandle, task: InferTaskData ): Promise - executeSendFeedbackFormLinkEmails(handle: DBHandle): Promise + executeSendFeedbackFormLinkEmailsAndNotifications(handle: DBHandle): Promise executeVerifyAttendeeAttendedTask(handle: DBHandle): Promise /** @@ -281,7 +282,8 @@ export function getAttendanceService( feedbackFormService: FeedbackFormService, feedbackAnswerService: FeedbackFormAnswerService, configuration: Configuration, - emailService: EmailService + emailService: EmailService, + notificationService: NotificationService ): AttendanceService { const logger = getLogger("attendance-service") @@ -427,7 +429,23 @@ export function getAttendanceService( } } - return await attendanceRepository.updateAttendanceById(handle, attendanceId, input) + const updatedAttendance = await attendanceRepository.updateAttendanceById(handle, attendanceId, input) + + // Reschedule the registration notification if registerStart changed + if (data.registerStart !== undefined && input.registerStart.getTime() !== attendance.registerStart.getTime()) { + const existingTask = await taskSchedulingService.findEventRegistrationNotificationTask(handle, attendanceId) + if (existingTask) { + await taskSchedulingService.cancel(handle, existingTask.id) + } + await taskSchedulingService.scheduleAt( + handle, + tasks.SEND_NOTIFICATION_EVENT_REGISTRATION, + { attendanceId }, + new TZDate(input.registerStart) + ) + } + + return updatedAttendance }, async createAttendancePool(handle, attendanceId, data) { @@ -1438,7 +1456,7 @@ export function getAttendanceService( await Promise.all([...personalMarkPromises]) }, - async executeSendFeedbackFormLinkEmails(handle) { + async executeSendFeedbackFormLinkEmailsAndNotifications(handle) { const eventsEndedYesterday = await eventService.findEvents(handle, { byHasFeedbackForm: true, byEndDate: { @@ -1469,36 +1487,51 @@ export function getAttendanceService( (attendee) => !answers.some((answer) => answer.attendeeId === attendee.id) ) const bcc = attendeesWithoutAnswers.map((a) => a.user.email).filter((email) => email !== null) + const notificationRecipientIds = attendeesWithoutAnswers.map((a) => a.userId) - if (bcc.length === 0) { - return + if (notificationRecipientIds.length > 0) { + const formattedDeadline = feedbackForm.answerDeadline.toLocaleString("no-NO", { + dateStyle: "short", + timeStyle: "short", + }) + await notificationService.create( + handle, + notificationRecipientIds, + "NEW_FEEDBACK_FORM", + "Nytt tilbakemeldingsskjema tilgjengelig", + `Tilbakemeldingsskjema for ${event.title} er nå tilgjengelig. Gi din tilbakemelding før ${formattedDeadline}.`, + `${configuration.WEB_PUBLIC_ORIGIN}/tilbakemelding/${event.id}`, + "URL" + ) } - const hostingGroupEmail = findFirstHostingGroupEmail(event) ?? "bedkom@online.ntnu.no" - logger.info( - "Sending feedback form email for Event(ID=%s) to %d attendees from email %s", - event.id, - bcc.length, - hostingGroupEmail - ) + if (bcc.length > 0) { + const hostingGroupEmail = findFirstHostingGroupEmail(event) ?? "bedkom@online.ntnu.no" + logger.info( + "Sending feedback form email for Event(ID=%s) to %d attendees from email %s", + event.id, + bcc.length, + hostingGroupEmail + ) - await emailService.send( - hostingGroupEmail, - [], - [], - [], - bcc, - `Tilbakemelding på ${event.title}`, - emails.FEEDBACK_FORM_LINK, - { - eventName: event.title, - eventLink: `${configuration.WEB_PUBLIC_ORIGIN}/arrangementer/${slugify(event.title)}/${event.id}`, - feedbackLink: `${configuration.WEB_PUBLIC_ORIGIN}/tilbakemelding/${event.id}`, - eventStart: event.start.toISOString(), - feedbackDeadline: feedbackForm.answerDeadline.toISOString(), - organizerEmail: hostingGroupEmail, - } - ) + await emailService.send( + hostingGroupEmail, + [], + [], + [], + bcc, + `Tilbakemelding på ${event.title}`, + emails.FEEDBACK_FORM_LINK, + { + eventName: event.title, + eventLink: `${configuration.WEB_PUBLIC_ORIGIN}/arrangementer/${slugify(event.title)}/${event.id}`, + feedbackLink: `${configuration.WEB_PUBLIC_ORIGIN}/tilbakemelding/${event.id}`, + eventStart: event.start.toISOString(), + feedbackDeadline: feedbackForm.answerDeadline.toISOString(), + organizerEmail: hostingGroupEmail, + } + ) + } }) await Promise.all(promises) diff --git a/apps/rpc/src/modules/event/event-service.ts b/apps/rpc/src/modules/event/event-service.ts index e6b5852ad2..ab1f88f92b 100644 --- a/apps/rpc/src/modules/event/event-service.ts +++ b/apps/rpc/src/modules/event/event-service.ts @@ -19,10 +19,16 @@ import { type UserId, EVENT_IMAGE_MAX_SIZE_KIB, } from "@dotkomonline/types" -import { createS3PresignedPost, slugify } from "@dotkomonline/utils" import { FailedPreconditionError, InvalidArgumentError, NotFoundError } from "../../error" +import { createEventSlug, createS3PresignedPost, slugify } from "@dotkomonline/utils" import type { Pageable } from "@dotkomonline/utils" import type { EventRepository } from "./event-repository" +import type { AttendanceRepository } from "./attendance-repository" +import type { TaskSchedulingService } from "../task/task-scheduling-service" +import { tasks } from "../task/task-definition" +import { TZDate } from "@date-fns/tz" +import { subDays } from "date-fns" +import type { NotificationService } from "../notification/notification-service" export interface EventService { createEvent(handle: DBHandle, data: EventWrite): Promise @@ -75,6 +81,9 @@ export interface EventService { export function getEventService( eventRepository: EventRepository, + attendanceRepository: AttendanceRepository, + taskSchedulingService: TaskSchedulingService, + notificationService: NotificationService, s3Client: S3Client, s3BucketName: string ): EventService { @@ -82,7 +91,21 @@ export function getEventService( return { async createEvent(handle, data) { - return await eventRepository.create(handle, data) + const createdEvent = await eventRepository.create(handle, data) + + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "NEW_EVENT") + await notificationService.create( + handle, + recipients, + "NEW_EVENT", + `Nytt arrangement: ${createdEvent.title}`, + `Et nytt arrangement "${createdEvent.title}" har blitt publisert.`, + createdEvent.hostingGroups[0]?.slug ?? null, + "EVENT", + `${createEventSlug(createdEvent.title)}/${createdEvent.id}` + ) + + return createdEvent }, async deleteEvent(handle, eventId) { @@ -90,7 +113,50 @@ export function getEventService( }, async updateEvent(handle, eventId, data) { - return await eventRepository.update(handle, eventId, data) + let preEvent: Event | null = null + + // Only send notifications if the event is either in the future or ongoing. + if (data.end && new Date() < new Date(data.end)) { + preEvent = await this.getEventById(handle, eventId) + } + + const afterEvent = await eventRepository.update(handle, eventId, data) + + if (preEvent) { + if ( + afterEvent.description !== preEvent.description && + afterEvent.attendanceId !== null && + afterEvent.hostingGroups.length > 0 + ) { + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "EVENT_UPDATE", afterEvent.id) + await notificationService.create( + handle, + recipients, + "EVENT_UPDATE", + `Arrangement oppdatert: ${afterEvent.title}`, + `Arrangementet "${afterEvent.title}" har blitt oppdatert.`, + afterEvent.hostingGroups[0]?.slug ?? null, + "EVENT", + `${createEventSlug(afterEvent.title)}/${afterEvent.id}` + ) + } + + // Reschedule the reminder if event start time changed + if (data.start && afterEvent.start.getTime() !== preEvent.start.getTime()) { + const existingReminder = await taskSchedulingService.findEventReminderNotificationTask(handle, afterEvent.id) + if (existingReminder) { + await taskSchedulingService.cancel(handle, existingReminder.id) + } + await taskSchedulingService.scheduleAt( + handle, + tasks.SEND_NOTIFICATION_EVENT_REMINDER, + { eventId: afterEvent.id }, + new TZDate(subDays(afterEvent.start, 1)) + ) + } + } + + return afterEvent }, async findEvents(handle, query, page) { @@ -186,7 +252,25 @@ export function getEventService( async updateEventAttendance(handle, eventId, attendanceId) { const event = await this.getEventById(handle, eventId) - return await eventRepository.updateEventAttendance(handle, event.id, attendanceId) + const updatedEvent = await eventRepository.updateEventAttendance(handle, event.id, attendanceId) + + const attendance = await attendanceRepository.findAttendanceById(handle, attendanceId) + if (attendance) { + await taskSchedulingService.scheduleAt( + handle, + tasks.SEND_NOTIFICATION_EVENT_REGISTRATION, + { attendanceId }, + new TZDate(attendance.registerStart) + ) + await taskSchedulingService.scheduleAt( + handle, + tasks.SEND_NOTIFICATION_EVENT_REMINDER, + { eventId: event.id }, + new TZDate(subDays(event.start, 1)) + ) + } + + return updatedEvent }, async updateEventParent(handle, eventId, parentEventId) { diff --git a/apps/rpc/src/modules/group/group-service.ts b/apps/rpc/src/modules/group/group-service.ts index 3bccec4edb..bcff8352c0 100644 --- a/apps/rpc/src/modules/group/group-service.ts +++ b/apps/rpc/src/modules/group/group-service.ts @@ -28,6 +28,7 @@ import { FailedPreconditionError, IllegalStateError, NotFoundError } from "../.. import type { UserService } from "../user/user-service" import type { GroupRepository } from "./group-repository" import crypto from "node:crypto" +import type { NotificationService } from "../notification/notification-service" export interface GroupService { create(handle: DBHandle, data: GroupWrite): Promise @@ -96,6 +97,7 @@ export interface GroupService { export function getGroupService( groupRepository: GroupRepository, userService: UserService, + notificationService: NotificationService, s3Client: S3Client, s3BucketName: string ): GroupService { @@ -121,7 +123,23 @@ export function getGroupService( await groupRepository.create(handle, slug, data) await groupRepository.createGroupRoles(handle, getDefaultGroupMemberRoles(slug)) - return await this.getBySlug(handle, slug) + const createdGroup = await this.getBySlug(handle, slug) + + if (createdGroup.type === "INTEREST_GROUP") { + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "NEW_INTEREST_GROUP") + await notificationService.create( + handle, + recipients, + "NEW_INTEREST_GROUP", + `Ny interessegruppe: ${createdGroup.name}`, + `En ny interessegruppe "${createdGroup.name}" har blitt opprettet.`, + createdGroup.slug, + "GROUP", + createdGroup.slug + ) + } + + return createdGroup }, async update(handle, groupSlug, data) { diff --git a/apps/rpc/src/modules/job-listing/job-listing-service.ts b/apps/rpc/src/modules/job-listing/job-listing-service.ts index b327c32a7e..addf44ade2 100644 --- a/apps/rpc/src/modules/job-listing/job-listing-service.ts +++ b/apps/rpc/src/modules/job-listing/job-listing-service.ts @@ -8,10 +8,14 @@ import type { JobListingLocationId, JobListingWrite, } from "@dotkomonline/types" -import { isAfter } from "date-fns" +import { addMilliseconds, isAfter } from "date-fns" import { assert, InvalidArgumentError, NotFoundError } from "../../error" import type { Pageable } from "@dotkomonline/utils" import type { JobListingRepository } from "./job-listing-repository" +import type { TaskSchedulingService } from "../task/task-scheduling-service" +import { tasks } from "../task/task-definition" +import { TZDate } from "@date-fns/tz" +import type { NotificationService } from "../notification/notification-service" export interface JobListingService { create( @@ -33,11 +37,39 @@ export interface JobListingService { findJobListingLocations(handle: DBHandle): Promise } -export function getJobListingService(jobListingRepository: JobListingRepository): JobListingService { +export function getJobListingService( + jobListingRepository: JobListingRepository, + taskSchedulingService: TaskSchedulingService, + notificationService: NotificationService +): JobListingService { return { async create(handle, companyId, jobListingData, locationIdsData) { validateJobListingWrite(jobListingData) - return await jobListingRepository.create(handle, companyId, jobListingData, locationIdsData) + const createdJobListing = await jobListingRepository.create(handle, companyId, jobListingData, locationIdsData) + + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "NEW_JOB_LISTING") + await notificationService.create( + handle, + recipients, + "NEW_JOB_LISTING", + `Ny stillingsutlysning: ${createdJobListing.title}`, + `En ny stillingsutlysning "${createdJobListing.title}" har blitt publisert.`, + null, + "JOB_LISTING", + createdJobListing.id + ) + + // Schedule reminder halfway through the job listing's lifespan + const lifespanMs = createdJobListing.end.getTime() - createdJobListing.start.getTime() + const halfwayAt = addMilliseconds(createdJobListing.start, lifespanMs / 2) + await taskSchedulingService.scheduleAt( + handle, + tasks.SEND_NOTIFICATION_JOB_LISTING_REMINDER, + { jobListingId: createdJobListing.id, title: createdJobListing.title }, + new TZDate(halfwayAt) + ) + + return createdJobListing }, async update(handle, jobListingId, jobListingData, locationIdsData) { diff --git a/apps/rpc/src/modules/mark/personal-mark-service.ts b/apps/rpc/src/modules/mark/personal-mark-service.ts index 7d9cdac40b..8e6f9955ee 100644 --- a/apps/rpc/src/modules/mark/personal-mark-service.ts +++ b/apps/rpc/src/modules/mark/personal-mark-service.ts @@ -17,6 +17,7 @@ import { DEFAULT_EMAIL_SOURCE, emails } from "../email/email-template" import type { UserService } from "../user/user-service" import type { MarkService } from "./mark-service" import type { PersonalMarkRepository } from "./personal-mark-repository" +import type { NotificationService } from "../notification/notification-service" export interface PersonalMarkService { findPersonalMarksByMarkId(handle: DBHandle, markId: MarkId): Promise @@ -43,7 +44,8 @@ export function getPersonalMarkService( personalMarkRepository: PersonalMarkRepository, markService: MarkService, userService: UserService, - emailService: EmailService + emailService: EmailService, + notificationService: NotificationService ): PersonalMarkService { const logger = getLogger("personal-mark-service") @@ -68,6 +70,19 @@ export function getPersonalMarkService( const mark = await markService.getById(handle, markId) const personalMark = await personalMarkRepository.create(handle, userId, mark.id, givenByUserId) + const title = mark.weight > 1 ? `Du har fått ${mark.weight} prikker` : `Du har fått en prikk` + const description = `${mark.title} med grunn "${mark.details}"` + + await notificationService.create( + handle, + [userId], + "NEW_MARK", + title, + description, + null, + "USER", + userId + ) await this.sendReceivedMarkEmail(handle, personalMark) return personalMark diff --git a/apps/rpc/src/modules/notification/notification-router.ts b/apps/rpc/src/modules/notification/notification-router.ts index 877bc90976..1bb82a9e04 100644 --- a/apps/rpc/src/modules/notification/notification-router.ts +++ b/apps/rpc/src/modules/notification/notification-router.ts @@ -1,6 +1,6 @@ +import { on } from "node:events" 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 { BasePaginateInputSchema, PaginateInputSchema } from "@dotkomonline/utils" @@ -11,6 +11,8 @@ import { UserNotificationDTOSchema, UserNotificationSchema, } from "./notification-types" +import type { Notification } from "./notification-types" +import { isCommitteeMember } from "src/authorization" export type GetNotificationInput = inferProcedureInput export type GetNotificationOutput = inferProcedureOutput @@ -28,11 +30,20 @@ const createNotificationProcedure = procedure .input(NotificationWriteSchema) .output(NotificationDTOSchema) .use(withAuthentication()) - .use(withAuthorization(isEditor())) + .use(withAuthorization(isCommitteeMember())) .use(withDatabaseTransaction()) .use(withAuditLogEntry()) .mutation(({ input, ctx }) => { - return ctx.notificationService.createWithRecipients(ctx.handle, input) + return ctx.notificationService.create( + ctx.handle, + input.recipientIds, + input.type, + input.title, + input.shortDescription ?? input.title, + input.actorGroupId, + input.payloadType, + input.payload + ) }) export type EditNotificationInput = inferProcedureInput @@ -46,7 +57,8 @@ const editNotificationProcedure = procedure ) .output(NotificationDTOSchema) .use(withAuthentication()) - .use(withAuthorization(isEditor())) + .use(withAuthorization(isCommitteeMember())) + .use(withDatabaseTransaction()) .use(withAuditLogEntry()) .mutation(async ({ input: changes, ctx }) => { @@ -59,7 +71,8 @@ const deleteNotificationProcedure = procedure .input(NotificationSchema.shape.id) .output(z.boolean()) .use(withAuthentication()) - .use(withAuthorization(isEditor())) + .use(withAuthorization(isCommitteeMember())) + .use(withDatabaseTransaction()) .use(withAuditLogEntry()) .mutation(async ({ input, ctx }) => { @@ -118,7 +131,8 @@ const findNotificationsProcedure = procedure .input(PaginateInputSchema) .output(z.object({ items: z.array(NotificationDTOSchema), nextCursor: NotificationSchema.shape.id.optional() })) .use(withAuthentication()) - .use(withAuthorization(isEditor())) + .use(withAuthorization(isCommitteeMember())) + .use(withDatabaseTransaction()) .query(async ({ input, ctx }) => { const items = await ctx.notificationService.findMany(ctx.handle, input) @@ -129,6 +143,20 @@ const findNotificationsProcedure = procedure } }) +export type OnNewNotificationInput = inferProcedureInput +export type OnNewNotificationOutput = inferProcedureOutput +const onNewNotificationProcedure = procedure + .use(withAuthentication()) + .subscription(async function* ({ ctx, signal }) { + for await (const [data] of on(ctx.eventEmitter, "notification:new", { signal })) { + const { userId, notification } = data as { userId: string; notification: Notification } + if (userId !== ctx.principal.subject) { + continue + } + yield notification + } + }) + export const notificationRouter = t.router({ get: getNotificationProcedure, create: createNotificationProcedure, @@ -139,4 +167,5 @@ export const notificationRouter = t.router({ markAsRead: markAsReadProcedure, markAllAsRead: markAllAsReadProcedure, findMany: findNotificationsProcedure, + onNewNotification: onNewNotificationProcedure, }) diff --git a/apps/rpc/src/modules/notification/notification-service.ts b/apps/rpc/src/modules/notification/notification-service.ts index 8f70862a95..70093839c3 100644 --- a/apps/rpc/src/modules/notification/notification-service.ts +++ b/apps/rpc/src/modules/notification/notification-service.ts @@ -1,13 +1,17 @@ +import type EventEmitter from "node:events" import type { DBHandle } from "@dotkomonline/db" import type { UserId } from "@dotkomonline/types" import type { NotificationRepository } from "./notification-repository" import type { Pageable } from "@dotkomonline/utils" import type { UserRepository } from "../user/user-repository" +import type { AttendanceRepository } from "../event/attendance-repository" import type { Notification, NotificationId, + NotificationPayloadType, NotificationRecipient, NotificationRecipientId, + NotificationType, NotificationWrite, UserNotification, } from "./notification-types" @@ -15,7 +19,21 @@ import type { export interface NotificationService { findById(handle: DBHandle, notificationId: NotificationId): Promise findMany(handle: DBHandle, page: Pageable): Promise - createWithRecipients(handle: DBHandle, notificationData: NotificationWrite): Promise + retrieveIntendedRecipientIds( + handle: DBHandle, + notificationType: NotificationType, + eventId?: string + ): Promise + create( + handle: DBHandle, + recipientIds: UserId[], + notificationType: NotificationType, + title: string, + shortDescription: string, + actorGroupId?: string | null, + payloadType?: NotificationPayloadType, + payload?: string | null + ): Promise update( handle: DBHandle, notificationId: NotificationId, @@ -39,7 +57,9 @@ export interface NotificationService { export function getNotificationService( notificationRepository: NotificationRepository, - userRepository: UserRepository + userRepository: UserRepository, + attendanceRepository: AttendanceRepository, + eventEmitter: EventEmitter ): NotificationService { return { async findById(handle, notificationId) { @@ -50,13 +70,34 @@ export function getNotificationService( return await notificationRepository.findMany(handle, page) }, - async createWithRecipients(handle, notificationData) { - const data = { ...notificationData } - if (data.recipientIds.length === 0) { - const users = await userRepository.findMany(handle, {}, { take: 10000 }) - data.recipientIds = users.map((user) => user.id) + async create(handle, recipientIds, notificationType, title, shortDescription, actorGroupId, payloadType, payload) { + const notification = await notificationRepository.createWithRecipients(handle, { + title, + shortDescription, + content: shortDescription ?? title, + type: notificationType, + payload: payload ?? null, + payloadType: payloadType ?? "NONE", + actorGroupId: actorGroupId ?? null, + taskId: null, + recipientIds, + }) + for (const userId of recipientIds) { + eventEmitter.emit("notification:new", { userId, notification }) + } + return notification + }, + + async retrieveIntendedRecipientIds(handle, notificationType, eventId) { + const eventAttendeeTypes: NotificationType[] = ["EVENT_REGISTRATION", "EVENT_REMINDER", "EVENT_UPDATE"] + + if (eventAttendeeTypes.includes(notificationType) && eventId) { + const attendance = await attendanceRepository.findAttendanceByEventId(handle, eventId) + return attendance?.attendees.map((a) => a.userId) ?? [] } - return await notificationRepository.createWithRecipients(handle, data) + + const users = await userRepository.findMany(handle, {}, { take: 10000 }) + return users.map((u) => u.id) }, async update(handle, notificationId, notificationData) { diff --git a/apps/rpc/src/modules/notification/notification-task-service.ts b/apps/rpc/src/modules/notification/notification-task-service.ts new file mode 100644 index 0000000000..2cc1e197c5 --- /dev/null +++ b/apps/rpc/src/modules/notification/notification-task-service.ts @@ -0,0 +1,96 @@ +import type { DBHandle } from "@dotkomonline/db" +import { isAfter, isBefore, subDays, addHours } from "date-fns" +import { createEventSlug } from "@dotkomonline/utils" +import type { AttendanceRepository } from "../event/attendance-repository" +import type { EventRepository } from "../event/event-repository" +import type { + InferTaskData, + SendNotificationEventRegistrationTaskDefinition, + SendNotificationEventReminderTaskDefinition, + SendNotificationJobListingReminderTaskDefinition, +} from "../task/task-definition" +import type { NotificationService } from "./notification-service" + +export interface NotificationTaskService { + executeEventRegistrationNotificationTask( + handle: DBHandle, + data: InferTaskData + ): Promise + executeEventReminderNotificationTask( + handle: DBHandle, + data: InferTaskData + ): Promise + executeJobListingReminderNotificationTask( + handle: DBHandle, + data: InferTaskData + ): Promise +} + +export function getNotificationTaskService( + notificationService: NotificationService, + attendanceRepository: AttendanceRepository, + eventRepository: EventRepository +): NotificationTaskService { + return { + async executeEventRegistrationNotificationTask(handle, { attendanceId }) { + const attendance = await attendanceRepository.findAttendanceById(handle, attendanceId) + if (!attendance) return + + // Verify registration has actually opened (with a small grace window for task scheduling jitter) + const now = new Date() + if (isAfter(attendance.registerStart, addHours(now, 0.1))) return + + const event = await eventRepository.findByAttendanceId(handle, attendanceId) + if (!event) return + + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "EVENT_REGISTRATION") + await notificationService.create( + handle, + recipients, + "EVENT_REGISTRATION", + `Påmelding åpnet: ${event.title}`, + `Påmeldingen til arrangementet "${event.title}" er nå åpen.`, + event.hostingGroups[0]?.slug ?? null, + "EVENT", + `${createEventSlug(event.title)}/${event.id}` + ) + }, + + async executeEventReminderNotificationTask(handle, { eventId }) { + const event = await eventRepository.findById(handle, eventId) + if (!event) return + + // Verify the event actually starts within the next ~48 hours (tolerant window around "1 day before") + const now = new Date() + const windowStart = subDays(now, 1) + const windowEnd = addHours(now, 48) + if (isBefore(event.start, windowStart) || isAfter(event.start, windowEnd)) return + + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "EVENT_REMINDER", eventId) + await notificationService.create( + handle, + recipients, + "EVENT_REMINDER", + `Påminnelse: ${event.title}`, + `Arrangementet "${event.title}" starter i morgen.`, + event.hostingGroups[0]?.slug ?? null, + "EVENT", + `${createEventSlug(event.title)}/${event.id}` + ) + }, + + async executeJobListingReminderNotificationTask(handle, { jobListingId, title }) { + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "JOB_LISTING_REMINDER") + await notificationService.create( + handle, + recipients, + "JOB_LISTING_REMINDER", + `Frist nærmer seg: ${title}`, + `Fristen for stillingsutlysningen "${title}" nærmer seg.`, + null, + "JOB_LISTING", + jobListingId + ) + }, + } +} diff --git a/apps/rpc/src/modules/notification/notification-types.ts b/apps/rpc/src/modules/notification/notification-types.ts index 18ac76b845..4c6f496b4b 100644 --- a/apps/rpc/src/modules/notification/notification-types.ts +++ b/apps/rpc/src/modules/notification/notification-types.ts @@ -49,8 +49,8 @@ export const NotificationSchema = z.object({ type: NotificationTypeSchema, payload: z.string().nullable(), payloadType: NotificationPayloadTypeSchema, - actorGroupId: z.string(), - actorGroup: GroupSchema, + actorGroupId: z.string().nullable(), + actorGroup: GroupSchema.nullable(), createdById: z.string().nullable(), // createdBy: UserSchema.nullable(), lastUpdatedById: z.string().nullable(), diff --git a/apps/rpc/src/modules/offline/offline-service.ts b/apps/rpc/src/modules/offline/offline-service.ts index 8ca4737eda..869f95ce94 100644 --- a/apps/rpc/src/modules/offline/offline-service.ts +++ b/apps/rpc/src/modules/offline/offline-service.ts @@ -13,6 +13,7 @@ import { createS3PresignedPost, slugify } from "@dotkomonline/utils" import { NotFoundError } from "../../error" import type { Pageable } from "@dotkomonline/utils" import type { OfflineRepository } from "./offline-repository" +import type { NotificationService } from "../notification/notification-service" export interface OfflineService { create(handle: DBHandle, data: OfflineWrite): Promise @@ -43,12 +44,27 @@ export interface OfflineService { export function getOfflineService( offlineRepository: OfflineRepository, + notificationService: NotificationService, s3Client: S3Client, s3BucketName: string ): OfflineService { return { async create(handle, data) { - return offlineRepository.create(handle, data) + const createdOffline = await offlineRepository.create(handle, data) + + const recipients = await notificationService.retrieveIntendedRecipientIds(handle, "NEW_OFFLINE") + await notificationService.create( + handle, + recipients, + "NEW_OFFLINE", + `Ny offline: ${createdOffline.title}`, + `En ny offline "${createdOffline.title}" har blitt publisert.`, + null, + "OFFLINE", + createdOffline.id + ) + + return createdOffline }, async update(handle, id, data) { diff --git a/apps/rpc/src/modules/task/task-definition.ts b/apps/rpc/src/modules/task/task-definition.ts index 77a1d57a24..6caed0a5fa 100644 --- a/apps/rpc/src/modules/task/task-definition.ts +++ b/apps/rpc/src/modules/task/task-definition.ts @@ -2,6 +2,9 @@ import { AttendanceSchema, AttendeeSchema, FeedbackFormSchema, type TaskType } f import { z } from "zod" import { NotFoundError } from "../../error" +const eventIdSchema = z.string().uuid() +const attendanceIdSchema = z.string().uuid() + export interface TaskDefinition { getSchema(): z.ZodSchema type: TType @@ -23,6 +26,9 @@ export type ChargeAttendeeTaskDefinition = typeof tasks.CHARGE_ATTENDEE export type VerifyFeedbackAnsweredTaskDefinition = typeof tasks.VERIFY_FEEDBACK_ANSWERED export type SendFeedbackFormEmailsTaskDefinition = typeof tasks.SEND_FEEDBACK_FORM_EMAILS export type VerifyAttendeeAttendedTaskDefinition = typeof tasks.VERIFY_ATTENDEE_ATTENDED +export type SendNotificationEventRegistrationTaskDefinition = typeof tasks.SEND_NOTIFICATION_EVENT_REGISTRATION +export type SendNotificationEventReminderTaskDefinition = typeof tasks.SEND_NOTIFICATION_EVENT_REMINDER +export type SendNotificationJobListingReminderTaskDefinition = typeof tasks.SEND_NOTIFICATION_JOB_LISTING_REMINDER export type AnyTaskDefinition = | ReserveAttendeeTaskDefinition | MergeAttendancePoolsTaskDefinition @@ -31,6 +37,9 @@ export type AnyTaskDefinition = | VerifyFeedbackAnsweredTaskDefinition | SendFeedbackFormEmailsTaskDefinition | VerifyAttendeeAttendedTaskDefinition + | SendNotificationEventRegistrationTaskDefinition + | SendNotificationEventReminderTaskDefinition + | SendNotificationJobListingReminderTaskDefinition export const tasks = { RESERVE_ATTENDEE: createTaskDefinition({ @@ -77,6 +86,28 @@ export const tasks = { type: "VERIFY_ATTENDEE_ATTENDED", getSchema: () => z.object({}), }), + SEND_NOTIFICATION_EVENT_REGISTRATION: createTaskDefinition({ + type: "SEND_NOTIFICATION_EVENT_REGISTRATION", + getSchema: () => + z.object({ + attendanceId: attendanceIdSchema, + }), + }), + SEND_NOTIFICATION_EVENT_REMINDER: createTaskDefinition({ + type: "SEND_NOTIFICATION_EVENT_REMINDER", + getSchema: () => + z.object({ + eventId: eventIdSchema, + }), + }), + SEND_NOTIFICATION_JOB_LISTING_REMINDER: createTaskDefinition({ + type: "SEND_NOTIFICATION_JOB_LISTING_REMINDER", + getSchema: () => + z.object({ + jobListingId: z.string().uuid(), + title: z.string(), + }), + }), // biome-ignore lint/suspicious/noExplicitAny: used for type inference only } satisfies Record> diff --git a/apps/rpc/src/modules/task/task-executor.ts b/apps/rpc/src/modules/task/task-executor.ts index 338cc0976f..f52dd50830 100644 --- a/apps/rpc/src/modules/task/task-executor.ts +++ b/apps/rpc/src/modules/task/task-executor.ts @@ -9,17 +9,22 @@ import { captureException } from "@sentry/node" import type { Configuration } from "../../configuration" import { IllegalStateError } from "../../error" import type { AttendanceService } from "../event/attendance-service" +import type { NotificationTaskService } from "../notification/notification-task-service" import type { RecurringTaskService } from "./recurring-task-service" import { type ChargeAttendeeTaskDefinition, type InferTaskData, type MergeAttendancePoolsTaskDefinition, type ReserveAttendeeTaskDefinition, + type SendNotificationEventRegistrationTaskDefinition, + type SendNotificationEventReminderTaskDefinition, + type SendNotificationJobListingReminderTaskDefinition, type VerifyFeedbackAnsweredTaskDefinition, type VerifyPaymentTaskDefinition, getTaskDefinition, tasks, } from "./task-definition" + import type { TaskDiscoveryService } from "./task-discovery-service" import type { TaskSchedulingService } from "./task-scheduling-service" import type { TaskService } from "./task-service" @@ -34,6 +39,7 @@ export function getLocalTaskExecutor( taskDiscoveryService: TaskDiscoveryService, taskSchedulingService: TaskSchedulingService, attendanceService: AttendanceService, + notificationTaskService: NotificationTaskService, configuration: Configuration ): TaskExecutor { const logger = getLogger("task-executor") @@ -94,10 +100,29 @@ export function getLocalTaskExecutor( ) case tasks.SEND_FEEDBACK_FORM_EMAILS.type: - return await attendanceService.executeSendFeedbackFormLinkEmails(handle) + return await attendanceService.executeSendFeedbackFormLinkEmailsAndNotifications(handle) case tasks.VERIFY_ATTENDEE_ATTENDED.type: return await attendanceService.executeVerifyAttendeeAttendedTask(handle) + + case tasks.SEND_NOTIFICATION_EVENT_REGISTRATION.type: + return await notificationTaskService.executeEventRegistrationNotificationTask( + handle, + payload as InferTaskData + ) + + case tasks.SEND_NOTIFICATION_EVENT_REMINDER.type: + return await notificationTaskService.executeEventReminderNotificationTask( + handle, + payload as InferTaskData + ) + + case tasks.SEND_NOTIFICATION_JOB_LISTING_REMINDER.type: + return await notificationTaskService.executeJobListingReminderNotificationTask( + handle, + payload as InferTaskData + ) + } // NOTE: If you have done everything correctly, TypeScript should SCREAM "Unreachable code detected" below. We diff --git a/apps/rpc/src/modules/task/task-repository.ts b/apps/rpc/src/modules/task/task-repository.ts index 972d390836..3d21030d53 100644 --- a/apps/rpc/src/modules/task/task-repository.ts +++ b/apps/rpc/src/modules/task/task-repository.ts @@ -3,6 +3,7 @@ import { Prisma } from "@dotkomonline/db" import { type AttendanceId, type AttendeeId, + type EventId, type FeedbackFormId, type Task, type TaskId, @@ -25,6 +26,8 @@ export interface TaskRepository { findVerifyPaymentTask(handle: DBHandle, attendeeId: AttendeeId): Promise findChargeAttendeeTask(handle: DBHandle, attendeeId: AttendeeId): Promise findVerifyFeedbackAnsweredTask(handle: DBHandle, feedbackFormId: FeedbackFormId): Promise + findEventRegistrationNotificationTask(handle: DBHandle, attendanceId: AttendanceId): Promise + findEventReminderNotificationTask(handle: DBHandle, eventId: EventId): Promise } export function getTaskRepository(): TaskRepository { @@ -182,5 +185,37 @@ export function getTaskRepository(): TaskRepository { return parseOrReport(TaskSchema.nullable(), task) }, + + async findEventRegistrationNotificationTask(handle, attendanceId) { + const task = await handle.task.findFirst({ + where: { + type: tasks.SEND_NOTIFICATION_EVENT_REGISTRATION.type, + status: "PENDING", + payload: { + path: ["attendanceId"], + equals: attendanceId, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + return parseOrReport(TaskSchema.nullable(), task) + }, + + async findEventReminderNotificationTask(handle, eventId) { + const task = await handle.task.findFirst({ + where: { + type: tasks.SEND_NOTIFICATION_EVENT_REMINDER.type, + status: "PENDING", + payload: { + path: ["eventId"], + equals: eventId, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + return parseOrReport(TaskSchema.nullable(), task) + }, } } diff --git a/apps/rpc/src/modules/task/task-scheduling-service.ts b/apps/rpc/src/modules/task/task-scheduling-service.ts index 15f2b59554..7a6bd76465 100644 --- a/apps/rpc/src/modules/task/task-scheduling-service.ts +++ b/apps/rpc/src/modules/task/task-scheduling-service.ts @@ -1,7 +1,15 @@ import type { TZDate } from "@date-fns/tz" import type { DBHandle, Prisma } from "@dotkomonline/db" import { getLogger } from "@dotkomonline/logger" -import type { AttendanceId, AttendeeId, FeedbackFormId, RecurringTaskId, Task, TaskId } from "@dotkomonline/types" +import type { + AttendanceId, + AttendeeId, + EventId, + FeedbackFormId, + RecurringTaskId, + Task, + TaskId, +} from "@dotkomonline/types" import type { InferTaskData, TaskDefinition } from "./task-definition" import type { TaskRepository } from "./task-repository" import type { TaskService } from "./task-service" @@ -27,6 +35,8 @@ export interface TaskSchedulingService { findVerifyPaymentTask(handle: DBHandle, attendeeId: AttendeeId): Promise findChargeAttendeeTask(handle: DBHandle, attendeeId: AttendeeId): Promise findVerifyFeedbackAnsweredTask(handle: DBHandle, feedbackFormId: FeedbackFormId): Promise + findEventRegistrationNotificationTask(handle: DBHandle, attendanceId: AttendanceId): Promise + findEventReminderNotificationTask(handle: DBHandle, eventId: EventId): Promise } export function getLocalTaskSchedulingService( @@ -78,5 +88,13 @@ export function getLocalTaskSchedulingService( async findVerifyFeedbackAnsweredTask(handle, feedbackFormId) { return await taskRepository.findVerifyFeedbackAnsweredTask(handle, feedbackFormId) }, + + async findEventRegistrationNotificationTask(handle, attendanceId) { + return await taskRepository.findEventRegistrationNotificationTask(handle, attendanceId) + }, + + async findEventReminderNotificationTask(handle, eventId) { + return await taskRepository.findEventReminderNotificationTask(handle, eventId) + }, } } diff --git a/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx b/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx index 6feb01a656..8b29e849fa 100644 --- a/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx +++ b/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx @@ -6,6 +6,7 @@ import { cn } from "@dotkomonline/ui" import { useTRPC } from "@/utils/trpc/client" import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query" import type { UserNotificationDTO } from "@dotkomonline/rpc" +import { useSubscription } from "@trpc/tanstack-react-query" interface NotificationDropdownProps extends ComponentProps { open?: boolean @@ -16,6 +17,17 @@ export const NotificationDropdown = ({ open, amountUnread, ...props }: Notificat const trpc = useTRPC() const queryClient = useQueryClient() + useSubscription( + trpc.notification.onNewNotification.subscriptionOptions(undefined, { + onData: () => { + queryClient.invalidateQueries(trpc.notification.getUnreadCount.queryOptions()) + queryClient.invalidateQueries({ + queryKey: trpc.notification.getMyNotifications.infiniteQueryOptions({ take: 10 }).queryKey, + }) + }, + }) + ) + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ ...trpc.notification.getMyNotifications.infiniteQueryOptions({ take: 10 }), enabled: open, @@ -109,7 +121,7 @@ export const NotificationDropdown = ({ open, amountUnread, ...props }: Notificat "mx-4 xs:ml-4 xs:w-102 xs:-mr-16 lg:-mr-4", "rounded-3xl shadow-sm", "bg-blue-50 border border-blue-100", - "dark:border-white/10 dark:bg-stone-700" + "dark:border-white/10 dark:bg-stone-800" )} >
diff --git a/apps/web/src/utils/trpc/QueryProvider.tsx b/apps/web/src/utils/trpc/QueryProvider.tsx index bd2ab71845..0fdd012c29 100644 --- a/apps/web/src/utils/trpc/QueryProvider.tsx +++ b/apps/web/src/utils/trpc/QueryProvider.tsx @@ -1,7 +1,7 @@ "use client" import { env } from "@/env" -import { getAccessToken } from "@auth0/nextjs-auth0" +import { getAccessToken, useUser } from "@auth0/nextjs-auth0" import type { AppRouter } from "@dotkomonline/rpc" import { createLogoutUrl, isAccessTokenFetchFailure, toAbsoluteUrl } from "@dotkomonline/utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" @@ -50,11 +50,16 @@ export const useTRPCSSERegisterChangeConnectionState = () => { } export const QueryProvider = ({ children }: PropsWithChildren) => { + const { user } = useUser() + const userId = user?.sub + const [trpcSSERegisterChangeConnectionState, setTRPCSSERegisterChangeConnectionState] = useState("connecting") - const trpcConfig: CreateTRPCClientOptions = useMemo( - () => ({ + const trpcConfig: CreateTRPCClientOptions = useMemo(() => { + const hasAuthenticatedUser = typeof userId === "string" && userId !== "" + + return { links: [ loggerLink({ enabled: (opts) => opts.direction === "down" && opts.result instanceof Error, @@ -64,6 +69,23 @@ export const QueryProvider = ({ children }: PropsWithChildren) => { true: httpSubscriptionLink({ transformer: superjson, url: `${env.NEXT_PUBLIC_RPC_HOST}/api/trpc`, + async connectionParams() { + if (!hasAuthenticatedUser) { + return {} + } + + try { + const token = await getAccessToken() + + if (typeof token === "string" && token !== "") { + return { token } + } + } catch { + // not authenticated + } + + return {} + }, }), false: httpBatchLink({ transformer: superjson, @@ -100,9 +122,8 @@ export const QueryProvider = ({ children }: PropsWithChildren) => { }), }), ], - }), - [] - ) + } + }, [userId]) const trpcClient = useMemo(() => createTRPCClient(trpcConfig), [trpcConfig]) diff --git a/packages/db/prisma/migrations/20260325214642_fix_notification_recipient_table_name/migration.sql b/packages/db/prisma/migrations/20260325214642_fix_notification_recipient_table_name/migration.sql deleted file mode 100644 index d33fd7c0e6..0000000000 --- a/packages/db/prisma/migrations/20260325214642_fix_notification_recipient_table_name/migration.sql +++ /dev/null @@ -1,30 +0,0 @@ -/* - Warnings: - - - You are about to drop the `NotificationRecipient` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropForeignKey -ALTER TABLE "NotificationRecipient" DROP CONSTRAINT "NotificationRecipient_notification_id_fkey"; - --- DropForeignKey -ALTER TABLE "NotificationRecipient" DROP CONSTRAINT "NotificationRecipient_user_id_fkey"; - --- DropTable -DROP TABLE "NotificationRecipient"; - --- CreateTable -CREATE TABLE "notification_recipient" ( - "id" TEXT NOT NULL, - "read_at" TIMESTAMPTZ(3), - "notification_id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - - CONSTRAINT "notification_recipient_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "notification_recipient" ADD CONSTRAINT "notification_recipient_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notification"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "notification_recipient" ADD CONSTRAINT "notification_recipient_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ow_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260403150934_notification_actor_group_nullable/migration.sql b/packages/db/prisma/migrations/20260403150934_notification_actor_group_nullable/migration.sql new file mode 100644 index 0000000000..5db1d50664 --- /dev/null +++ b/packages/db/prisma/migrations/20260403150934_notification_actor_group_nullable/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "notification" DROP CONSTRAINT "notification_actor_group_id_fkey"; + +-- AlterTable +ALTER TABLE "notification" ALTER COLUMN "actor_group_id" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "notification" ADD CONSTRAINT "notification_actor_group_id_fkey" FOREIGN KEY ("actor_group_id") REFERENCES "group"("slug") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260405000000_add_notification_task_types/migration.sql b/packages/db/prisma/migrations/20260405000000_add_notification_task_types/migration.sql new file mode 100644 index 0000000000..9fcb8a1059 --- /dev/null +++ b/packages/db/prisma/migrations/20260405000000_add_notification_task_types/migration.sql @@ -0,0 +1,4 @@ +-- AlterEnum +ALTER TYPE "task_type" ADD VALUE 'SEND_NOTIFICATION_EVENT_REGISTRATION'; +ALTER TYPE "task_type" ADD VALUE 'SEND_NOTIFICATION_EVENT_REMINDER'; +ALTER TYPE "task_type" ADD VALUE 'SEND_NOTIFICATION_JOB_LISTING_REMINDER'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 52e1f378bf..e6e4a6733f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -592,13 +592,16 @@ model ArticleTagLink { } enum TaskType { - RESERVE_ATTENDEE @map("RESERVE_ATTENDEE") - CHARGE_ATTENDEE @map("CHARGE_ATTENDEE") - MERGE_ATTENDANCE_POOLS @map("MERGE_ATTENDANCE_POOLS") - VERIFY_PAYMENT @map("VERIFY_PAYMENT") - VERIFY_FEEDBACK_ANSWERED @map("VERIFY_FEEDBACK_ANSWERED") - SEND_FEEDBACK_FORM_EMAILS @map("SEND_FEEDBACK_FORM_EMAILS") - VERIFY_ATTENDEE_ATTENDED @map("VERIFY_ATTENDEE_ATTENDED") + RESERVE_ATTENDEE @map("RESERVE_ATTENDEE") + CHARGE_ATTENDEE @map("CHARGE_ATTENDEE") + MERGE_ATTENDANCE_POOLS @map("MERGE_ATTENDANCE_POOLS") + VERIFY_PAYMENT @map("VERIFY_PAYMENT") + VERIFY_FEEDBACK_ANSWERED @map("VERIFY_FEEDBACK_ANSWERED") + SEND_FEEDBACK_FORM_EMAILS @map("SEND_FEEDBACK_FORM_EMAILS") + VERIFY_ATTENDEE_ATTENDED @map("VERIFY_ATTENDEE_ATTENDED") + SEND_NOTIFICATION_EVENT_REGISTRATION @map("SEND_NOTIFICATION_EVENT_REGISTRATION") + SEND_NOTIFICATION_EVENT_REMINDER @map("SEND_NOTIFICATION_EVENT_REMINDER") + SEND_NOTIFICATION_JOB_LISTING_REMINDER @map("SEND_NOTIFICATION_JOB_LISTING_REMINDER") @@map("task_type") } @@ -845,8 +848,8 @@ model Notification { payloadType NotificationPayloadType @map("payload_type") /// The group that created the notification or the system created on behalf of. - actorGroupId String @map("actor_group_id") - actorGroup Group @relation(fields: [actorGroupId], references: [slug]) + actorGroupId String? @map("actor_group_id") + actorGroup Group? @relation(fields: [actorGroupId], references: [slug]) /// The specific user that created the notification. This is meant for logging purposes. Nullable because the system /// can create notifications without a specific user to link it to, for example with recurring tasks. createdById String? @map("created_by_id")