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/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" 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.
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 = `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 = `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 = `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/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) 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 = ` + + +
+ + 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. +
+ +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 = `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 you enter your first and last name, it will replace your username in project areas. 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/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), }) 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 @@
- 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.