diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index db7f65b0..c3f39802 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -58,3 +58,40 @@ flowchart LR C -->|Declined| F[Resubmit if requested] E -->|Accept| D[Merged dev] ``` + +## Developer Guidelines + +### Clear and Helpful Commit Messages + +When you make a commit (a save point in our project), it's important to describe what you've changed in a way that's easy to understand. Use simple, active sentences. For example, say "Update button styles" instead of something vague like "changed styling." This helps everyone see what's been done just by looking at the commit history. +Keep Your Branch Up-to-Date + +If you’re working on a feature in a separate branch, it’s a good idea to regularly pull in the latest changes from the main branch. This helps prevent conflicts later and keeps your work relevant. Also, if you’ve been working on a branch for a long time without pulling updates, let the team know why in your pull request. This way, everyone understands your reasoning. + +### Protecting the Main Branch + +We want to make sure only high-quality code makes it into our main branch, which is what goes into production. To do this, we’ll require all changes to go through a pull request (PR). This means your code needs to be reviewed by someone else, and automated checks will be run to catch any issues before they become bigger problems. + +### Keep Pull Requests Small and Focused + +When possible, try to keep your pull requests small and focused on one thing. This makes it easier for others to review your changes quickly and reduces the chance of missing issues. If a pull request is too big, it can take longer to review and might introduce more bugs. + +### Automated Testing + +Before you commit your code, it’s important to write tests to make sure everything works as expected. Automated tests help keep our project stable by catching issues early. You can use GitHub Actions to run these tests automatically, so you know everything is working before you merge your changes into the main branch. + +### Write and Keep Up Documentation + +Good documentation is key to helping everyone understand how our project works. A clear README file and other documents can save a lot of time. It’s helpful to document both public info (like how to set up the project on your computer) and private info (like how our servers are managed). This way, everyone can find the information they need. + +### Use GitHub Issues for Proposing Changes + +Before you start working on something new, it’s a good idea to open a GitHub issue to get feedback. This can help you refine your ideas and make sure you’re on the right track. It also helps avoid doing work that might need to be redone later. Getting early feedback can save time and make sure your efforts are aligned with the team. + +### Write Clear GitHub Issues + +When you create a GitHub issue, try to include all the information needed to understand the problem or task. If you don’t have all the details, ask questions and tag the right people. A well-written issue is half the work, making it easier for someone to jump in and help. + +### Communicate Clearly + +Good communication is key to working well together. Before you send a message or comment, think about how others will read it. Try to make your point clear and simple. If you’re in a hurry, it’s easy to be misunderstood, so take a moment to check if your message is clear and concise. This will help others understand you better and keep things moving smoothly. diff --git a/LICENSE.md b/LICENSE.md index 23a03bb1..fbc2f6d6 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -4,12 +4,12 @@ Copyright (c) 2025 STAPLE Development Team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, subject to the following conditions: 1. **Attribution** must be given to the original authors of the STAPLE software. -2. **Non-Commercial Use Only**: The Software may not be used, in whole or in part, for commercial purposes without prior written permission from the copyright holders. +2. **Non-Commercial Use Only**: The Software may not be used, in whole or in part, for commercial purposes without prior written permission from the copyright holders. - “Commercial purposes” include selling the Software, offering it as a service for a fee, or incorporating it into a commercial product or platform. 3. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. diff --git a/README.md b/README.md index 499e9dc1..3bbeece8 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,25 @@ ## STAPLE: Software for Scientists -Cite: [![DOI](https://zenodo.org/badge/665542257.svg)](https://doi.org/10.5281/zenodo.13916969) +*A research project management platform for open, transparent, and collaborative science.* -(use https://doi.org/10.5281/zenodo.13916969 for the concept version, or use the specific doi for a release if desired). +[![DOI](https://zenodo.org/badge/665542257.svg)](https://doi.org/10.5281/zenodo.13916969) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Build Status](https://github.com/STAPLE-verse/STAPLE/actions/workflows/dry-run.yml/badge.svg)](https://github.com/STAPLE-verse/STAPLE/actions/workflows/dry-run.yml) +[![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/STAPLE-verse/STAPLE?tab=contributing-ov-file) +[![Slack](https://img.shields.io/badge/chat-on%20Slack-purple.svg?logo=slack)]([https://your-slack-invite-link](https://join.slack.com/t/staple-talk/shared_invite/zt-25c08jrdt-f66do2kbIZExpAou5ZQYew)) -Docs: +### 📖 Documentation: -Contribute: +👉 [Full installation and usage guide](https://staple.science/documentation/) + +### 📦 Citation + +If you use STAPLE in your work, please cite it using the concept DOI or a release-specific DOI: + +- Concept DOI: https://doi.org/10.5281/zenodo.13916969 +- For specific releases, use the DOI associated with that release (badge above links to the latest). + +### 🤝 Contributing + +We welcome contributions of all kinds — code, documentation, testing, and feedback. +[See our contribution guidelines.](https://github.com/STAPLE-verse/STAPLE?tab=contributing-ov-file) diff --git a/cron/cronJobMailer.mjs b/cron/cronJobMailer.mjs index dad352eb..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. - 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) } diff --git a/db/migrations/migration_lock.toml b/db/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/db/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/db/schema.prisma b/db/schema.prisma index 0f7f75f5..f0c9c6f7 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -170,6 +170,13 @@ enum Status { NOT_COMPLETED } +enum AutoAssignNew { + NONE + CONTRIBUTOR + TEAM + ALL +} + model Task { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -200,6 +207,8 @@ model Task { assignedMembers ProjectMember[] @relation("AssignedTasks") elementId Int? element Element? @relation(fields: [elementId], references: [id]) + autoAssignNew AutoAssignNew @default(NONE) + anonymous Boolean @default(false) } model TaskLog { diff --git a/integrations/emails.tsx b/integrations/emails.tsx index c337b5bf..f3d8c002 100644 --- a/integrations/emails.tsx +++ b/integrations/emails.tsx @@ -236,7 +236,7 @@ alt="STAPLE Logo" height="200">

    STAPLE Password Change

    This email is to notify you that you recently updated your - password. If you did not make this change, please + password at https://app.staple.science. If you did not make this change, please contact us immediately.

    If you need more help, you can reply to this email to create a ticket. @@ -289,7 +289,7 @@ alt="STAPLE Logo" height="200">

    STAPLE Profile Change

    This email is to notify you that you recently updated your - profile information. If you did not make this change, please + profile information at https://app.staple.science. If you did not make this change, please contact us immediately.

    If you need more help, you can reply to this email to create a ticket. diff --git a/integrations/mailer.js b/integrations/mailer.js index 592f524c..752162ca 100644 --- a/integrations/mailer.js +++ b/integrations/mailer.js @@ -3,6 +3,7 @@ import nodemailer from "nodemailer" import * as aws from "@aws-sdk/client-ses" import { Resend } from "resend" +// use for Gmail export async function Mailer(msg) { const pass = process.env.EMAIL_PASS @@ -20,6 +21,7 @@ export async function Mailer(msg) { }) } +// use for Amazon export async function Amazon(msg) { const ses = new aws.SES({ apiVersion: "2010-12-01", @@ -42,6 +44,7 @@ export async function Amazon(msg) { } } +// use for Resend const resend = new Resend(process.env.RESEND_API_KEY) export async function ResendMsg(msg) { diff --git a/mailers/forgotPasswordMailer.ts b/mailers/forgotPasswordMailer.ts index 61a311c6..302d17fa 100644 --- a/mailers/forgotPasswordMailer.ts +++ b/mailers/forgotPasswordMailer.ts @@ -22,4 +22,6 @@ export async function forgotPasswordMailer({ to, token }: ResetPasswordMailer) { //send the email await ResendMsg(createForgotPasswordMsg(to, resetUrl)) + // await Amazon(createForgotPasswordMsg(to, resetUrl)) # or amazon + // await Mailer(createForgotPasswordMsg(to, resetUrl)) # or gmail } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 6e30ed5b..a9c64d9d 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -24,13 +24,75 @@ "forms": "Forms", "roles": "Roles", "admin": "Admin", - "help": "Help" + "help": "Help", + "tooltips": { + "dashboard": "Home dashboard", + "projects": "View all projects", + "invitations": "Project invitations", + "tasks": "View all tasks", + "notifications": "View all notifications", + "forms": "Manage metadata forms", + "roles": "Manage role categories", + "help": "Get help" + } } }, "main": { "welcome": "Welcome", "dashboard": { - "totalcontributors": "Total Contributors" + "upcomingtask": "Upcoming Tasks", + "overduetask": "Overdue Tasks", + "notifications": "Notifications", + "totalcontributors": "Total Contributors", + "alltasks": "Tasks", + "projects": "Projects", + "forms": "Forms", + "invites": "Invitations", + "roles": "Roles", + "lastupdatedprojects": "Last Updated Projects", + "alltasksbutton": "All Tasks", + "allprojectsbutton": "All Projects", + "allnotificationsbutton": "All Notifications", + "tooltips": { + "upcomingtask": "Three upcoming tasks for all projects", + "overduetask": "Overdue Tasks", + "notifications": "Notifications", + "totalcontributors": "Total Contributors", + "alltasks": "Tasks", + "projects": "Projects", + "forms": "Forms", + "invites": "Invitations", + "roles": "Roles", + "lastupdatedprojects": "Last Updated Projects" + } } + }, + "projects": { + "title": "All Projects", + "createproject": "Create Project" + }, + "widgets": { + "upcomingtask": "No upcoming task", + "overduetask": "No overdue task" + }, + "breadcrumbs": { + "projects": "Projects", + "home": "Home", + "main": "Main", + "invites": "Invitations", + "tasks": "Tasks", + "notifications": "Notifications", + "forms": "Forms", + "roles": "Roles", + "updates": "Administration", + "help": "Help", + "summary": "Summary", + "settings": "Setttings", + "tags": "Tags", + "notes": "Notes", + "milestones": "Milestones", + "contributors": "Contributors", + "teams": "Teams", + "edit": "Settings" } } diff --git a/public/locales/es/common.json b/public/locales/es/common.json index e253d3f0..3994caa2 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -24,13 +24,75 @@ "forms": "Formularios", "roles": "Roles", "admin": "Administración", - "help": "Ayuda" + "help": "Ayuda", + "tooltips": { + "dashboard": "Tablero de inicio", + "projects": "Ver todos los proyectos", + "invitations": "Invitaciones a proyectos", + "tasks": "Ver todas las tareas", + "notifications": "Ver todas las notificaciones", + "forms": "Gestionar formularios de metadatos", + "roles": "Gestionar categorías de roles", + "help": "Obtener ayuda" + } } }, "main": { "welcome": "Bienvenido", "dashboard": { - "totalcontributors": "Contribuidores Totales" + "upcomingtask": "Tareas Pendientes", + "overduetask": "Tareas Vencidas", + "notifications": "Notificaciones", + "totalcontributors": "Contribuidores Totales", + "alltasks": "Tareas", + "projects": "Proyectos", + "forms": "Formularios", + "invites": "Invitaciones", + "roles": "Roles", + "lastupdatedprojects": "Últimos Proyectos Actualizados", + "alltasksbutton": "Ver todas las tareas", + "allprojectsbutton": "Ver todos los proyectos", + "allnotificationsbutton": "Ver todas las notificaciones", + "tooltips": { + "upcomingtask": "Tres tareas próximas para todos los proyectos", + "overduetask": "Tres tareas vencidas para todos los proyectos", + "notifications": "Tres notificaciones recientes para todos los proyectos", + "totalcontributors": "Total de contribuidores únicos en todos los proyectos", + "alltasks": "Porcentaje de tareas completadas", + "projects": "Total de proyectos", + "forms": "Total de plantillas de metadatos", + "invites": "Total de invitaciones a proyectos", + "roles": "Total de etiquetas de roles", + "lastupdatedprojects": "Tres proyectos recientemente actualizados" + } } + }, + "projects": { + "title": "Todos los proyectos", + "createproject": "Crear proyecto" + }, + "widgets": { + "upcomingtask": "No hay tareas pendientes", + "overduetask": "No overdue task TEST" + }, + "breadcrumbs": { + "projects": "Proyectos", + "home": "Inicio", + "main": "Tablero", + "invites": "Invitaciones", + "tasks": "Tareas", + "notifications": "Notificaciones", + "forms": "Formularios", + "roles": "Roles", + "updates": "Administración", + "help": "Ayuda", + "summary": "Resumen", + "settings": "Configuración", + "tags": "Etiquetas", + "notes": "Notas", + "milestones": "Hitos", + "contributors": "Colaboradores", + "teams": "Equipos", + "edit": "Ajustes" } } diff --git a/src/comments/components/ChatBox.tsx b/src/comments/components/ChatBox.tsx index c89af6dd..81cd95a2 100644 --- a/src/comments/components/ChatBox.tsx +++ b/src/comments/components/ChatBox.tsx @@ -6,6 +6,9 @@ import { CommentWithAuthor } from "src/core/types" import { getContributorName } from "src/core/utils/getName" import { useParam } from "@blitzjs/next" import { useCurrentContributor } from "src/contributors/hooks/useCurrentContributor" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import remarkBreaks from "remark-breaks" interface ChatBoxProps { initialComments?: CommentWithAuthor[] @@ -20,6 +23,7 @@ export default function ChatBox({ }: ChatBoxProps) { const [comments, setComments] = useState(initialComments) const [newComment, setNewComment] = useState("") + const [mode, setMode] = useState<"edit" | "preview">("edit") const chatRef = useRef(null) const [addCommentMutation] = useMutation(addComment) const [markCommentsAsReadMutation] = useMutation(markAsRead) @@ -51,7 +55,7 @@ export default function ChatBox({ projectMemberId: currentContributor.id, }) .then(() => { - if (refetchComments) refetchComments() + if (refetchComments) void refetchComments() }) .catch((error) => { console.error("Failed to mark comments as read:", error) @@ -71,14 +75,14 @@ export default function ChatBox({ }) setComments((prev) => [...prev, { ...createdComment, commentReadStatus: [] }]) // Ensure new comment has empty commentReadStatus setNewComment("") // Clear input field - if (refetchComments) refetchComments() // Trigger refresh + if (refetchComments) await refetchComments() // Trigger refresh } catch (error) { console.error("Failed to send comment:", error) } } return ( -

    +
    {comments.length > 0 ? ( comments.map((comment) => { @@ -110,7 +114,36 @@ export default function ChatBox({ : "bg-secondary text-secondary-content" }`} > - {comment.content || "[No Content]"} +
    + ( + + ), + ul: ({ node, ...props }) =>
      , + ol: ({ node, ...props }) =>
        , + p: ({ node, ...props }) =>

        , + code: ({ inline, className, children, ...props }) => + inline ? ( + + {children} + + ) : ( +

        +                              {children}
        +                            
        + ), + }} + > + {comment.content || "[No Content]"} + +
    ) @@ -120,19 +153,92 @@ export default function ChatBox({ )}
    - {/* Input Field */} -
    - setNewComment(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSendComment()} // Send on Enter key press - /> - + {/* Input Field (Markdown with Preview) */} +
    + {/* Toolbar */} +
    +
    +
    + + +
    + + Supports{" "} +
    + Markdown + {" "} + formatting. + +
    + + Enter to send • Shift+Enter for newline + +
    + + {mode === "edit" ? ( +