From 87d2c3fe7ba95b5a044a8de2efe01836c2a52033 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 19 Oct 2025 13:21:08 -0500 Subject: [PATCH 1/4] you can invite multiple people now --- .../components/ContributorForm.tsx | 23 ++++--- src/invites/hooks/useInviteContributor.ts | 39 +++++++++--- .../[projectId]/contributors/index.tsx | 2 +- .../[projectId]/contributors/invites.tsx | 2 +- .../projects/[projectId]/contributors/new.tsx | 63 +++++++++++++++++-- 5 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/contributors/components/ContributorForm.tsx b/src/contributors/components/ContributorForm.tsx index d50959ba..0d51a9af 100644 --- a/src/contributors/components/ContributorForm.tsx +++ b/src/contributors/components/ContributorForm.tsx @@ -4,14 +4,13 @@ import { z } from "zod" import { LabelSelectField } from "src/core/components/fields/LabelSelectField" import { useQuery } from "@blitzjs/rpc" import { MemberPrivileges } from "@prisma/client" -import LabeledTextField from "src/core/components/fields/LabeledTextField" import AddRoleInput from "src/roles/components/AddRoleInput" import getProjectManagerUserIds from "src/projectmembers/queries/getProjectManagerUserIds" import TooltipWrapper from "src/core/components/TooltipWrapper" import { WithContext as ReactTags, SEPARATORS } from "react-tag-input" import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" -import Card from "src/core/components/Card" +import LabeledTextAreaField from "src/core/components/fields/LabeledTextAreaField" interface ContributorFormProps> extends FormProps { projectId: number @@ -99,8 +98,14 @@ export function ContributorForm>(props: Contributo ) }} onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() // Prevent form submission on Enter + if (e.key === "Enter" && !e.shiftKey) { + const el = e.target as HTMLElement + const tagName = (el.tagName || "").toLowerCase() + const inTextarea = tagName === "textarea" + const inReactTags = !!el.closest(".react-tags-wrapper") + if (!inTextarea && !inReactTags) { + e.preventDefault() // Prevent accidental form submit from text inputs/buttons + } } }} > @@ -116,12 +121,12 @@ export function ContributorForm>(props: Contributo opacity={1} /> {!isEdit && ( - )} { + const handleEmailSending = async (emailData, successMessage, errorMessage, silent = false) => { const emailSent = await sendInvitationEmail(emailData) + if (silent) { + return emailSent + } if (emailSent) { toast.success(successMessage) } else { console.error(errorMessage) toast.error(errorMessage) } + return emailSent } - const handleSubmit = async (values: any) => { + const handleSubmit = async ( + values: any, + options?: { silent?: boolean; skipRedirect?: boolean } + ) => { + const silent = !!options?.silent + const skipRedirect = !!options?.skipRedirect try { const projectMember = await createInviteMutation({ projectId: projectId, @@ -40,35 +49,45 @@ export function useInviteContributor(projectId: number) { switch (projectMember.code) { case "already_added": + if (silent) { + return { ok: false, reason: "already_added" } + } return { [FORM_ERROR]: "User is already a contributor on the project." } case "restore_possible": await handleEmailSending( createReassignmentInvitation(values, currentUser, projectMember.projectmember), "Reassignment invitation sent to the contributor!", - "Failed to send reassignment email" + "Failed to send reassignment email", + silent ) - break + return { ok: true, code: projectMember.code } case "invite_sent": await handleEmailSending( createNewInvitation(values, currentUser, projectMember.projectmember), "Contributor invited to the project!", - "Failed to send invitation email" + "Failed to send invitation email", + silent ) - break + return { ok: true, code: projectMember.code } default: - toast.error("Unexpected response code.") - break + if (!silent) toast.error("Unexpected response code.") + return { ok: false, code: projectMember.code } } - // Redirect to ContributorsPage after handling the invitation - await router.push(Routes.ContributorsPage({ projectId })) + if (!skipRedirect) { + await router.push(Routes.ContributorsPage({ projectId })) + } } catch (error: any) { console.error(error) + if (silent) { + return { ok: false, reason: error?.toString?.() ?? "unknown_error" } + } return { [FORM_ERROR]: error.toString() } } + return { ok: true } } return handleSubmit diff --git a/src/pages/projects/[projectId]/contributors/index.tsx b/src/pages/projects/[projectId]/contributors/index.tsx index 40c0f2c2..0fe51810 100644 --- a/src/pages/projects/[projectId]/contributors/index.tsx +++ b/src/pages/projects/[projectId]/contributors/index.tsx @@ -68,7 +68,7 @@ const ContributorsPage = () => { className="btn btn-primary mb-2 mt-4" href={Routes.NewContributorPage({ projectId: projectId! })} > - Invite Contributor + Invite Contributor(s) { className="btn btn-primary mb-4 mt-4" href={Routes.NewContributorPage({ projectId: projectId! })} > - Invite Contributor + Invite Contributor(s) { + const raw = (values?.email ?? "").toString() + const emails = raw + .split(/[\n,;\s]+/) + .map((e) => e.trim()) + .filter(Boolean) + + if (emails.length === 0) return + + const loadingToast = toast.loading( + `Inviting ${emails.length} contributor${emails.length === 1 ? "" : "s"}...` + ) + + const results: string[] = [] + const errors: string[] = [] + + // Run sequentially to preserve existing server validations and rate limits + for (const email of emails) { + try { + // eslint-disable-next-line no-await-in-loop + const res = await handleSubmit({ ...values, email }, { silent: true, skipRedirect: true }) + if (res?.ok) { + results.push(email) + } else { + errors.push(email) + } + } catch { + errors.push(email) + } + } + + const succeeded = results.length + const failed = errors.length + const message = + failed === 0 + ? `✅ Successfully invited ${succeeded} contributor${succeeded === 1 ? "" : "s"}.` + : `✅ Invited ${succeeded} contributor${ + succeeded === 1 ? "" : "s" + }, ❌ failed for ${failed}: ${errors.join(", ")}.` + + toast.dismiss(loadingToast) + toast.success(message) + + // After processing, take the user to the invites page where they can see statuses + await router.push(Routes.InvitesPagePM({ projectId: projectId! })) + } + return (

