Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 143 additions & 19 deletions cron/cronJobMailer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html>
<body>
Expand All @@ -20,11 +24,15 @@ function createDailyNotification(email, notificationContent) {
<h3>STAPLE Daily Notifications</h3>

<p>
This email is to notify you about recent updates to your project(s). You can view all notifications on the <a href="https://app.staple.science/auth/login?next=%2Fnotifications">Notifications page</a> (you may be asked to log in).
Here are new announcements, tasks, and other project updates:
</p>
This email is to notify you about overdue tasks and recent updates to your project(s).
You can view all notifications on the <a href="https://app.staple.science/auth/login?next=%2Fnotifications">Notifications page</a>.
</p>

<h3>⏰ Overdue Tasks</h3>
<div style="margin:0 0 16px;">${overdueContent}</div>

${notificationContent}
<h3>📢 Project Updates</h3>
<div style="margin:0 0 16px;">${notificationContent}</div>
</body>
</html>
`
Expand Down Expand Up @@ -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))

Expand All @@ -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 = `<h4>Project: ${projectName}</h4>`
const messagesList = messages.map((message) => `<li>${message}</li>`).join("")
return projectHeader + `<ul>${messagesList}</ul>`
})
.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 = `<h4>Project: ${projectName}</h4>`
const messagesList = messages.map((message) => `<li>${message}</li>`).join("")
return projectHeader + `<ul>${messagesList}</ul>`
})
.join("") || "<p>No new updates in the last 24 hours.</p>"

// Build overdue content for this recipient (if any)
const overdueProjects = groupedOverdues?.[email] || {}
const overdueContent =
Object.entries(overdueProjects)
.map(([projectName, rows]) => {
const projectHeader = `<h4>Project: ${projectName}</h4>`
const items = rows.map((row) => `<li>${row}</li>`).join("")
return projectHeader + `<ul>${items}</ul>`
})
.join("") || "<p>No overdue tasks 🎉</p>"

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()
Expand All @@ -130,10 +241,18 @@ export async function sendGroupedNotifications(groupedNotifications) {
body: JSON.stringify(emailContent),
})

const respText = await response.text().catch(() => "<no body>")
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
Expand All @@ -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)
}
Expand Down
23 changes: 14 additions & 9 deletions src/contributors/components/ContributorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<S extends z.ZodType<any, any>> extends FormProps<S> {
projectId: number
Expand Down Expand Up @@ -99,8 +98,14 @@ export function ContributorForm<S extends z.ZodType<any, any>>(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
}
}
}}
>
Expand All @@ -116,12 +121,12 @@ export function ContributorForm<S extends z.ZodType<any, any>>(props: Contributo
opacity={1}
/>
{!isEdit && (
<LabeledTextField
<LabeledTextAreaField
name="email"
label="Email:"
placeholder="Email"
type="text"
className="input mb-4 w-1/2 text-primary input-primary input-bordered border-2 bg-base-300"
label="Email(s):"
placeholder="Enter one or multiple emails (comma, semicolon, space, or newline separated)"
rows={4}
className="textarea textarea-primary textarea-bordered border-2 bg-base-300 text-primary mb-4 w-1/2"
/>
)}
<LabelSelectField
Expand Down
39 changes: 29 additions & 10 deletions src/invites/hooks/useInviteContributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,26 @@ export function useInviteContributor(projectId: number) {
const router = useRouter()
const currentUser = useCurrentUser()

const handleEmailSending = async (emailData, successMessage, errorMessage) => {
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,
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/pages/projects/[projectId]/contributors/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const ContributorsPage = () => {
className="btn btn-primary mb-2 mt-4"
href={Routes.NewContributorPage({ projectId: projectId! })}
>
Invite Contributor
Invite Contributor(s)
</Link>
<Link
className="btn btn-secondary mx-2 mb-2 mt-4"
Expand Down
2 changes: 1 addition & 1 deletion src/pages/projects/[projectId]/contributors/invites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const InvitesPagePM = () => {
className="btn btn-primary mb-4 mt-4"
href={Routes.NewContributorPage({ projectId: projectId! })}
>
Invite Contributor
Invite Contributor(s)
</Link>

<Link
Expand Down
Loading