Add notification bell system and MOPC onboarding form
Build and Push Docker Image / build (push) Successful in 8m59s
Details
Build and Push Docker Image / build (push) Successful in 8m59s
Details
Notification System: - Add InAppNotification and NotificationEmailSetting database models - Create notification service with 60+ notification types for all user roles - Add notification router with CRUD endpoints - Build NotificationBell UI component with dropdown and unread count - Integrate bell into admin, jury, mentor, and observer navs - Add notification email settings admin UI in Settings > Notifications - Add notification triggers to filtering router (complete/failed) - Add sendNotificationEmail function to email library - Add formatRelativeTime utility function MOPC Onboarding Form: - Create /apply landing page with auto-redirect for single form - Create seed script for MOPC 2026 application form (6 steps) - Create seed script for default notification email settings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e1968d45df
commit
0277768ed7
|
|
@ -0,0 +1,58 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "InAppNotification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"priority" TEXT NOT NULL DEFAULT 'normal',
|
||||
"icon" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"linkUrl" TEXT,
|
||||
"linkLabel" TEXT,
|
||||
"metadata" JSONB,
|
||||
"groupKey" TEXT,
|
||||
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"readAt" TIMESTAMP(3),
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InAppNotification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NotificationEmailSetting" (
|
||||
"id" TEXT NOT NULL,
|
||||
"notificationType" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sendEmail" BOOLEAN NOT NULL DEFAULT true,
|
||||
"emailSubject" TEXT,
|
||||
"emailTemplate" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedById" TEXT,
|
||||
|
||||
CONSTRAINT "NotificationEmailSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InAppNotification_userId_isRead_idx" ON "InAppNotification"("userId", "isRead");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InAppNotification_userId_createdAt_idx" ON "InAppNotification"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InAppNotification_groupKey_idx" ON "InAppNotification"("groupKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NotificationEmailSetting_notificationType_key" ON "NotificationEmailSetting"("notificationType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationEmailSetting_category_idx" ON "NotificationEmailSetting"("category");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InAppNotification" ADD CONSTRAINT "InAppNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationEmailSetting" ADD CONSTRAINT "NotificationEmailSetting_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -278,6 +278,10 @@ model User {
|
|||
// Award overrides
|
||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||
|
||||
// In-app notifications
|
||||
notifications InAppNotification[] @relation("UserNotifications")
|
||||
notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater")
|
||||
|
||||
// NextAuth relations
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
|
@ -759,6 +763,55 @@ model NotificationLog {
|
|||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IN-APP NOTIFICATIONS
|
||||
// =============================================================================
|
||||
|
||||
model InAppNotification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String // FILTERING_COMPLETE, NEW_APPLICATION, ASSIGNED_TO_PROJECT, etc.
|
||||
priority String @default("normal") // low, normal, high, urgent
|
||||
icon String? // lucide icon name
|
||||
title String
|
||||
message String @db.Text
|
||||
linkUrl String? // Where to navigate when clicked
|
||||
linkLabel String? // CTA text
|
||||
metadata Json? @db.JsonB // Extra context (projectId, roundId, etc.)
|
||||
groupKey String? // For batching similar notifications
|
||||
|
||||
isRead Boolean @default(false)
|
||||
readAt DateTime?
|
||||
expiresAt DateTime? // Auto-dismiss after date
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, isRead])
|
||||
@@index([userId, createdAt])
|
||||
@@index([groupKey])
|
||||
}
|
||||
|
||||
model NotificationEmailSetting {
|
||||
id String @id @default(cuid())
|
||||
notificationType String @unique // e.g., "ADVANCED_TO_ROUND", "ASSIGNED_TO_PROJECT"
|
||||
category String // "team", "jury", "mentor", "admin"
|
||||
label String // Human-readable label for admin UI
|
||||
description String? // Help text
|
||||
sendEmail Boolean @default(true)
|
||||
emailSubject String? // Custom subject template (optional)
|
||||
emailTemplate String? @db.Text // Custom body template (optional)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedById String?
|
||||
updatedBy User? @relation("NotificationSettingUpdater", fields: [updatedById], references: [id])
|
||||
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LEARNING HUB (Phase 2)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* Seed script for MOPC Onboarding Form
|
||||
*
|
||||
* This creates the application form configuration for the Monaco Ocean Protection Challenge.
|
||||
* The form is accessible at /apply/mopc-2026
|
||||
*
|
||||
* Run with: npx tsx prisma/seed-mopc-onboarding.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient, FormFieldType, SpecialFieldType } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const MOPC_FORM_CONFIG = {
|
||||
name: 'MOPC Application 2026',
|
||||
description: 'Monaco Ocean Protection Challenge application form',
|
||||
publicSlug: 'mopc-2026',
|
||||
status: 'PUBLISHED',
|
||||
isPublic: true,
|
||||
sendConfirmationEmail: true,
|
||||
sendTeamInviteEmails: true,
|
||||
confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge',
|
||||
confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026!
|
||||
|
||||
We have received your application and our team will review it carefully.
|
||||
|
||||
If you have any questions, please don't hesitate to reach out.
|
||||
|
||||
Good luck!
|
||||
The MOPC Team`,
|
||||
confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.',
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
name: 'category',
|
||||
title: 'Competition Category',
|
||||
description: 'Select your competition track',
|
||||
sortOrder: 0,
|
||||
isOptional: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'competitionCategory',
|
||||
label: 'Which category best describes your project?',
|
||||
fieldType: FormFieldType.RADIO,
|
||||
specialType: SpecialFieldType.COMPETITION_CATEGORY,
|
||||
required: true,
|
||||
sortOrder: 0,
|
||||
width: 'full',
|
||||
projectMapping: 'competitionCategory',
|
||||
description: 'Choose the category that best fits your stage of development',
|
||||
optionsJson: [
|
||||
{
|
||||
value: 'STARTUP',
|
||||
label: 'Startup',
|
||||
description: 'You have an existing company or registered business entity',
|
||||
},
|
||||
{
|
||||
value: 'BUSINESS_CONCEPT',
|
||||
label: 'Business Concept',
|
||||
description: 'You are a student, graduate, or have an idea not yet incorporated',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
title: 'Contact Information',
|
||||
description: 'Tell us how to reach you',
|
||||
sortOrder: 1,
|
||||
isOptional: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'contactName',
|
||||
label: 'Full Name',
|
||||
fieldType: FormFieldType.TEXT,
|
||||
required: true,
|
||||
sortOrder: 0,
|
||||
width: 'half',
|
||||
placeholder: 'Enter your full name',
|
||||
},
|
||||
{
|
||||
name: 'contactEmail',
|
||||
label: 'Email Address',
|
||||
fieldType: FormFieldType.EMAIL,
|
||||
required: true,
|
||||
sortOrder: 1,
|
||||
width: 'half',
|
||||
placeholder: 'your.email@example.com',
|
||||
description: 'We will use this email for all communications',
|
||||
},
|
||||
{
|
||||
name: 'contactPhone',
|
||||
label: 'Phone Number',
|
||||
fieldType: FormFieldType.PHONE,
|
||||
required: true,
|
||||
sortOrder: 2,
|
||||
width: 'half',
|
||||
placeholder: '+1 (555) 123-4567',
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
label: 'Country',
|
||||
fieldType: FormFieldType.SELECT,
|
||||
specialType: SpecialFieldType.COUNTRY_SELECT,
|
||||
required: true,
|
||||
sortOrder: 3,
|
||||
width: 'half',
|
||||
projectMapping: 'country',
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
label: 'City',
|
||||
fieldType: FormFieldType.TEXT,
|
||||
required: false,
|
||||
sortOrder: 4,
|
||||
width: 'half',
|
||||
placeholder: 'City name',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
title: 'Project Details',
|
||||
description: 'Tell us about your ocean protection project',
|
||||
sortOrder: 2,
|
||||
isOptional: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'projectName',
|
||||
label: 'Project Name',
|
||||
fieldType: FormFieldType.TEXT,
|
||||
required: true,
|
||||
sortOrder: 0,
|
||||
width: 'full',
|
||||
projectMapping: 'title',
|
||||
maxLength: 200,
|
||||
placeholder: 'Give your project a memorable name',
|
||||
},
|
||||
{
|
||||
name: 'teamName',
|
||||
label: 'Team / Company Name',
|
||||
fieldType: FormFieldType.TEXT,
|
||||
required: false,
|
||||
sortOrder: 1,
|
||||
width: 'half',
|
||||
projectMapping: 'teamName',
|
||||
placeholder: 'Your team or company name',
|
||||
},
|
||||
{
|
||||
name: 'oceanIssue',
|
||||
label: 'Primary Ocean Issue',
|
||||
fieldType: FormFieldType.SELECT,
|
||||
specialType: SpecialFieldType.OCEAN_ISSUE,
|
||||
required: true,
|
||||
sortOrder: 2,
|
||||
width: 'half',
|
||||
projectMapping: 'oceanIssue',
|
||||
description: 'Select the primary ocean issue your project addresses',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Project Description',
|
||||
fieldType: FormFieldType.TEXTAREA,
|
||||
required: true,
|
||||
sortOrder: 3,
|
||||
width: 'full',
|
||||
projectMapping: 'description',
|
||||
minLength: 50,
|
||||
maxLength: 2000,
|
||||
placeholder: 'Describe your project, its goals, and how it will help protect the ocean...',
|
||||
description: 'Provide a clear description of your project (50-2000 characters)',
|
||||
},
|
||||
{
|
||||
name: 'websiteUrl',
|
||||
label: 'Website URL',
|
||||
fieldType: FormFieldType.URL,
|
||||
required: false,
|
||||
sortOrder: 4,
|
||||
width: 'half',
|
||||
projectMapping: 'websiteUrl',
|
||||
placeholder: 'https://yourproject.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
title: 'Team Members',
|
||||
description: 'Add your team members (they will receive email invitations)',
|
||||
sortOrder: 3,
|
||||
isOptional: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'teamMembers',
|
||||
label: 'Team Members',
|
||||
fieldType: FormFieldType.TEXT, // Will use specialType for rendering
|
||||
specialType: SpecialFieldType.TEAM_MEMBERS,
|
||||
required: false,
|
||||
sortOrder: 0,
|
||||
width: 'full',
|
||||
description: 'Add up to 5 team members. They will receive an invitation email to join your application.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'additional',
|
||||
title: 'Additional Details',
|
||||
description: 'A few more questions about your project',
|
||||
sortOrder: 4,
|
||||
isOptional: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'institution',
|
||||
label: 'University / School',
|
||||
fieldType: FormFieldType.TEXT,
|
||||
required: false,
|
||||
sortOrder: 0,
|
||||
width: 'half',
|
||||
projectMapping: 'institution',
|
||||
placeholder: 'Name of your institution',
|
||||
conditionJson: {
|
||||
field: 'competitionCategory',
|
||||
operator: 'equals',
|
||||
value: 'BUSINESS_CONCEPT',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'startupCreatedDate',
|
||||
label: 'Startup Founded Date',
|
||||
fieldType: FormFieldType.DATE,
|
||||
required: false,
|
||||
sortOrder: 1,
|
||||
width: 'half',
|
||||
description: 'When was your company founded?',
|
||||
conditionJson: {
|
||||
field: 'competitionCategory',
|
||||
operator: 'equals',
|
||||
value: 'STARTUP',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'wantsMentorship',
|
||||
label: 'I am interested in receiving mentorship',
|
||||
fieldType: FormFieldType.CHECKBOX,
|
||||
required: false,
|
||||
sortOrder: 2,
|
||||
width: 'full',
|
||||
projectMapping: 'wantsMentorship',
|
||||
description: 'Check this box if you would like to be paired with an expert mentor',
|
||||
},
|
||||
{
|
||||
name: 'referralSource',
|
||||
label: 'How did you hear about MOPC?',
|
||||
fieldType: FormFieldType.SELECT,
|
||||
required: false,
|
||||
sortOrder: 3,
|
||||
width: 'half',
|
||||
optionsJson: [
|
||||
{ value: 'social_media', label: 'Social Media' },
|
||||
{ value: 'search_engine', label: 'Search Engine' },
|
||||
{ value: 'word_of_mouth', label: 'Word of Mouth' },
|
||||
{ value: 'university', label: 'University / School' },
|
||||
{ value: 'partner', label: 'Partner Organization' },
|
||||
{ value: 'media', label: 'News / Media' },
|
||||
{ value: 'event', label: 'Event / Conference' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
title: 'Review & Submit',
|
||||
description: 'Review your application and accept the terms',
|
||||
sortOrder: 5,
|
||||
isOptional: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'instructions',
|
||||
label: 'Review Instructions',
|
||||
fieldType: FormFieldType.INSTRUCTIONS,
|
||||
required: false,
|
||||
sortOrder: 0,
|
||||
width: 'full',
|
||||
description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.',
|
||||
},
|
||||
{
|
||||
name: 'gdprConsent',
|
||||
label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy',
|
||||
fieldType: FormFieldType.CHECKBOX,
|
||||
specialType: SpecialFieldType.GDPR_CONSENT,
|
||||
required: true,
|
||||
sortOrder: 1,
|
||||
width: 'full',
|
||||
},
|
||||
{
|
||||
name: 'termsAccepted',
|
||||
label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge',
|
||||
fieldType: FormFieldType.CHECKBOX,
|
||||
required: true,
|
||||
sortOrder: 2,
|
||||
width: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding MOPC onboarding form...')
|
||||
|
||||
// Check if form already exists
|
||||
const existingForm = await prisma.applicationForm.findUnique({
|
||||
where: { publicSlug: MOPC_FORM_CONFIG.publicSlug },
|
||||
})
|
||||
|
||||
if (existingForm) {
|
||||
console.log('Form with slug "mopc-2026" already exists. Updating...')
|
||||
|
||||
// Delete existing steps and fields to recreate them
|
||||
await prisma.applicationFormField.deleteMany({
|
||||
where: { formId: existingForm.id },
|
||||
})
|
||||
await prisma.onboardingStep.deleteMany({
|
||||
where: { formId: existingForm.id },
|
||||
})
|
||||
|
||||
// Update the form
|
||||
await prisma.applicationForm.update({
|
||||
where: { id: existingForm.id },
|
||||
data: {
|
||||
name: MOPC_FORM_CONFIG.name,
|
||||
description: MOPC_FORM_CONFIG.description,
|
||||
status: MOPC_FORM_CONFIG.status,
|
||||
isPublic: MOPC_FORM_CONFIG.isPublic,
|
||||
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
|
||||
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
|
||||
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
|
||||
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
|
||||
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
|
||||
},
|
||||
})
|
||||
|
||||
// Create steps and fields
|
||||
for (const stepData of STEPS) {
|
||||
const step = await prisma.onboardingStep.create({
|
||||
data: {
|
||||
formId: existingForm.id,
|
||||
name: stepData.name,
|
||||
title: stepData.title,
|
||||
description: stepData.description,
|
||||
sortOrder: stepData.sortOrder,
|
||||
isOptional: stepData.isOptional,
|
||||
},
|
||||
})
|
||||
|
||||
for (const fieldData of stepData.fields) {
|
||||
const field = fieldData as Record<string, unknown>
|
||||
await prisma.applicationFormField.create({
|
||||
data: {
|
||||
formId: existingForm.id,
|
||||
stepId: step.id,
|
||||
name: field.name as string,
|
||||
label: field.label as string,
|
||||
fieldType: field.fieldType as FormFieldType,
|
||||
specialType: (field.specialType as SpecialFieldType) || null,
|
||||
required: field.required as boolean,
|
||||
sortOrder: field.sortOrder as number,
|
||||
width: field.width as string,
|
||||
description: (field.description as string) || null,
|
||||
placeholder: (field.placeholder as string) || null,
|
||||
projectMapping: (field.projectMapping as string) || null,
|
||||
minLength: (field.minLength as number) || null,
|
||||
maxLength: (field.maxLength as number) || null,
|
||||
optionsJson: field.optionsJson as object | undefined,
|
||||
conditionJson: field.conditionJson as object | undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
|
||||
}
|
||||
|
||||
console.log(`\nForm updated: ${existingForm.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new form
|
||||
const form = await prisma.applicationForm.create({
|
||||
data: {
|
||||
name: MOPC_FORM_CONFIG.name,
|
||||
description: MOPC_FORM_CONFIG.description,
|
||||
publicSlug: MOPC_FORM_CONFIG.publicSlug,
|
||||
status: MOPC_FORM_CONFIG.status,
|
||||
isPublic: MOPC_FORM_CONFIG.isPublic,
|
||||
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
|
||||
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
|
||||
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
|
||||
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
|
||||
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Created form: ${form.id}`)
|
||||
|
||||
// Create steps and fields
|
||||
for (const stepData of STEPS) {
|
||||
const step = await prisma.onboardingStep.create({
|
||||
data: {
|
||||
formId: form.id,
|
||||
name: stepData.name,
|
||||
title: stepData.title,
|
||||
description: stepData.description,
|
||||
sortOrder: stepData.sortOrder,
|
||||
isOptional: stepData.isOptional,
|
||||
},
|
||||
})
|
||||
|
||||
for (const fieldData of stepData.fields) {
|
||||
const field = fieldData as Record<string, unknown>
|
||||
await prisma.applicationFormField.create({
|
||||
data: {
|
||||
formId: form.id,
|
||||
stepId: step.id,
|
||||
name: field.name as string,
|
||||
label: field.label as string,
|
||||
fieldType: field.fieldType as FormFieldType,
|
||||
specialType: (field.specialType as SpecialFieldType) || null,
|
||||
required: field.required as boolean,
|
||||
sortOrder: field.sortOrder as number,
|
||||
width: field.width as string,
|
||||
description: (field.description as string) || null,
|
||||
placeholder: (field.placeholder as string) || null,
|
||||
projectMapping: (field.projectMapping as string) || null,
|
||||
minLength: (field.minLength as number) || null,
|
||||
maxLength: (field.maxLength as number) || null,
|
||||
optionsJson: field.optionsJson as object | undefined,
|
||||
conditionJson: field.conditionJson as object | undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
|
||||
}
|
||||
|
||||
console.log(`\nMOPC form seeded successfully!`)
|
||||
console.log(`Form ID: ${form.id}`)
|
||||
console.log(`Public URL: /apply/${form.publicSlug}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Seed script for notification email settings
|
||||
*
|
||||
* Run with: npx tsx prisma/seed-notification-settings.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// Default notification email settings by category
|
||||
const NOTIFICATION_EMAIL_SETTINGS = [
|
||||
// Team / Applicant notifications
|
||||
{
|
||||
notificationType: 'APPLICATION_SUBMITTED',
|
||||
category: 'team',
|
||||
label: 'Application Submitted',
|
||||
description: 'When a team submits their application',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'TEAM_INVITE_RECEIVED',
|
||||
category: 'team',
|
||||
label: 'Team Invitation Received',
|
||||
description: 'When someone is invited to join a team',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'TEAM_MEMBER_JOINED',
|
||||
category: 'team',
|
||||
label: 'Team Member Joined',
|
||||
description: 'When a new member joins the team',
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
notificationType: 'ADVANCED_SEMIFINAL',
|
||||
category: 'team',
|
||||
label: 'Advanced to Semi-Finals',
|
||||
description: 'When a project advances to semi-finals',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'ADVANCED_FINAL',
|
||||
category: 'team',
|
||||
label: 'Selected as Finalist',
|
||||
description: 'When a project is selected as a finalist',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'MENTOR_ASSIGNED',
|
||||
category: 'team',
|
||||
label: 'Mentor Assigned',
|
||||
description: 'When a mentor is assigned to the team',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'NOT_SELECTED',
|
||||
category: 'team',
|
||||
label: 'Not Selected',
|
||||
description: 'When a project is not selected for the next round',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FEEDBACK_AVAILABLE',
|
||||
category: 'team',
|
||||
label: 'Feedback Available',
|
||||
description: 'When jury feedback becomes available',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'WINNER_ANNOUNCEMENT',
|
||||
category: 'team',
|
||||
label: 'Winner Announcement',
|
||||
description: 'When a project wins an award',
|
||||
sendEmail: true,
|
||||
},
|
||||
|
||||
// Jury notifications
|
||||
{
|
||||
notificationType: 'ASSIGNED_TO_PROJECT',
|
||||
category: 'jury',
|
||||
label: 'Assigned to Project',
|
||||
description: 'When a jury member is assigned to a project',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'BATCH_ASSIGNED',
|
||||
category: 'jury',
|
||||
label: 'Batch Assignment',
|
||||
description: 'When multiple projects are assigned at once',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'ROUND_NOW_OPEN',
|
||||
category: 'jury',
|
||||
label: 'Round Now Open',
|
||||
description: 'When a round opens for evaluation',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'REMINDER_24H',
|
||||
category: 'jury',
|
||||
label: 'Reminder (24h)',
|
||||
description: 'Reminder 24 hours before deadline',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'REMINDER_1H',
|
||||
category: 'jury',
|
||||
label: 'Reminder (1h)',
|
||||
description: 'Urgent reminder 1 hour before deadline',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'ROUND_CLOSED',
|
||||
category: 'jury',
|
||||
label: 'Round Closed',
|
||||
description: 'When a round closes',
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
notificationType: 'AWARD_VOTING_OPEN',
|
||||
category: 'jury',
|
||||
label: 'Award Voting Open',
|
||||
description: 'When special award voting opens',
|
||||
sendEmail: true,
|
||||
},
|
||||
|
||||
// Mentor notifications
|
||||
{
|
||||
notificationType: 'MENTEE_ASSIGNED',
|
||||
category: 'mentor',
|
||||
label: 'Mentee Assigned',
|
||||
description: 'When assigned as mentor to a project',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'MENTEE_UPLOADED_DOCS',
|
||||
category: 'mentor',
|
||||
label: 'Mentee Documents Updated',
|
||||
description: 'When a mentee uploads new documents',
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
notificationType: 'MENTEE_ADVANCED',
|
||||
category: 'mentor',
|
||||
label: 'Mentee Advanced',
|
||||
description: 'When a mentee advances to the next round',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'MENTEE_FINALIST',
|
||||
category: 'mentor',
|
||||
label: 'Mentee is Finalist',
|
||||
description: 'When a mentee is selected as finalist',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'MENTEE_WON',
|
||||
category: 'mentor',
|
||||
label: 'Mentee Won',
|
||||
description: 'When a mentee wins an award',
|
||||
sendEmail: true,
|
||||
},
|
||||
|
||||
// Observer notifications
|
||||
{
|
||||
notificationType: 'ROUND_STARTED',
|
||||
category: 'observer',
|
||||
label: 'Round Started',
|
||||
description: 'When a new round begins',
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
notificationType: 'ROUND_COMPLETED',
|
||||
category: 'observer',
|
||||
label: 'Round Completed',
|
||||
description: 'When a round is completed',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'FINALISTS_ANNOUNCED',
|
||||
category: 'observer',
|
||||
label: 'Finalists Announced',
|
||||
description: 'When finalists are announced',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'WINNERS_ANNOUNCED',
|
||||
category: 'observer',
|
||||
label: 'Winners Announced',
|
||||
description: 'When winners are announced',
|
||||
sendEmail: true,
|
||||
},
|
||||
|
||||
// Admin notifications (in-app only by default)
|
||||
{
|
||||
notificationType: 'FILTERING_COMPLETE',
|
||||
category: 'admin',
|
||||
label: 'AI Filtering Complete',
|
||||
description: 'When AI filtering job completes',
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
notificationType: 'FILTERING_FAILED',
|
||||
category: 'admin',
|
||||
label: 'AI Filtering Failed',
|
||||
description: 'When AI filtering job fails',
|
||||
sendEmail: true,
|
||||
},
|
||||
{
|
||||
notificationType: 'NEW_APPLICATION',
|
||||
category: 'admin',
|
||||
label: 'New Application',
|
||||
description: 'When a new application is received',
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
notificationType: 'SYSTEM_ERROR',
|
||||
category: 'admin',
|
||||
label: 'System Error',
|
||||
description: 'When a system error occurs',
|
||||
sendEmail: true,
|
||||
},
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding notification email settings...')
|
||||
|
||||
for (const setting of NOTIFICATION_EMAIL_SETTINGS) {
|
||||
await prisma.notificationEmailSetting.upsert({
|
||||
where: { notificationType: setting.notificationType },
|
||||
update: {
|
||||
category: setting.category,
|
||||
label: setting.label,
|
||||
description: setting.description,
|
||||
},
|
||||
create: setting,
|
||||
})
|
||||
console.log(` - ${setting.label} (${setting.notificationType})`)
|
||||
}
|
||||
|
||||
console.log(`\nSeeded ${NOTIFICATION_EMAIL_SETTINGS.length} notification email settings.`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { FileText, Calendar, ArrowRight, ExternalLink } from 'lucide-react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ApplyLandingPage() {
|
||||
// Get all published, public application forms
|
||||
const forms = await prisma.applicationForm.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
isPublic: true,
|
||||
OR: [
|
||||
{ opensAt: null },
|
||||
{ opensAt: { lte: new Date() } },
|
||||
],
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ closesAt: null },
|
||||
{ closesAt: { gte: new Date() } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
publicSlug: true,
|
||||
opensAt: true,
|
||||
closesAt: true,
|
||||
steps: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If exactly one form is available, redirect to it
|
||||
if (forms.length === 1 && forms[0].publicSlug) {
|
||||
const form = forms[0]
|
||||
const hasSteps = form.steps && form.steps.length > 0
|
||||
const url = hasSteps
|
||||
? `/apply/${form.publicSlug}/wizard`
|
||||
: `/apply/${form.publicSlug}`
|
||||
redirect(url as Route)
|
||||
}
|
||||
|
||||
// If no forms are available, show a message
|
||||
if (forms.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
|
||||
<div className="container max-w-2xl py-16">
|
||||
<div className="text-center mb-12">
|
||||
<Logo variant="long" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<FileText className="h-16 w-16 text-muted-foreground/30 mb-6" />
|
||||
<h1 className="text-2xl font-semibold mb-3">Applications Not Open</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
There are currently no open applications. Please check back later
|
||||
or visit our website for more information.
|
||||
</p>
|
||||
<Button asChild className="mt-8">
|
||||
<a href="https://monaco-opc.com" target="_blank" rel="noopener noreferrer">
|
||||
Visit Website
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multiple forms available - show selection
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
|
||||
<div className="container max-w-4xl py-16">
|
||||
<div className="text-center mb-12">
|
||||
<Logo variant="long" />
|
||||
<h1 className="text-3xl font-bold mt-8 mb-3">Apply Now</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Select an application form below to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{forms.map((form) => {
|
||||
const hasSteps = form.steps && form.steps.length > 0
|
||||
const url = hasSteps
|
||||
? `/apply/${form.publicSlug}/wizard`
|
||||
: `/apply/${form.publicSlug}`
|
||||
|
||||
return (
|
||||
<Card key={form.id} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<Link href={url as Route} className="block">
|
||||
<div className="flex items-stretch">
|
||||
<div className="flex-1 p-6">
|
||||
<CardHeader className="p-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
{form.name}
|
||||
</CardTitle>
|
||||
{form.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{form.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(form.opensAt || form.closesAt) && (
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{form.closesAt && (
|
||||
<span>
|
||||
Closes: {new Date(form.closesAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center px-6 bg-muted/30 border-l">
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Having trouble? Contact us at{' '}
|
||||
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
|
||||
support@monaco-opc.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import { getInitials } from '@/lib/utils'
|
|||
import { Logo } from '@/components/shared/logo'
|
||||
import { EditionSelector } from '@/components/shared/edition-selector'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
interface AdminSidebarProps {
|
||||
|
|
@ -137,18 +138,21 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||
{/* Mobile menu button */}
|
||||
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
|
||||
<Logo showText textSuffix="Admin" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
|
|
@ -241,6 +245,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||
|
||||
{/* User Profile Section */}
|
||||
<div className="border-t p-3">
|
||||
{/* Notification Bell - Desktop */}
|
||||
<div className="hidden lg:flex justify-end mb-2">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import type { Route } from 'next'
|
||||
import { BookOpen, ClipboardList, Home, LogOut, Menu, Settings, User, X } from 'lucide-react'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
|
||||
interface JuryNavProps {
|
||||
user: {
|
||||
|
|
@ -84,6 +85,7 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||
|
||||
{/* User menu & mobile toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { BookOpen, Home, LogOut, Menu, Settings, User, Users, X } from 'lucide-react'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
|
||||
interface MentorNavProps {
|
||||
user: {
|
||||
|
|
@ -84,6 +85,7 @@ export function MentorNav({ user }: MentorNavProps) {
|
|||
|
||||
{/* User menu & mobile toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import type { Route } from 'next'
|
||||
import { Home, BarChart3, Menu, X, LogOut, Eye, Settings } from 'lucide-react'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
|
||||
interface ObserverNavProps {
|
||||
user: {
|
||||
|
|
@ -76,6 +77,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
|
|||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from 'sonner'
|
||||
import { Users, Scale, GraduationCap, Eye, Shield } from 'lucide-react'
|
||||
|
||||
// Category icons and labels
|
||||
const CATEGORIES = {
|
||||
team: { label: 'Team / Applicant', icon: Users },
|
||||
jury: { label: 'Jury Members', icon: Scale },
|
||||
mentor: { label: 'Mentors', icon: GraduationCap },
|
||||
observer: { label: 'Observers', icon: Eye },
|
||||
admin: { label: 'Administrators', icon: Shield },
|
||||
}
|
||||
|
||||
type NotificationSetting = {
|
||||
id: string
|
||||
notificationType: string
|
||||
category: string
|
||||
label: string
|
||||
description: string | null
|
||||
sendEmail: boolean
|
||||
}
|
||||
|
||||
export function NotificationSettingsForm() {
|
||||
const { data: settings, isLoading, refetch } = trpc.notification.getEmailSettings.useQuery()
|
||||
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Notification setting updated')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (notificationType: string, sendEmail: boolean) => {
|
||||
updateMutation.mutate({ notificationType, sendEmail })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group settings by category
|
||||
const groupedSettings = (settings || []).reduce(
|
||||
(acc, setting) => {
|
||||
const category = setting.category || 'other'
|
||||
if (!acc[category]) acc[category] = []
|
||||
acc[category].push(setting)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, NotificationSetting[]>
|
||||
)
|
||||
|
||||
if (Object.keys(groupedSettings).length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No notification types configured yet. Notification settings will appear here once the system is seeded.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
toast.info('Run the seed script to populate notification types')
|
||||
}}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toggle which notifications should also send email notifications to users.
|
||||
Users can still disable email notifications in their personal preferences.
|
||||
</p>
|
||||
|
||||
{Object.entries(CATEGORIES).map(([categoryKey, { label, icon: Icon }]) => {
|
||||
const categorySettings = groupedSettings[categoryKey]
|
||||
if (!categorySettings || categorySettings.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card key={categoryKey}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-3 text-base">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
{label}
|
||||
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||
{categorySettings.filter(s => s.sendEmail).length}/{categorySettings.length} enabled
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{categorySettings.map((setting) => (
|
||||
<div
|
||||
key={setting.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{setting.label}
|
||||
</Label>
|
||||
{setting.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{setting.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={setting.sendEmail}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(setting.notificationType, checked)
|
||||
}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
HardDrive,
|
||||
Shield,
|
||||
Settings as SettingsIcon,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { AISettingsForm } from './ai-settings-form'
|
||||
import { AIUsageCard } from './ai-usage-card'
|
||||
|
|
@ -25,6 +26,7 @@ import { EmailSettingsForm } from './email-settings-form'
|
|||
import { StorageSettingsForm } from './storage-settings-form'
|
||||
import { SecuritySettingsForm } from './security-settings-form'
|
||||
import { DefaultsSettingsForm } from './defaults-settings-form'
|
||||
import { NotificationSettingsForm } from './notification-settings-form'
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
|
|
@ -108,7 +110,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
|
||||
return (
|
||||
<Tabs defaultValue="ai" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-7">
|
||||
<TabsTrigger value="ai" className="gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">AI</span>
|
||||
|
|
@ -121,6 +123,10 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
<Mail className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Email</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="storage" className="gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Storage</span>
|
||||
|
|
@ -178,6 +184,20 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Email Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure which notification types should also send email notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NotificationSettingsForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Files,
|
||||
Upload,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
CheckCircle,
|
||||
Star,
|
||||
GraduationCap,
|
||||
Vote,
|
||||
Brain,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
CalendarPlus,
|
||||
Heart,
|
||||
BarChart,
|
||||
Award,
|
||||
UserPlus,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
FileCheck,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Info,
|
||||
Calendar,
|
||||
Newspaper,
|
||||
UserX,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
XCircle,
|
||||
Edit,
|
||||
FileUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Icon mapping for notification types
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Files,
|
||||
Upload,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
CheckCircle,
|
||||
Star,
|
||||
GraduationCap,
|
||||
Vote,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
CalendarPlus,
|
||||
Heart,
|
||||
BarChart,
|
||||
Award,
|
||||
UserPlus,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
FileCheck,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Info,
|
||||
Calendar,
|
||||
Newspaper,
|
||||
UserX,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
XCircle,
|
||||
Edit,
|
||||
FileUp,
|
||||
Bell,
|
||||
}
|
||||
|
||||
// Priority styles
|
||||
const PRIORITY_STYLES = {
|
||||
low: {
|
||||
iconBg: 'bg-slate-100 dark:bg-slate-800',
|
||||
iconColor: 'text-slate-500',
|
||||
},
|
||||
normal: {
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
high: {
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
urgent: {
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
type: string
|
||||
priority: string
|
||||
icon: string | null
|
||||
title: string
|
||||
message: string
|
||||
linkUrl: string | null
|
||||
linkLabel: string | null
|
||||
isRead: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onRead,
|
||||
}: {
|
||||
notification: Notification
|
||||
onRead: () => void
|
||||
}) {
|
||||
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
|
||||
const priorityStyle =
|
||||
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
|
||||
PRIORITY_STYLES.normal
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
|
||||
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
|
||||
)}
|
||||
onClick={onRead}
|
||||
>
|
||||
{/* Icon with colored background */}
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
|
||||
priorityStyle.iconBg
|
||||
)}
|
||||
>
|
||||
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
</span>
|
||||
{notification.linkLabel && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{notification.linkLabel} →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unread dot */}
|
||||
{!notification.isRead && (
|
||||
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (notification.linkUrl) {
|
||||
return (
|
||||
<Link href={notification.linkUrl as Route} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
|
||||
{
|
||||
unreadOnly: filter === 'unread',
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
enabled: open, // Only fetch when popover is open
|
||||
}
|
||||
)
|
||||
|
||||
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const unreadCount = countData ?? 0
|
||||
const notifications = notificationData?.notifications ?? []
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell
|
||||
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
|
||||
/>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
|
||||
hasUrgent ? 'bg-red-500' : 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<h3 className="font-semibold">Notifications</h3>
|
||||
<div className="flex gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={'/admin/settings' as Route}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="sr-only">Notification settings</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
className={cn(
|
||||
'flex-1 py-2 text-sm transition-colors',
|
||||
filter === 'all'
|
||||
? 'border-b-2 border-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex-1 py-2 text-sm transition-colors',
|
||||
filter === 'unread'
|
||||
? 'border-b-2 border-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setFilter('unread')}
|
||||
>
|
||||
Unread ({unreadCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onRead={() => {
|
||||
if (!notification.isRead) {
|
||||
markAsReadMutation.mutate({ id: notification.id })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{notifications.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{filter === 'unread'
|
||||
? 'No unread notifications'
|
||||
: 'No notifications yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="p-2 border-t bg-muted/30">
|
||||
<Button variant="ghost" className="w-full" asChild>
|
||||
<Link href={'/admin/notifications' as Route}>View all notifications</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -780,3 +780,71 @@ export async function sendTeamMemberInviteEmail(
|
|||
html: template.html,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate notification email template
|
||||
*/
|
||||
function getNotificationEmailTemplate(
|
||||
name: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
linkUrl?: string
|
||||
): EmailTemplate {
|
||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||
|
||||
// Format body text preserving line breaks
|
||||
const formattedBody = body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
const content = `
|
||||
${sectionTitle(greeting)}
|
||||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
||||
${formattedBody}
|
||||
</div>
|
||||
${linkUrl ? ctaButton(linkUrl, 'View Details') : ''}
|
||||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||||
You received this email because of your notification preferences on the MOPC Platform.
|
||||
</p>
|
||||
`
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: getEmailWrapper(content),
|
||||
text: `
|
||||
${greeting}
|
||||
|
||||
${body}
|
||||
|
||||
${linkUrl ? `View details: ${linkUrl}` : ''}
|
||||
|
||||
---
|
||||
Monaco Ocean Protection Challenge
|
||||
Together for a healthier ocean.
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification email (triggered by in-app notification system)
|
||||
*/
|
||||
export async function sendNotificationEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
linkUrl?: string
|
||||
): Promise<void> {
|
||||
const template = getNotificationEmailTemplate(name, subject, body, linkUrl)
|
||||
const { transporter, from } = await getTransporter()
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to: email,
|
||||
subject: template.subject,
|
||||
text: template.text,
|
||||
html: template.html,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,3 +59,25 @@ export function daysUntil(date: Date | string): number {
|
|||
const now = new Date()
|
||||
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const now = new Date()
|
||||
const target = new Date(date)
|
||||
const diffMs = now.getTime() - target.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
const diffWeek = Math.floor(diffDay / 7)
|
||||
|
||||
if (diffSec < 60) return 'just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHour < 24) return `${diffHour}h ago`
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
if (diffWeek < 4) return `${diffWeek}w ago`
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(target)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { applicationRouter } from './application'
|
|||
import { mentorRouter } from './mentor'
|
||||
import { filteringRouter } from './filtering'
|
||||
import { specialAwardRouter } from './specialAward'
|
||||
import { notificationRouter } from './notification'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
|
|
@ -66,6 +67,7 @@ export const appRouter = router({
|
|||
mentor: mentorRouter,
|
||||
filtering: filteringRouter,
|
||||
specialAward: specialAwardRouter,
|
||||
notification: notificationRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { executeFilteringRules, type ProgressCallback } from '../services/ai-fil
|
|||
import { logAudit } from '../utils/audit'
|
||||
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
// Background job execution function
|
||||
async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
||||
|
|
@ -123,6 +127,30 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
|||
flagged: flaggedCount,
|
||||
},
|
||||
})
|
||||
|
||||
// Get round name for notification
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
// Notify admins that filtering is complete
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_COMPLETE,
|
||||
title: 'AI Filtering Complete',
|
||||
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
|
||||
linkUrl: `/admin/rounds/${roundId}/filtering/results`,
|
||||
linkLabel: 'View Results',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
roundId,
|
||||
jobId,
|
||||
projectCount: projects.length,
|
||||
passedCount,
|
||||
filteredCount,
|
||||
flaggedCount,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Filtering Job] Error:', error)
|
||||
await prisma.filteringJob.update({
|
||||
|
|
@ -133,6 +161,17 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
|||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
|
||||
// Notify admins of failure
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_FAILED,
|
||||
title: 'AI Filtering Failed',
|
||||
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
linkUrl: `/admin/rounds/${roundId}/filtering`,
|
||||
linkLabel: 'View Details',
|
||||
priority: 'urgent',
|
||||
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Notification Router
|
||||
*
|
||||
* Handles in-app notification CRUD operations for users.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import {
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadCount,
|
||||
deleteExpiredNotifications,
|
||||
deleteOldNotifications,
|
||||
NotificationIcons,
|
||||
NotificationPriorities,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
export const notificationRouter = router({
|
||||
/**
|
||||
* List notifications for the current user
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
unreadOnly: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
cursor: z.string().optional(), // For infinite scroll pagination
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { unreadOnly, limit, cursor } = input
|
||||
const userId = ctx.user.id
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
...(unreadOnly && { isRead: false }),
|
||||
// Don't show expired notifications
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
}
|
||||
|
||||
const notifications = await ctx.prisma.inAppNotification.findMany({
|
||||
where,
|
||||
take: limit + 1, // Fetch one extra to check if there are more
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(cursor && {
|
||||
cursor: { id: cursor },
|
||||
skip: 1, // Skip the cursor item
|
||||
}),
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (notifications.length > limit) {
|
||||
const nextItem = notifications.pop()
|
||||
nextCursor = nextItem?.id
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
nextCursor,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get unread notification count for the current user
|
||||
*/
|
||||
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getUnreadCount(ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if there are any urgent unread notifications
|
||||
*/
|
||||
hasUrgent: protectedProcedure.query(async ({ ctx }) => {
|
||||
const count = await ctx.prisma.inAppNotification.count({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
priority: 'urgent',
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
})
|
||||
return count > 0
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
markAsRead: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await markNotificationAsRead(input.id, ctx.user.id)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for the current user
|
||||
*/
|
||||
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await markAllNotificationsAsRead(ctx.user.id)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a notification (user can only delete their own)
|
||||
*/
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: ctx.user.id, // Ensure user can only delete their own
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get notification email settings (admin only)
|
||||
*/
|
||||
getEmailSettings: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.prisma.notificationEmailSetting.findMany({
|
||||
orderBy: [{ category: 'asc' }, { label: 'asc' }],
|
||||
include: {
|
||||
updatedBy: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a notification email setting (admin only)
|
||||
*/
|
||||
updateEmailSetting: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
notificationType: z.string(),
|
||||
sendEmail: z.boolean(),
|
||||
emailSubject: z.string().optional(),
|
||||
emailTemplate: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { notificationType, sendEmail, emailSubject, emailTemplate } = input
|
||||
|
||||
return ctx.prisma.notificationEmailSetting.upsert({
|
||||
where: { notificationType },
|
||||
update: {
|
||||
sendEmail,
|
||||
emailSubject,
|
||||
emailTemplate,
|
||||
updatedById: ctx.user.id,
|
||||
},
|
||||
create: {
|
||||
notificationType,
|
||||
category: 'custom',
|
||||
label: notificationType,
|
||||
sendEmail,
|
||||
emailSubject,
|
||||
emailTemplate,
|
||||
updatedById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete expired notifications (admin cleanup)
|
||||
*/
|
||||
deleteExpired: adminProcedure.mutation(async () => {
|
||||
const count = await deleteExpiredNotifications()
|
||||
return { deletedCount: count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete old read notifications (admin cleanup)
|
||||
*/
|
||||
deleteOld: adminProcedure
|
||||
.input(z.object({ olderThanDays: z.number().int().min(1).max(365).default(30) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const count = await deleteOldNotifications(input.olderThanDays)
|
||||
return { deletedCount: count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get notification icon and priority mappings (for UI)
|
||||
*/
|
||||
getMappings: protectedProcedure.query(() => {
|
||||
return {
|
||||
icons: NotificationIcons,
|
||||
priorities: NotificationPriorities,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: Get notification statistics
|
||||
*/
|
||||
getStats: adminProcedure.query(async ({ ctx }) => {
|
||||
const [total, unread, byType, byPriority] = await Promise.all([
|
||||
ctx.prisma.inAppNotification.count(),
|
||||
ctx.prisma.inAppNotification.count({ where: { isRead: false } }),
|
||||
ctx.prisma.inAppNotification.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
orderBy: { _count: { type: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
ctx.prisma.inAppNotification.groupBy({
|
||||
by: ['priority'],
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
total,
|
||||
unread,
|
||||
readRate: total > 0 ? ((total - unread) / total) * 100 : 0,
|
||||
byType: byType.map((t) => ({ type: t.type, count: t._count })),
|
||||
byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
/**
|
||||
* In-App Notification Service
|
||||
*
|
||||
* Creates and manages in-app notifications for users.
|
||||
* Optionally sends email notifications based on admin settings.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendNotificationEmail } from '@/lib/email'
|
||||
|
||||
// Notification priority levels
|
||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||
|
||||
// Notification type constants
|
||||
export const NotificationTypes = {
|
||||
// Admin notifications
|
||||
FILTERING_COMPLETE: 'FILTERING_COMPLETE',
|
||||
FILTERING_FAILED: 'FILTERING_FAILED',
|
||||
NEW_APPLICATION: 'NEW_APPLICATION',
|
||||
BULK_APPLICATIONS: 'BULK_APPLICATIONS',
|
||||
DOCUMENTS_UPLOADED: 'DOCUMENTS_UPLOADED',
|
||||
EVALUATION_MILESTONE: 'EVALUATION_MILESTONE',
|
||||
ALL_EVALUATIONS_DONE: 'ALL_EVALUATIONS_DONE',
|
||||
JURY_INACTIVE: 'JURY_INACTIVE',
|
||||
DEADLINE_24H: 'DEADLINE_24H',
|
||||
DEADLINE_1H: 'DEADLINE_1H',
|
||||
ROUND_AUTO_CLOSED: 'ROUND_AUTO_CLOSED',
|
||||
EXPORT_READY: 'EXPORT_READY',
|
||||
SYSTEM_ERROR: 'SYSTEM_ERROR',
|
||||
|
||||
// Jury notifications
|
||||
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
||||
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
|
||||
PROJECT_UPDATED: 'PROJECT_UPDATED',
|
||||
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
|
||||
REMINDER_3_DAYS: 'REMINDER_3_DAYS',
|
||||
REMINDER_24H: 'REMINDER_24H',
|
||||
REMINDER_1H: 'REMINDER_1H',
|
||||
ROUND_EXTENDED: 'ROUND_EXTENDED',
|
||||
ROUND_CLOSED: 'ROUND_CLOSED',
|
||||
THANK_YOU: 'THANK_YOU',
|
||||
RESULTS_AVAILABLE: 'RESULTS_AVAILABLE',
|
||||
|
||||
// Jury - Award specific
|
||||
AWARD_JURY_SELECTED: 'AWARD_JURY_SELECTED',
|
||||
AWARD_VOTING_OPEN: 'AWARD_VOTING_OPEN',
|
||||
AWARD_REMINDER: 'AWARD_REMINDER',
|
||||
AWARD_RESULTS: 'AWARD_RESULTS',
|
||||
|
||||
// Mentor notifications
|
||||
MENTEE_ASSIGNED: 'MENTEE_ASSIGNED',
|
||||
MENTEE_BATCH_ASSIGNED: 'MENTEE_BATCH_ASSIGNED',
|
||||
MENTEE_INTRO: 'MENTEE_INTRO',
|
||||
MENTEE_UPLOADED_DOCS: 'MENTEE_UPLOADED_DOCS',
|
||||
MENTEE_UPDATED_PROJECT: 'MENTEE_UPDATED_PROJECT',
|
||||
MENTEE_ADVANCED: 'MENTEE_ADVANCED',
|
||||
MENTEE_FINALIST: 'MENTEE_FINALIST',
|
||||
MENTEE_WON: 'MENTEE_WON',
|
||||
MENTEE_ELIMINATED: 'MENTEE_ELIMINATED',
|
||||
MENTORSHIP_TIP: 'MENTORSHIP_TIP',
|
||||
NEW_RESOURCE: 'NEW_RESOURCE',
|
||||
|
||||
// Team/Applicant notifications
|
||||
APPLICATION_SUBMITTED: 'APPLICATION_SUBMITTED',
|
||||
APPLICATION_INCOMPLETE: 'APPLICATION_INCOMPLETE',
|
||||
TEAM_INVITE_RECEIVED: 'TEAM_INVITE_RECEIVED',
|
||||
TEAM_MEMBER_JOINED: 'TEAM_MEMBER_JOINED',
|
||||
TEAM_MEMBER_LEFT: 'TEAM_MEMBER_LEFT',
|
||||
DOCUMENTS_RECEIVED: 'DOCUMENTS_RECEIVED',
|
||||
REVIEW_IN_PROGRESS: 'REVIEW_IN_PROGRESS',
|
||||
ADVANCED_SEMIFINAL: 'ADVANCED_SEMIFINAL',
|
||||
ADVANCED_FINAL: 'ADVANCED_FINAL',
|
||||
MENTOR_ASSIGNED: 'MENTOR_ASSIGNED',
|
||||
MENTOR_MESSAGE: 'MENTOR_MESSAGE',
|
||||
NOT_SELECTED: 'NOT_SELECTED',
|
||||
FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE',
|
||||
EVENT_INVITATION: 'EVENT_INVITATION',
|
||||
WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT',
|
||||
CERTIFICATE_READY: 'CERTIFICATE_READY',
|
||||
PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER',
|
||||
|
||||
// Observer notifications
|
||||
ROUND_STARTED: 'ROUND_STARTED',
|
||||
ROUND_PROGRESS: 'ROUND_PROGRESS',
|
||||
ROUND_COMPLETED: 'ROUND_COMPLETED',
|
||||
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
|
||||
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
|
||||
REPORT_AVAILABLE: 'REPORT_AVAILABLE',
|
||||
} as const
|
||||
|
||||
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
||||
|
||||
// Notification icons by type
|
||||
export const NotificationIcons: Record<string, string> = {
|
||||
[NotificationTypes.FILTERING_COMPLETE]: 'Brain',
|
||||
[NotificationTypes.FILTERING_FAILED]: 'AlertTriangle',
|
||||
[NotificationTypes.NEW_APPLICATION]: 'FileText',
|
||||
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
|
||||
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
|
||||
[NotificationTypes.REMINDER_24H]: 'Clock',
|
||||
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
|
||||
[NotificationTypes.ROUND_CLOSED]: 'Lock',
|
||||
[NotificationTypes.MENTEE_ASSIGNED]: 'Users',
|
||||
[NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp',
|
||||
[NotificationTypes.MENTEE_WON]: 'Trophy',
|
||||
[NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle',
|
||||
[NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp',
|
||||
[NotificationTypes.ADVANCED_FINAL]: 'Star',
|
||||
[NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap',
|
||||
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'Trophy',
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'Vote',
|
||||
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
|
||||
}
|
||||
|
||||
// Priority by notification type
|
||||
export const NotificationPriorities: Record<string, NotificationPriority> = {
|
||||
[NotificationTypes.FILTERING_COMPLETE]: 'high',
|
||||
[NotificationTypes.FILTERING_FAILED]: 'urgent',
|
||||
[NotificationTypes.DEADLINE_1H]: 'urgent',
|
||||
[NotificationTypes.REMINDER_1H]: 'urgent',
|
||||
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
|
||||
[NotificationTypes.DEADLINE_24H]: 'high',
|
||||
[NotificationTypes.REMINDER_24H]: 'high',
|
||||
[NotificationTypes.MENTEE_ASSIGNED]: 'high',
|
||||
[NotificationTypes.APPLICATION_SUBMITTED]: 'high',
|
||||
[NotificationTypes.ADVANCED_SEMIFINAL]: 'high',
|
||||
[NotificationTypes.ADVANCED_FINAL]: 'high',
|
||||
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'high',
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
|
||||
}
|
||||
|
||||
interface CreateNotificationParams {
|
||||
userId: string
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
linkUrl?: string
|
||||
linkLabel?: string
|
||||
icon?: string
|
||||
priority?: NotificationPriority
|
||||
metadata?: Record<string, unknown>
|
||||
groupKey?: string
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single in-app notification
|
||||
*/
|
||||
export async function createNotification(
|
||||
params: CreateNotificationParams
|
||||
): Promise<void> {
|
||||
const {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon,
|
||||
priority,
|
||||
metadata,
|
||||
groupKey,
|
||||
expiresAt,
|
||||
} = params
|
||||
|
||||
// Determine icon and priority if not provided
|
||||
const finalIcon = icon || NotificationIcons[type] || 'Bell'
|
||||
const finalPriority = priority || NotificationPriorities[type] || 'normal'
|
||||
|
||||
// Check for existing notification with same groupKey (for batching)
|
||||
if (groupKey) {
|
||||
const existingNotification = await prisma.inAppNotification.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
groupKey,
|
||||
isRead: false,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 60 * 60 * 1000), // Within last hour
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingNotification) {
|
||||
// Update existing notification instead of creating new one
|
||||
const existingMeta = existingNotification.metadata as Record<string, unknown> || {}
|
||||
const currentCount = (existingMeta.count as number) || 1
|
||||
await prisma.inAppNotification.update({
|
||||
where: { id: existingNotification.id },
|
||||
data: {
|
||||
message,
|
||||
metadata: { ...existingMeta, ...metadata, count: currentCount + 1 },
|
||||
createdAt: new Date(), // Bump to top
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create the in-app notification
|
||||
await prisma.inAppNotification.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon: finalIcon,
|
||||
priority: finalPriority,
|
||||
metadata: metadata as object | undefined,
|
||||
groupKey,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if we should also send an email
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for multiple users
|
||||
*/
|
||||
export async function createBulkNotifications(params: {
|
||||
userIds: string[]
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
linkUrl?: string
|
||||
linkLabel?: string
|
||||
icon?: string
|
||||
priority?: NotificationPriority
|
||||
metadata?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
userIds,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon,
|
||||
priority,
|
||||
metadata,
|
||||
} = params
|
||||
|
||||
const finalIcon = icon || NotificationIcons[type] || 'Bell'
|
||||
const finalPriority = priority || NotificationPriorities[type] || 'normal'
|
||||
|
||||
// Create notifications in bulk
|
||||
await prisma.inAppNotification.createMany({
|
||||
data: userIds.map((userId) => ({
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon: finalIcon,
|
||||
priority: finalPriority,
|
||||
metadata: metadata as object | undefined,
|
||||
})),
|
||||
})
|
||||
|
||||
// Check email settings and send emails
|
||||
for (const userId of userIds) {
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all admin users
|
||||
*/
|
||||
export async function notifyAdmins(params: {
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
linkUrl?: string
|
||||
linkLabel?: string
|
||||
icon?: string
|
||||
priority?: NotificationPriority
|
||||
metadata?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const admins = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (admins.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds: admins.map((a) => a.id),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all jury members for a specific round
|
||||
*/
|
||||
export async function notifyRoundJury(
|
||||
roundId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
|
||||
if (assignments.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds: assignments.map((a) => a.userId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify team members of a project
|
||||
*/
|
||||
export async function notifyProjectTeam(
|
||||
projectId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { projectId },
|
||||
include: { user: { select: { id: true } } },
|
||||
})
|
||||
|
||||
const userIds = teamMembers
|
||||
.filter((tm) => tm.user)
|
||||
.map((tm) => tm.user!.id)
|
||||
|
||||
if (userIds.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify assigned mentors of a project
|
||||
*/
|
||||
export async function notifyProjectMentors(
|
||||
projectId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const mentorAssignments = await prisma.mentorAssignment.findMany({
|
||||
where: { projectId },
|
||||
select: { mentorId: true },
|
||||
})
|
||||
|
||||
if (mentorAssignments.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds: mentorAssignments.map((ma) => ma.mentorId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check email settings and send email if enabled
|
||||
*/
|
||||
async function maybeSendEmail(
|
||||
userId: string,
|
||||
type: string,
|
||||
title: string,
|
||||
message: string,
|
||||
linkUrl?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if email is enabled for this notification type
|
||||
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
||||
where: { notificationType: type },
|
||||
})
|
||||
|
||||
// If no setting exists, don't send email by default
|
||||
if (!emailSetting || !emailSetting.sendEmail) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check user's notification preference
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true, notificationPreference: true },
|
||||
})
|
||||
|
||||
if (!user || user.notificationPreference === 'NONE') {
|
||||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
const subject = emailSetting.emailSubject || title
|
||||
const body = emailSetting.emailTemplate
|
||||
? emailSetting.emailTemplate
|
||||
.replace('{title}', title)
|
||||
.replace('{message}', message)
|
||||
.replace('{link}', linkUrl || '')
|
||||
: message
|
||||
|
||||
await sendNotificationEmail(user.email, user.name || 'User', subject, body, linkUrl)
|
||||
} catch (error) {
|
||||
// Log but don't fail the notification creation
|
||||
console.error('[Notification] Failed to send email:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read
|
||||
*/
|
||||
export async function markNotificationAsRead(
|
||||
notificationId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await prisma.inAppNotification.updateMany({
|
||||
where: { id: notificationId, userId },
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
|
||||
await prisma.inAppNotification.updateMany({
|
||||
where: { userId, isRead: false },
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a user
|
||||
*/
|
||||
export async function getUnreadCount(userId: string): Promise<number> {
|
||||
return prisma.inAppNotification.count({
|
||||
where: { userId, isRead: false },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired notifications
|
||||
*/
|
||||
export async function deleteExpiredNotifications(): Promise<number> {
|
||||
const result = await prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
})
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old read notifications (cleanup job)
|
||||
*/
|
||||
export async function deleteOldNotifications(olderThanDays: number): Promise<number> {
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays)
|
||||
|
||||
const result = await prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
isRead: true,
|
||||
createdAt: { lt: cutoffDate },
|
||||
},
|
||||
})
|
||||
return result.count
|
||||
}
|
||||
Loading…
Reference in New Issue