From 4ecf111806d00689fb7e492e53d634cb78f7ddc2 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Tue, 21 Oct 2025 18:06:17 -0500 Subject: [PATCH 1/7] trying to see why builds are failing --- .gitignore | 9 +++++---- src/summary/utils/viewerWorker.js | 29 +++++++++++++++++++++++++++-- viewer-builds/.gitkeep | 0 3 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 viewer-builds/.gitkeep diff --git a/.gitignore b/.gitignore index 9ba436cb..26da9023 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,8 @@ STAPLE.Rproj db/migrations/local/ db/migrations/ tmp/* -summary-viewer/docs/.nojekyll -summary-viewer/docs/* -viewer-builds/* -summary-viewer/src/data/project_summary.json + +# ignore builds but keep the folder for folks +viewer-builds/*.zip +viewer-builds/Proj* +viewer-builds/viewer* diff --git a/src/summary/utils/viewerWorker.js b/src/summary/utils/viewerWorker.js index f9e3b265..58906345 100644 --- a/src/summary/utils/viewerWorker.js +++ b/src/summary/utils/viewerWorker.js @@ -5,6 +5,7 @@ const fs = require("fs") const path = require("path") const { execSync } = require("child_process") const archiver = require("archiver") +const { once } = require("events") const viewerAppPath = path.resolve("summary-viewer") const buildOutputDir = path.join(process.cwd(), "viewer-builds") @@ -15,6 +16,7 @@ new QueueWorker( async (job) => { const { jobId, data } = job.data const jobFolder = path.join(buildOutputDir, `Project_Summary_${jobId}`) + console.log("[viewer-worker] start", { jobId, viewerAppPath, buildOutputDir, jobFolder }) // Ensure the buildOutputDir exists before creating the ZIP file fs.mkdirSync(buildOutputDir, { recursive: true }) // Write project_summary.json @@ -49,9 +51,32 @@ new QueueWorker( const output = fs.createWriteStream(zipPath) const archive = archiver("zip", { zlib: { level: 9 } }) + // Error handlers to make failures visible to BullMQ + archive.on("error", (err) => { + console.error("[viewer-worker] archive error", err) + throw err + }) + output.on("error", (err) => { + console.error("[viewer-worker] output stream error", err) + throw err + }) + archive.pipe(output) - archive.directory(jobFolder, false) - await archive.finalize() + // Trailing slash ensures we add the *contents* of the folder + archive.directory(jobFolder + "/", false) + + // Finalize and wait for OS to close the file descriptor + const finalizePromise = archive.finalize() + await once(output, "close") + await finalizePromise + + // Sanity log with resulting size + try { + const { size } = fs.statSync(zipPath) + console.log("[viewer-worker] zip done", { jobId, zipPath, size }) + } catch (e) { + console.warn("[viewer-worker] zip stat failed", { jobId, zipPath, error: e?.message }) + } }, { connection: viewerQueue.opts.connection, diff --git a/viewer-builds/.gitkeep b/viewer-builds/.gitkeep new file mode 100644 index 00000000..e69de29b From 35afa12a3413cf2a217728d4fa6022a57d769a84 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Tue, 21 Oct 2025 18:15:59 -0500 Subject: [PATCH 2/7] Rename tasks.html to Tasks.html this damned thing --- summary-viewer/{tasks.html => Tasks.html} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename summary-viewer/{tasks.html => Tasks.html} (100%) diff --git a/summary-viewer/tasks.html b/summary-viewer/Tasks.html similarity index 100% rename from summary-viewer/tasks.html rename to summary-viewer/Tasks.html From e3e0236f9762767253462a7a39ab0e3d605e0678 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Wed, 5 Nov 2025 09:15:55 -0600 Subject: [PATCH 3/7] fixing form data text --- summary-viewer/Form_Data.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/summary-viewer/Form_Data.html b/summary-viewer/Form_Data.html index 25098e85..152df4e4 100644 --- a/summary-viewer/Form_Data.html +++ b/summary-viewer/Form_Data.html @@ -327,9 +327,9 @@

Form Metadata Information

Form Data

- This page displays statistics and information about tasks assigned in the project. Tasks - completed indicate the number of tasks marked as completed by the project manager while - the task logs completed indicates the number of member marked completed tasks. + This page shows the metadata collected via forms within tasks. Use the dropdown to + select a task and view the latest submitted form for each assignee, with all form fields + displayed as columns. You can search across columns or export the table to CSV or Excel.

From ed20850659a00d04f0cd5aaa13d2028e2b3bf70f Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 14 Nov 2025 10:39:11 -0600 Subject: [PATCH 4/7] update schema and database to allow users to select notification frequency --- db/schema.prisma | 56 ++++++++++-------- src/pages/profile/index.tsx | 78 ++++++++++++++++---------- src/profile/components/EditProfile.tsx | 2 + src/profile/components/ProfileForm.tsx | 29 ++++++++++ src/users/mutations/updateUser.ts | 15 ++++- src/users/queries/getCurrentUser.ts | 4 ++ src/users/schemas.ts | 5 ++ 7 files changed, 134 insertions(+), 55 deletions(-) diff --git a/db/schema.prisma b/db/schema.prisma index f0c9c6f7..a9133375 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -13,33 +13,35 @@ generator client { // -------------------------------------- model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - hashedPassword String? - role String @default("USER") - tos Boolean? - tokens Token[] - sessions Session[] + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + hashedPassword String? + role String @default("USER") + tos Boolean? + tokens Token[] + sessions Session[] // notifications and dashboards - notifications Notification[] @relation("UserNotifications") - widgets Widget[] - projectWidget ProjectWidget[] + notifications Notification[] @relation("UserNotifications") + widgets Widget[] + projectWidget ProjectWidget[] + emailProjectActivityFrequency EmailFrequency @default(DAILY) + emailOverdueTaskFrequency EmailFrequency @default(DAILY) // profile information - orcid String? - institution String? - username String @unique - firstName String? - lastName String? - email String @unique - language String @default("en-US") - gravatar String? - tooltips Boolean @default(true) + orcid String? + institution String? + username String @unique + firstName String? + lastName String? + email String @unique + language String @default("en-US") + gravatar String? + tooltips Boolean @default(true) // relational information - projects ProjectMember[] - forms Form[] - roles Role[] - ProjectPrivilege ProjectPrivilege[] + projects ProjectMember[] + forms Form[] + roles Role[] + ProjectPrivilege ProjectPrivilege[] } model Session { @@ -319,6 +321,12 @@ enum WidgetSize { LARGE } +enum EmailFrequency { + NEVER + DAILY + WEEKLY +} + model Widget { id Int @id @default(autoincrement()) userId Int diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index f32c03f6..cd7d732d 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -21,36 +21,54 @@ const ProfilePage = () => {
Profile Information
- Username: {currentUser.username} -
- Email: {currentUser.email} -
- Name:{" "} - {currentUser.firstName && currentUser.lastName ? ( - fullName - ) : ( - - No name is provided. Use the Edit Profile button to add your name. - - )} -
- Institution:{" "} - {currentUser.institution ? ( - currentUser.institution - ) : ( - - No institution is provided. Use the Edit Profile button to add your information. - - )} -
- Signup Date:{" "} - - Tooltips: - {currentUser.tooltips ? ( - On - ) : ( - Off - )} +
+ Username: {currentUser.username} +
+
+ Email: {currentUser.email} +
+
+ Name:{" "} + {currentUser.firstName && currentUser.lastName ? ( + fullName + ) : ( + No name provided + )} +
+
+ Institution:{" "} + {currentUser.institution ? ( + currentUser.institution + ) : ( + No institution provided + )} +
+
+ Signup Date:{" "} + +
+
+ Tooltips:{" "} + {currentUser.tooltips ? ( + On + ) : ( + Off + )} +
+
+ Language:{" "} + {currentUser.language || No language selected} +
+
+ Project Activity Emails:{" "} + {currentUser.emailProjectActivityFrequency.charAt(0) + + currentUser.emailProjectActivityFrequency.slice(1).toLowerCase()} +
+
+ Overdue Task Emails:{" "} + {currentUser.emailOverdueTaskFrequency.charAt(0) + + currentUser.emailOverdueTaskFrequency.slice(1).toLowerCase()} +
Edit Profile diff --git a/src/profile/components/EditProfile.tsx b/src/profile/components/EditProfile.tsx index 77c07337..19446396 100644 --- a/src/profile/components/EditProfile.tsx +++ b/src/profile/components/EditProfile.tsx @@ -35,6 +35,8 @@ export const EditProfile = () => { language: initialLanguageOption["id"] as string, gravatar: user!.gravatar, tooltips: user!.tooltips, + emailProjectActivityFrequency: user!.emailProjectActivityFrequency, + emailOverdueTaskFrequency: user!.emailOverdueTaskFrequency, } return ( diff --git a/src/profile/components/ProfileForm.tsx b/src/profile/components/ProfileForm.tsx index 949c080f..608e9f96 100644 --- a/src/profile/components/ProfileForm.tsx +++ b/src/profile/components/ProfileForm.tsx @@ -33,6 +33,35 @@ export function ProfileForm>(props: FormProps) /> + + + + +

If you enter your first and last name, it will replace your username in project areas. diff --git a/src/users/mutations/updateUser.ts b/src/users/mutations/updateUser.ts index 6fb95a98..9ce090a1 100644 --- a/src/users/mutations/updateUser.ts +++ b/src/users/mutations/updateUser.ts @@ -7,7 +7,18 @@ export default resolver.pipe( resolver.zod(UpdateUserSchema), resolver.authorize(), async ( - { email, firstName, lastName, institution, username, language, gravatar, tooltips }, + { + email, + firstName, + lastName, + institution, + username, + language, + gravatar, + tooltips, + emailProjectActivityFrequency, + emailOverdueTaskFrequency, + }, ctx: Ctx ) => { const user = await db.user.findFirst({ where: { id: ctx.session.userId! } }) @@ -25,6 +36,8 @@ export default resolver.pipe( language: language, gravatar: gravatar, tooltips: tooltips, + emailProjectActivityFrequency: emailProjectActivityFrequency, + emailOverdueTaskFrequency: emailOverdueTaskFrequency, }, }) diff --git a/src/users/queries/getCurrentUser.ts b/src/users/queries/getCurrentUser.ts index d7ea178e..86859e2c 100644 --- a/src/users/queries/getCurrentUser.ts +++ b/src/users/queries/getCurrentUser.ts @@ -14,6 +14,8 @@ export type CurrentUser = Prisma.UserGetPayload<{ language: true gravatar: true tooltips: true + emailProjectActivityFrequency: true + emailOverdueTaskFrequency: true } }> @@ -37,6 +39,8 @@ export default async function getCurrentUser( language: true, gravatar: true, tooltips: true, + emailProjectActivityFrequency: true, + emailOverdueTaskFrequency: true, }, }) diff --git a/src/users/schemas.ts b/src/users/schemas.ts index fb92e859..102b7679 100644 --- a/src/users/schemas.ts +++ b/src/users/schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { EmailFrequency } from "db" export const email = z .string() @@ -18,6 +19,8 @@ export const FormProfileSchema = z.object({ .transform((str) => str.toLowerCase().trim()) .nullable(), tooltips: z.boolean(), + emailProjectActivityFrequency: z.nativeEnum(EmailFrequency), + emailOverdueTaskFrequency: z.nativeEnum(EmailFrequency), }) export const UpdateUserSchema = z.object({ @@ -33,4 +36,6 @@ export const UpdateUserSchema = z.object({ .transform((str) => str.toLowerCase().trim()) .nullable(), tooltips: z.boolean(), + emailProjectActivityFrequency: z.nativeEnum(EmailFrequency), + emailOverdueTaskFrequency: z.nativeEnum(EmailFrequency), }) From bb900936abfe8f3536d170202503a74791b08064 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 14 Nov 2025 11:19:36 -0600 Subject: [PATCH 5/7] fixing the mailers to respect preferences --- cron/cronJobDaily.sh | 2 +- ...onJobMailer.mjs => cronJobDailyMailer.mjs} | 97 +++-- cron/cronJobWeekly.sh | 6 + cron/cronJobWeeklyMailer.mjs | 333 ++++++++++++++++++ 4 files changed, 414 insertions(+), 24 deletions(-) rename cron/{cronJobMailer.mjs => cronJobDailyMailer.mjs} (71%) create mode 100644 cron/cronJobWeekly.sh create mode 100644 cron/cronJobWeeklyMailer.mjs diff --git a/cron/cronJobDaily.sh b/cron/cronJobDaily.sh index f2b8bc3b..bef27620 100644 --- a/cron/cronJobDaily.sh +++ b/cron/cronJobDaily.sh @@ -3,4 +3,4 @@ SCRIPT_DIR="/var/www/html/STAPLE/cron" # Change to the script's directory cd "$SCRIPT_DIR" || exit # Run the Node.js scriptnode cronJob.mjs -node cronJobMailer.mjs +node cronJobDailyMailer.mjs diff --git a/cron/cronJobMailer.mjs b/cron/cronJobDailyMailer.mjs similarity index 71% rename from cron/cronJobMailer.mjs rename to cron/cronJobDailyMailer.mjs index c46cae4d..5df4bc42 100644 --- a/cron/cronJobMailer.mjs +++ b/cron/cronJobDailyMailer.mjs @@ -1,7 +1,7 @@ import dotenv from "dotenv" dotenv.config({ path: "../.env.local" }) import moment from "moment" -import { PrismaClient } from "@prisma/client" +import { PrismaClient, EmailFrequency } from "@prisma/client" import fetch from "node-fetch" import { resolver } from "@blitzjs/rpc" @@ -26,6 +26,7 @@ function createDailyNotification(email, notificationContent, overdueContent) {

This email is to notify you about overdue tasks and recent updates to your project(s). You can view all notifications on the Notifications page. + You change the frequency of these emails on your Profile page.

⏰ Overdue Tasks

@@ -191,9 +192,9 @@ const checkRateLimit = async () => { } } } -// Function to send grouped notifications +// Function to send grouped notifications (daily), respecting user email preferences export async function sendGroupedNotifications(groupedNotifications, groupedOverdues) { - const delayTime = 500 // Delay time between each email in milliseconds (e.g., 1 second) + const delayTime = 500 // Delay time between each email in milliseconds const allEmails = new Set([ ...Object.keys(groupedNotifications || {}), @@ -201,33 +202,83 @@ export async function sendGroupedNotifications(groupedNotifications, groupedOver ]) for (const email of allEmails) { - const projects = groupedNotifications?.[email] || {} + // Look up the user to read their email frequency preferences + const user = await db.user.findUnique({ + where: { email }, + select: { + id: true, + emailProjectActivityFrequency: true, + emailOverdueTaskFrequency: true, + }, + }) + + if (!user) { + console.log(`[Mailer] No user found for email ${email}, skipping.`) + continue + } + + // This is the DAILY mailer, so only honor DAILY preferences here + const wantsProjectDaily = user.emailProjectActivityFrequency === EmailFrequency.DAILY + const wantsOverdueDaily = user.emailOverdueTaskFrequency === EmailFrequency.DAILY - const notificationContent = - Object.entries(projects) - .map(([projectName, messages]) => { - const projectHeader = `

Project: ${projectName}

` - const messagesList = messages.map((message) => `
  • ${message}
  • `).join("") - return projectHeader + `
      ${messagesList}
    ` - }) - .join("") || "

    No new updates in the last 24 hours.

    " + // If the user doesn't want any daily emails, skip them entirely + if (!wantsProjectDaily && !wantsOverdueDaily) { + console.log(`[Mailer] User ${email} has no DAILY email prefs, skipping in daily job.`) + continue + } - // Build overdue content for this recipient (if any) + const projects = groupedNotifications?.[email] || {} const overdueProjects = groupedOverdues?.[email] || {} - const overdueContent = - Object.entries(overdueProjects) - .map(([projectName, rows]) => { - const projectHeader = `

    Project: ${projectName}

    ` - const items = rows.map((row) => `
  • ${row}
  • `).join("") - return projectHeader + `
      ${items}
    ` - }) - .join("") || "

    No overdue tasks 🎉

    " + + const hasProjectData = Object.keys(projects).length > 0 + const hasOverdueData = Object.keys(overdueProjects).length > 0 + + const willHaveProjectSection = wantsProjectDaily && hasProjectData + const willHaveOverdueSection = wantsOverdueDaily && hasOverdueData + + console.log( + `[Mailer][Daily] ${email} prefs: project=${user.emailProjectActivityFrequency}, overdue=${user.emailOverdueTaskFrequency}; ` + + `wantsProjectDaily=${wantsProjectDaily}, wantsOverdueDaily=${wantsOverdueDaily}; ` + + `hasProjectData=${hasProjectData}, hasOverdueData=${hasOverdueData}, ` + + `willHaveProjectSection=${willHaveProjectSection}, willHaveOverdueSection=${willHaveOverdueSection}` + ) + + // If there is nothing relevant to send for this cadence, skip + if (!willHaveProjectSection && !willHaveOverdueSection) { + console.log(`[Mailer] No relevant daily content for ${email} (prefs or data), skipping.`) + continue + } + + // Build project updates section + const notificationContent = !wantsProjectDaily + ? "

    You are not subscribed to daily project update emails.

    " + : hasProjectData + ? Object.entries(projects) + .map(([projectName, messages]) => { + const projectHeader = `

    Project: ${projectName}

    ` + const messagesList = messages.map((message) => `
  • ${message}
  • `).join("") + return projectHeader + `
      ${messagesList}
    ` + }) + .join("") + : "

    No new updates in the last 24 hours.

    " + + // Build overdue tasks section + const overdueContent = !wantsOverdueDaily + ? "

    You are not subscribed to daily overdue task emails.

    " + : hasOverdueData + ? Object.entries(overdueProjects) + .map(([projectName, rows]) => { + const projectHeader = `

    Project: ${projectName}

    ` + const items = rows.map((row) => `
  • ${row}
  • `).join("") + return projectHeader + `
      ${items}
    ` + }) + .join("") + : "

    No overdue tasks 🎉

    " const emailContent = createDailyNotification(email, notificationContent, overdueContent) console.log( - `[Mailer] Prepared email for ${email}: hasOverdues=${!!Object.keys(overdueProjects) - .length}, hasUpdates=${!!Object.keys(projects).length}` + `[Mailer] Prepared daily email for ${email}: hasOverdues=${willHaveOverdueSection}, hasUpdates=${willHaveProjectSection}` ) // Check rate limit before sending email diff --git a/cron/cronJobWeekly.sh b/cron/cronJobWeekly.sh new file mode 100644 index 00000000..c48a4b64 --- /dev/null +++ b/cron/cronJobWeekly.sh @@ -0,0 +1,6 @@ +# Absolute path to the directory where your cronJob.mjs is located +SCRIPT_DIR="/var/www/html/STAPLE/cron" +# Change to the script's directory +cd "$SCRIPT_DIR" || exit +# Run the Node.js scriptnode cronJob.mjs +node cronJobWeeklyMailer.mjs diff --git a/cron/cronJobWeeklyMailer.mjs b/cron/cronJobWeeklyMailer.mjs new file mode 100644 index 00000000..022040d6 --- /dev/null +++ b/cron/cronJobWeeklyMailer.mjs @@ -0,0 +1,333 @@ +import dotenv from "dotenv" +dotenv.config({ path: "../.env.local" }) +import moment from "moment" +import { PrismaClient, EmailFrequency } from "@prisma/client" +import fetch from "node-fetch" +import { resolver } from "@blitzjs/rpc" + +const db = new PrismaClient() // Create Prisma client instance + +function fmtDate(date) { + return moment(date).format("MMM D, YYYY") +} + +// Helper function to create email content +function createWeeklyNotification(email, notificationContent, overdueContent) { + const html_message = ` + + +
    + STAPLE Logo +
    + +

    STAPLE Weekly Notifications

    + +

    + This email is to notify you about overdue tasks and updates to your project(s) from the last week. + You can view all notifications on the Notifications page. + You change the frequency of these emails on your Profile page. +

    + +

    ⏰ Overdue Tasks

    +
    ${overdueContent}
    + +

    📢 Project Updates

    +
    ${notificationContent}
    + + + ` + + return { + from: "STAPLE ", + to: email, + subject: "STAPLE Weekly Notifications", + replyTo: "STAPLE Help ", + html: html_message, + } +} + +// Function to fetch notifications from the database +const getNotifications = resolver.pipe( + async ({ where, include }) => + await db.notification.findMany({ + where, // Pass `where` directly here + include, + orderBy: { createdAt: "desc" }, // Sort by creation date if needed + }) +) + +// Function to fetch and group notifications by email and project +export async function fetchAndGroupNotifications() { + const last7Days = moment().subtract(7, "days").toDate() + + const notifications = await getNotifications({ + where: { + createdAt: { + gte: last7Days, + }, + }, + include: { + recipients: { select: { email: true } }, + project: { select: { name: true } }, + }, + }) + + return notifications.reduce((acc, notification) => { + // Ensure recipients is defined as an array + const recipients = notification.recipients || [] + + recipients.forEach(({ email }) => { + if (!acc[email]) acc[email] = {} + const projectName = notification.project?.name || "No Project" + if (!acc[email][projectName]) acc[email][projectName] = [] + acc[email][projectName].push(notification.message) + }) + + return acc + }, {}) +} + +// Function to fetch and group overdue tasks by email and project +export async function fetchAndGroupOverdueTasks() { + const now = new Date() + + const tasks = await db.task.findMany({ + where: { + deadline: { lt: now }, + }, + include: { + project: { select: { name: true } }, + assignedMembers: { + include: { + users: { select: { email: true } }, + }, + }, + taskLogs: { + select: { + id: true, + createdAt: true, + assignedToId: true, + status: true, + completedById: true, + completedAs: true, + }, + orderBy: { createdAt: "desc" }, + }, + }, + orderBy: { deadline: "asc" }, + }) + + // A task counts as overdue for a member only if that member has a latest log and it is NOT_COMPLETED. + // If there is no log for that member, assume not assigned → do not include. + const isUnfinishedLatest = (log) => { + if (!log) return false + const s = (log.status || "").toString().toUpperCase() + return s === "NOT_COMPLETED" + } + + // Group as: email -> projectName -> [task rows] + return tasks.reduce((acc, task) => { + const projectName = task?.project?.name || "No Project" + const taskName = task?.name || `Task #${task?.id}` + const due = task?.deadline ? fmtDate(task.deadline) : "no due date" + const pastDeadline = task?.deadline && task.deadline < now + + // Map latest TaskLog by assignee (assignedToId) — schema note: TaskLog does not have projectmemberId + const latestByMember = new Map() + for (const log of task.taskLogs || []) { + if (!latestByMember.has(log.assignedToId)) { + latestByMember.set(log.assignedToId, log) + } + } + + const members = task?.assignedMembers || [] + if (members.length === 0) return acc + + for (const m of members) { + const latest = latestByMember.get(m.id) + const isUnfinished = isUnfinishedLatest(latest) + + if (pastDeadline && isUnfinished) { + const line = `${projectName} - ${taskName} - Due: ${due}` + const users = m?.users || [] + for (const u of users) { + const email = u?.email + if (!email) continue + if (!acc[email]) acc[email] = {} + if (!acc[email][projectName]) acc[email][projectName] = [] + acc[email][projectName].push(line) + } + + // TODO: If assignment is to a team, add logic here to notify team distribution list or members + } + } + + return acc + }, {}) +} + +// Function to introduce a delay (in milliseconds) +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const maxEmailsPerMinute = 100 // Max number of emails to send per minute +let emailCount = 0 // Keep track of the number of emails sent +let startTime = Date.now() // Track the time when email sending starts + +// Function to check if the rate limit is exceeded +const checkRateLimit = async () => { + // Check if we have sent maxEmailsPerMinute emails + if (emailCount >= maxEmailsPerMinute) { + const elapsedTime = Date.now() - startTime // Get time elapsed in milliseconds + const timeLeft = 60000 - elapsedTime // Calculate remaining time for the current minute + + if (timeLeft > 0) { + console.log(`Rate limit reached. Waiting for ${timeLeft / 1000}s...`) + // Wait for the remaining time before sending the next batch of emails + await delay(timeLeft) + } else { + // If a minute has passed, reset the counter and start time + emailCount = 0 + startTime = Date.now() + } + } +} +// Function to send grouped notifications (weekly), respecting user email preferences +export async function sendGroupedNotifications(groupedNotifications, groupedOverdues) { + const delayTime = 500 // Delay time between each email in milliseconds + + const allEmails = new Set([ + ...Object.keys(groupedNotifications || {}), + ...Object.keys(groupedOverdues || {}), + ]) + + for (const email of allEmails) { + // Look up the user to read their email frequency preferences + const user = await db.user.findUnique({ + where: { email }, + select: { + id: true, + emailProjectActivityFrequency: true, + emailOverdueTaskFrequency: true, + }, + }) + + if (!user) { + console.log(`[Mailer] No user found for email ${email}, skipping.`) + continue + } + + // This is the WEEKLY mailer, so only honor WEEKLY preferences here + const wantsProjectWeekly = user.emailProjectActivityFrequency === EmailFrequency.WEEKLY + const wantsOverdueWeekly = user.emailOverdueTaskFrequency === EmailFrequency.WEEKLY + + // If the user doesn't want any weekly emails, skip them entirely + if (!wantsProjectWeekly && !wantsOverdueWeekly) { + console.log(`[Mailer] User ${email} has no WEEKLY email prefs, skipping in weekly job.`) + continue + } + + const projects = groupedNotifications?.[email] || {} + const overdueProjects = groupedOverdues?.[email] || {} + + const hasProjectData = Object.keys(projects).length > 0 + const hasOverdueData = Object.keys(overdueProjects).length > 0 + + const willHaveProjectSection = wantsProjectWeekly && hasProjectData + const willHaveOverdueSection = wantsOverdueWeekly && hasOverdueData + + // If there is nothing relevant to send for this cadence, skip + if (!willHaveProjectSection && !willHaveOverdueSection) { + console.log(`[Mailer] No relevant weekly content for ${email} (prefs or data), skipping.`) + continue + } + + // Build project updates section + const notificationContent = willHaveProjectSection + ? Object.entries(projects) + .map(([projectName, messages]) => { + const projectHeader = `

    Project: ${projectName}

    ` + const messagesList = messages.map((message) => `
  • ${message}
  • `).join("") + return projectHeader + `
      ${messagesList}
    ` + }) + .join("") || "

    No new updates in the last week.

    " + : "

    You are not subscribed to weekly project update emails.

    " + + // Build overdue tasks section + const overdueContent = willHaveOverdueSection + ? Object.entries(overdueProjects) + .map(([projectName, rows]) => { + const projectHeader = `

    Project: ${projectName}

    ` + const items = rows.map((row) => `
  • ${row}
  • `).join("") + return projectHeader + `
      ${items}
    ` + }) + .join("") || "

    No overdue tasks 🎉

    " + : "

    You are not subscribed to weekly overdue task emails.

    " + + const emailContent = createWeeklyNotification(email, notificationContent, overdueContent) + + console.log( + `[Mailer] Prepared weekly email for ${email}: hasOverdues=${willHaveOverdueSection}, hasUpdates=${willHaveProjectSection}` + ) + + // Check rate limit before sending email + await checkRateLimit() + + // Send email + try { + const response = await fetch("https://app.staple.science/api/send-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(emailContent), + }) + + const respText = await response.text().catch(() => "") + if (!response.ok) { + console.error( + `Failed to send email to ${email}: ${response.status} ${response.statusText} — ${respText}` + ) + } else { + console.log( + `Email sent successfully to ${email}: ${response.status} — ${respText.substring( + 0, + 120 + )}...` + ) + } + + emailCount++ // Increment the email count after sending each email + + // Add delay between emails to avoid too many requests in a short time + await delay(delayTime) + } catch (error) { + console.error(`Error sending email to ${email}:`, error) + } + } + + console.log(`[Mailer] Processed ${allEmails.size} recipients.`) +} + +// Function to fetch and send weekly notifications +async function sendWeeklyNotifications() { + try { + const [groupedNotifications, groupedOverdues] = await Promise.all([ + fetchAndGroupNotifications(), + fetchAndGroupOverdueTasks(), + ]) + await sendGroupedNotifications(groupedNotifications, groupedOverdues) + } catch (error) { + console.error("Error in sendWeeklyNotifications:", error) + } +} + +// Run the weekly notifications job +sendWeeklyNotifications() + .then(() => { + console.log(`[${new Date().toISOString()}] Weekly notifications job completed successfully.`) + }) + .catch((error) => { + console.error(`[${new Date().toISOString()}] Error in weekly notifications job:`, error) + }) + .finally(async () => { + await db.$disconnect() // Disconnect from the database when done + }) From 16534bb66ca78d2577408f2a995ec22e8415cc3f Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 14 Nov 2025 11:29:11 -0600 Subject: [PATCH 6/7] looks like the folder path was incorrect --- cron/cronJobDeleteTmp.mjs | 6 +++++- cron/cronJobFolderSize.mjs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cron/cronJobDeleteTmp.mjs b/cron/cronJobDeleteTmp.mjs index f782b722..c21695b4 100644 --- a/cron/cronJobDeleteTmp.mjs +++ b/cron/cronJobDeleteTmp.mjs @@ -1,8 +1,12 @@ import fs from "fs" import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) function cleanUpViewerZips() { - const zipDir = path.join(process.cwd(), "viewer-builds") + const zipDir = path.join(__dirname, "..", "viewer-builds") if (!fs.existsSync(zipDir)) return const files = fs.readdirSync(zipDir) diff --git a/cron/cronJobFolderSize.mjs b/cron/cronJobFolderSize.mjs index 834be149..b2f10085 100644 --- a/cron/cronJobFolderSize.mjs +++ b/cron/cronJobFolderSize.mjs @@ -1,8 +1,12 @@ import fs from "fs" import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) function checkViewerBuildsSize() { - const zipDir = path.join(process.cwd(), "viewer-builds") + const zipDir = path.join(__dirname, "..", "viewer-builds") if (!fs.existsSync(zipDir)) return const files = fs.readdirSync(zipDir) From 58db8fbadbf5b6e2408f4f2c8a97ab12310b1500 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 14 Nov 2025 11:47:01 -0600 Subject: [PATCH 7/7] adding citation for automatic zenodo magic --- CITATION.cff | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..e7ae096c --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,41 @@ +cff-version: 1.2.0 +message: "If you use STAPLE in your research, please cite it using the following information." +title: "STAPLE: Science Tracking Across the Project Lifespan" +doi: "10.5281/zenodo.13916969" +authors: + - family-names: "Buchanan" + given-names: "Erin M." + orcid: "https://orcid.org/0000-0002-9689-4189" + - family-names: "Kovacs" + given-names: "Marton" + orcid: "https://orcid.org/0000-0002-8142-8492" + - family-names: "Yedra" + given-names: "Engerst" + orcid: "https://orcid.org/0000-0002-9555-7148" + # Add more authors here, e.g.: + # - family-names: "Doe" + # given-names: "Jane" + # orcid: "https://orcid.org/XXXX-XXXX-XXXX-XXXX" + +preferred-citation: + type: software + title: "STAPLE: Science Tracking Across the Project Lifespan" + authors: + - family-names: "Buchanan" + given-names: "Erin M." + orcid: "https://orcid.org/0000-0002-9689-4189" + - family-names: "Kovacs" + given-names: "Marton" + orcid: "https://orcid.org/0000-0002-8142-8492" + - family-names: "Yedra" + given-names: "Engerst" + orcid: "https://orcid.org/0000-0002-9555-7148" + url: "https://github.com/STAPLE/STAPLE" + +contributors: + - family-names: "Hartgerink" + given-names: "Chris" + orcid: "https://orcid.org/0000-0003-1050-6809" + - family-names: "Sunami" + given-names: "Nami" + orcid: "https://orcid.org/0000-0001-5482-8370"