@@ -27,22 +76,24 @@ function NewContributor() { />

- Enter the email of the contributor you would like to add to the project. They will receive - an email inviting them to join the project. You will not be able to add them to tasks or - teams until they accept their invitation. + Enter the email(s) of the contributor(s) you want to add to the project. You can paste + multiple emails separated by commas, spaces, semicolons, or new lines. They will receive + invitations, and you can add them to tasks or teams after they accept. All contributors + entered together will have the same privilege, roles, and tags. You can add or change these + values after they accept your invite.

router.push(Routes.InvitesPagePM({ projectId: projectId! }))} From 05bf233288ce5ea03639c5638e09c19be4c29d0c Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 19 Oct 2025 13:25:50 -0500 Subject: [PATCH 2/4] add the s for clarity it's multiple people --- src/pages/projects/[projectId]/contributors/new.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/projects/[projectId]/contributors/new.tsx b/src/pages/projects/[projectId]/contributors/new.tsx index 3d47fbfa..c06c6f04 100644 --- a/src/pages/projects/[projectId]/contributors/new.tsx +++ b/src/pages/projects/[projectId]/contributors/new.tsx @@ -69,7 +69,7 @@ function NewContributor() { return (

- Invite New Contributor + Invite New Contributor(s) Date: Sun, 19 Oct 2025 13:26:43 -0500 Subject: [PATCH 3/4] give them tags not tasks --- src/pages/projects/[projectId]/contributors/new.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/projects/[projectId]/contributors/new.tsx b/src/pages/projects/[projectId]/contributors/new.tsx index c06c6f04..d0f1457a 100644 --- a/src/pages/projects/[projectId]/contributors/new.tsx +++ b/src/pages/projects/[projectId]/contributors/new.tsx @@ -76,7 +76,7 @@ function NewContributor() { />

From ffb46e3d722c074a287a935431e015260094fbe3 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sun, 19 Oct 2025 14:13:46 -0500 Subject: [PATCH 4/4] adding overdue tasks to daily email + clean up --- cron/cronJobMailer.mjs | 162 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 19 deletions(-) diff --git a/cron/cronJobMailer.mjs b/cron/cronJobMailer.mjs index b5e873b2..c46cae4d 100644 --- a/cron/cronJobMailer.mjs +++ b/cron/cronJobMailer.mjs @@ -7,8 +7,12 @@ 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 createDailyNotification(email, notificationContent) { +function createDailyNotification(email, notificationContent, overdueContent) { const html_message = ` @@ -20,11 +24,15 @@ function createDailyNotification(email, notificationContent) {

STAPLE Daily Notifications

- This email is to notify you about recent updates to your project(s). You can view all notifications on the Notifications page (you may be asked to log in). - Here are new announcements, tasks, and other project updates: -

+ 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. +

+ +

⏰ Overdue Tasks

+
${overdueContent}
- ${notificationContent} +

📢 Project Updates

+
${notificationContent}
` @@ -79,6 +87,85 @@ export async function fetchAndGroupNotifications() { }, {}) } +// 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)) @@ -105,19 +192,43 @@ const checkRateLimit = async () => { } } // Function to send grouped notifications -export async function sendGroupedNotifications(groupedNotifications) { +export async function sendGroupedNotifications(groupedNotifications, groupedOverdues) { const delayTime = 500 // Delay time between each email in milliseconds (e.g., 1 second) - for (const [email, projects] of Object.entries(groupedNotifications)) { - const notificationContent = Object.entries(projects) - .map(([projectName, messages]) => { - const projectHeader = `

Project: ${projectName}

` - const messagesList = messages.map((message) => `
  • ${message}
  • `).join("") - return projectHeader + `
      ${messagesList}
    ` - }) - .join("") + const allEmails = new Set([ + ...Object.keys(groupedNotifications || {}), + ...Object.keys(groupedOverdues || {}), + ]) + + for (const email of allEmails) { + const projects = groupedNotifications?.[email] || {} - const emailContent = createDailyNotification(email, notificationContent) + 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.

    " + + // Build overdue content for this recipient (if any) + 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 emailContent = createDailyNotification(email, notificationContent, overdueContent) + + console.log( + `[Mailer] Prepared email for ${email}: hasOverdues=${!!Object.keys(overdueProjects) + .length}, hasUpdates=${!!Object.keys(projects).length}` + ) // Check rate limit before sending email await checkRateLimit() @@ -130,10 +241,18 @@ export async function sendGroupedNotifications(groupedNotifications) { body: JSON.stringify(emailContent), }) + const respText = await response.text().catch(() => "") if (!response.ok) { - console.error(`Failed to send email to ${email}:`, response.statusText) + console.error( + `Failed to send email to ${email}: ${response.status} ${response.statusText} — ${respText}` + ) } else { - console.log(`Email sent successfully to ${email}`) + console.log( + `Email sent successfully to ${email}: ${response.status} — ${respText.substring( + 0, + 120 + )}...` + ) } emailCount++ // Increment the email count after sending each email @@ -144,13 +263,18 @@ export async function sendGroupedNotifications(groupedNotifications) { console.error(`Error sending email to ${email}:`, error) } } + + console.log(`[Mailer] Processed ${allEmails.size} recipients.`) } // Function to fetch and send daily notifications async function sendDailyNotifications() { try { - const groupedNotifications = await fetchAndGroupNotifications() - await sendGroupedNotifications(groupedNotifications) + const [groupedNotifications, groupedOverdues] = await Promise.all([ + fetchAndGroupNotifications(), + fetchAndGroupOverdueTasks(), + ]) + await sendGroupedNotifications(groupedNotifications, groupedOverdues) } catch (error) { console.error("Error in sendDailyNotifications:", error) }