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 + ``
- })
- .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 + ``
+ })
+ .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 + ``
+ })
+ .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)
}