Skip to content
Heer64Public

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

85 Commits

Folders and files

Repository files navigation

DevOrbit

DevOrbit is a web app where student and developer teams can work on projects together. You can chat with your team, manage tasks, see your GitHub activity, and ask an AI assistant questions about your project. There is also a public side where people can post, vote, follow each other and find projects to join or collaborate.

This project is built for our Software Engineering course.


What the app does

  • Accounts and profiles: sign up, log in, and add a photo, bio and skills to your profile
  • Projects: create public or private projects and give members roles (Owner, Admin, Contributor, Viewer)
  • Team chat: project channels with @mentions, plus direct messages
  • Tasks: create and assign tasks, group them into milestones, and track them on a Kanban board
  • Activity Feed: one feed that shows messages, task updates and GitHub commits, issues and pull requests
  • AI Copilot: ask questions about your project and get a "Catch Me Up" summary of what you missed
  • Public side: discover public projects, share posts, vote, follow people, and build a portfolio

Tech Stack

Frontend

  • HTML
  • CSS
  • JavaScript
  • React

Backend

  • Node.js
  • Express.js
  • MongoDB

Testing

  • Jest
  • Vitest

Tools and Hosting

  • Docker
  • CI (automated tests before merging)
  • Vercel (frontend hosting)
  • Render (backend hosting)
  • MongoDB Atlas (database hosting)

Project Structure

devorbit/
├── client/        # Frontend (React)
├── server/        # Backend (Node + Express)
├── docs/          # Project documents
├── .env.example   # Example environment variables
└── README.md

Getting Started

You will need Node.js and a MongoDB connection (local or Atlas).

# clone the repo
git clone <repo-link>
cd devorbit

# install dependencies
cd server && npm install
cd ../client && npm install

Copy .env.example to .env in the server folder and fill in your own values. Please don't push your .env file to GitHub.

Then start both parts:

# backend
cd server
npm run dev

# frontend (in a new terminal)
cd client
npm run dev

To run the tests:

cd server && npm test      # Jest
cd client && npm run test  # Vitest

Deployment

  • Frontend: hosted on Vercel
  • Backend: hosted on Render
  • Database: MongoDB Atlas

Sprint 1

Goal: get the basics working so users can sign up, set up a profile, create a project, and have roles that control what they can do.

User stories

Story What it is
US-01 Register an account
US-02 Log in
US-03 Log out
US-05 Edit my profile
US-08 Create a project
US-09 Assign project roles

What we will build

  • Project setup: repo, branches, and a basic way to run tests automatically
  • Sign up, log in and log out, with passwords saved as a salted hash (never plain text)
  • Profile page where users can add a photo (JPEG or PNG, up to 2 MB), a bio (up to 500 characters) and up to 10 skill tags
  • Create a project with a name, description, domain and Public or Private visibility. The creator becomes the Owner

Sprint 1 is done when

  • A new user can register, log in, edit their profile and create a project
  • Role rules work (for example, a Viewer can't change roles)
  • Tests pass and the setup steps work on a fresh clone

Sprint 2 — Milestones and activity (Pair C)

Milestones (/api/projects/:id/milestones)

Call Who Notes
GET / Members, and visitors of a Public project Each milestone has taskCount, doneCount, progress (0–100) and completed, worked out from its tasks
POST / Owner, Admin { title, description?, dueDate? }
PATCH /:milestoneId Owner, Admin any of the fields above; dueDate: null clears it
DELETE /:milestoneId Owner, Admin tasks are kept and detached

Tasks take an optional milestone (an ID from the same project) on create and edit, and GET /api/projects/:id/tasks?milestone=<id>|none filters by it.

Activity

Call Who Notes
GET /api/projects/:id/activity?limit=30&before=<date> Members only newest first; pass nextBefore back as before for older events
GET /api/activity/me?limit=10 Signed-in user latest events across all of your projects, with project { name, color }

Events are written by logActivity() in backend/src/lib/activity.js for project create/update, member add/role change/remove/leave, task create/status/assign/edit/delete, and milestone create/edit/delete. Logging never breaks the main request.

Sprint 2 — Kanban board (Pair D: US-27, US-28)

The Tasks tab has a List / Board switch. The board has four columns: To Do, In Progress, Blocked, Done. You can drag a card, or use its "Move to" menu, to change the status. Moves show up right away and are put back if the server refuses them. Clicking a card opens it (for now in the task edit modal, which is read-only for Viewers).

Statuses: To do, In progress, Blocked, Done (Blocked added for FR-7b).

Versions: every task has a version number. It starts at 0 and goes up by 1 on every saved change. Clients send the version they loaded, and a mismatch returns 409 so nothing is silently overwritten (NFR-5a).

Call Who Notes
GET /api/projects/:id/tasks/:taskId Members, and visitors of a Public project { task }
PATCH /api/projects/:id/tasks/:taskId/status Owner, Admin, Contributor body { status, version } → { task }; 409 { message: "This task was changed by someone else.", task: <latest> }; 403 for Viewers and non-members
PATCH /api/projects/:id/tasks/:taskId Owner, Admin, Contributor unchanged, but now bumps version on every real change and accepts an optional version (409 when stale)

Live updates (Socket.IO, same port as the API):

  • Connect with io(<API origin>, { auth: { token: <access token> } }).
  • Emit project:join { projectId }. The ack is { ok, role }. Joining is allowed for members of any project and anyone for a Public project.
  • Listen for task:updated { projectId, task }. It is sent after every saved status change or edit. Clients ignore events whose task.version is not newer than what they already have.
  • The server code is in backend/src/lib/realtime.js. Add future real-time events (e.g. chat) there rather than starting a second Socket.IO server.

Demo without a backend: sample projects use frontend/src/taskMock.js. In the browser console, run localStorage.setItem('devorbit_mock_move', 'reject') to see a failed move roll back, or 'conflict' to see the 409 message. Run localStorage.removeItem('devorbit_mock_move') to go back to normal.

Tests: npm --prefix backend test (includes kanban.test.js, realtime.test.js) and npm --prefix frontend test (Vitest).

Sprint 2 — leftovers

These finish the parts of Sprint 2 that were still missing after Pairs A–D merged.

Task label (US-21). Tasks have an optional label (free text, trimmed, up to 30 characters, default '') next to the existing Priority. It is accepted by POST and PATCH /api/projects/:id/tasks (a longer label returns 400), returned with every task, shown on list rows and Kanban cards, and edited in the task form. Changing it is logged as a task.updated activity.

Dashboard task views (/api/tasks, signed-in user, only projects you are a member of)

Call Returns Notes
GET /api/tasks/upcoming?days=7&limit=20 { tasks, days } Open (not Done) tasks due from today up to days ahead (1–30, default 7), soonest first. Overdue tasks are not included. (US-23)
GET /api/tasks/mine?limit=8 { tasks, total } Open tasks assigned to you; dated tasks first, undated last
GET /api/tasks/stats { completedLast7Days, openAssignedToMe } Used by the dashboard stat cards

Each task includes project { _id, name, color } and assignee. The dashboard now shows Upcoming deadlines and My tasks panels, and the "Tasks completed" and "Assigned to me" cards use these numbers. Demo users still see sample data.

Discover on real data. For signed-in users the Discover page loads GET /api/projects/discover?q= (Public projects only) and GET /api/profile/people?q= (people with profileDiscoverable on, never yourself). Search is debounced (300 ms) and done by the API; the domain filter is built from the loaded projects; sort is "Recently active" or "Alphabetical". Follow and Save stay demo-only until those features have an API. Demo users keep the sample cards.

CI now runs the frontend tests (npm test --prefix frontend) before the frontend build.

End-to-end tests (e2e/, Playwright) run the real API, a real MongoDB and the built frontend in Chromium, and click through the main journeys: sign up and log in, a labelled task on the Kanban board, dashboard deadlines and stats, and Discover with two users. They run in CI as a second job after the unit tests pass, and locally with npm run test:e2e (no MongoDB install needed). See e2e/README.md for how to run them and how to add 1–2 tests per sprint.

Tests: backend/test/tasks.test.js (label), backend/test/myTasks.test.js, backend/test/discover.test.js; frontend/src/components/TasksPanel.label.test.jsx, frontend/src/components/DashboardTasks.test.jsx, frontend/src/pages/DashboardPage.test.jsx, frontend/src/pages/DiscoverPage.test.jsx.

Sprint 2 — final (channels and live task list)

This closes Sprint 2. It adds the project channels that Sprint 3 builds on (threads, mentions, feed, decisions).

Project channels (US-16, FR-5) (/api/projects/:id/channels, members only, even for a Public project)

Call Who Notes
GET / Members { channels, membershipRole }; #general is created the first time
POST / Owner, Admin { name, description? } → 201 { channel }; names are lowercase and unique per project
GET /:channelId/messages?limit=50&before=<date> Members { messages (oldest first), nextBefore }
POST /:channelId/messages Owner, Admin, Contributor { body } → 201 { message }; Viewers get 403 but can still read

The project page has a Chat tab. New messages are pushed live as message:created { projectId, channelId, message } to a members-only room. Visitors who are watching a Public project's board never receive messages.

Links into the project: /project/<id>?tab=tasks&task=<taskId> opens a task. /project/<id>?tab=chat&channel=<channelId> opens a channel.

Live task list: besides task:updated, the server now sends task:created { projectId, task } and task:deleted { projectId, taskId }. Tasks created or deleted by someone else appear or disappear without a reload. If the deleted task is open, its window closes with a notice.

Tests: backend/test/channels.test.js, backend/test/realtime.test.js (create/delete events), frontend/src/components/ChatPanel.test.jsx, frontend/src/components/TasksPanel.board.test.jsx.

Sprint 3 — M7 Part 1: notification storage (US-30)

Storage and read access for in-app notifications. Creating them automatically (mentions, assignments, deadlines), pushing them live and the notification list UI come in M7 Part 2.

Model (backend/src/models/Notification.js): recipient (User), type (mention | assignment | deadline, shown as Mention / Assignment / Deadline), project, actor (User or null for system notifications such as Deadline), summary (≤ 300), targetType (task | message | comment), targetId, targetLabel (≤ 200), link (in-app path starting with one /), read (default false), readAt (default null), optional dedupeKey, createdAt / updatedAt.

Storing one: createNotification({...}) in backend/src/lib/notifications.js. It only notifies current project members, skips the actor's own actions, never throws, and with a dedupeKey (e.g. deadline:<taskId>:<dueDate>) never stores the same notification twice for one user. taskLink() and messageLink() build the deep links ProjectPage already opens.

API (/api/notifications, signed-in user, own notifications only)

Call Response Notes
GET /api/notifications?limit=20&before=<date>&unread=true { notifications, nextBefore, unreadCount } newest first; limit 1–50; pass nextBefore back as before; each has actor { name, username, avatarUrl } and project { name, color }
PATCH /api/notifications/:notificationId/read { notification, unreadCount } safe to repeat (keeps the first readAt); another user's notification answers 404

Only projects you are a member of right now count: if you are removed from a project or leave it, its notifications disappear from the list and from unreadCount (and marking one read answers 404). The rows are kept, so they show again if you are added back.

Tests: backend/test/notifications.test.js.

Sprint 3 — M3: remove a member and leave a project (US-13, US-14)

Builds on the existing DELETE /api/projects/:id/members/:userId (US-09), which already removed members, let members leave, protected the Owner and logged member.removed / member.left.

What M3 adds

  • Access ends at once, also live. After a removal or a leave, the person's open browser tabs are taken out of the project's live rooms: no more chat messages or activity, and for a Private project no task updates either (a Public board stays watchable, like for any visitor). They receive project:access-removed { projectId, reason: 'removed' | 'left' }, and the project page tells them and reloads (Private → "no access", Public → visitor view). Rejoining a Private project's room is refused.
  • History stays. Their messages, created/assigned tasks and activity remain and keep their name. Tasks still assigned to them can be edited normally.
  • Confirmation first. Removing a member and leaving a project both open a confirmation dialog explaining what happens (ConfirmDialog.jsx). Errors from the server are shown in the dialog.
  • Only Owner. The Owner cannot be removed (403 "The project Owner cannot be removed. Every project needs an Owner."). If the Owner tries to leave, the dialog and the API (403) ask them to transfer ownership to another member first.

Tests: backend/test/removeLeave.test.js, frontend/src/components/TeamPanel.test.jsx.

Sprint 3 — M2: join requests (US-12, US-47)

A signed-in non-member can ask to join a Public project; the project's Owner or an Admin approves or rejects it.

Model (backend/src/models/JoinRequest.js): project, user (who asked), message (optional, ≤ 500), status (Pending | Approved | Rejected, default Pending), role (set on approval), decidedBy, decidedAt, createdAt / updatedAt. A unique index allows only one pending request per person per project.

API (/api/projects/:id/join-requests, signed in)

Call Who Notes
POST / Non-member of a Public project { message? } → 201 { joinRequest }. 409 if already a member, already pending, or rejected less than 7 days ago (then also canRequestAgainAt); 403 for a Private project; 400 message over 500 characters
GET /me Anyone who can see the project { joinRequest (latest or null), canRequestAgainAt, isMember }
GET /?status=Pending Owner, Admin { joinRequests }, oldest first; status = Pending (default), Approved, Rejected or all
POST /:requestId/approve Owner, Admin { role? } (default Contributor; Owner may give Admin/Contributor/Viewer, Admin may give Contributor/Viewer) → { joinRequest }; the person becomes a member
POST /:requestId/reject Owner, Admin → { joinRequest }; the person cannot ask again for 7 days

Approve/reject answer 409 if the request was already decided (only one decision wins, even if two managers click at once) and 404 if it belongs to another project. Each step is logged in the Activity Feed (join_request.created, .approved, .rejected).

UI: a visitor of a Public project sees a Request to join card (optional message, then "Request sent", or the date they can ask again after a rejection). Owners and Admins see a Join requests panel under Team & roles with a role picker, Approve and Reject; the team list reloads after an approval. A logged-out user who opens a project link is sent to log in and then returned to that page.

Tests: backend/test/joinRequests.test.js, frontend/src/components/JoinRequests.test.jsx, frontend/src/pages/LoginPage.test.jsx.

Sprint 3 — M4: thread replies and task comments (US-17, US-20)

Thread replies (US-17, FR-5). Any channel message can get a thread. Replies are Message rows with parentMessage set, so they are left out of the channel history (GET .../messages now lists top-level messages only). The parent keeps replyCount and lastReplyAt, increased atomically ($inc) so replies sent at the same moment are all counted. Messages saved before threads existed count as top-level with 0 replies; nothing needs migrating. Threads are one level deep.

Call (/api/projects/:id/channels/:channelId/messages/:messageId/replies) Who Notes
GET /?limit=100&after=<date> Members { parent, replies (oldest first), nextAfter }; pass nextAfter back as after
POST / Owner, Admin, Contributor { body } (≤ 4000) → 201 { reply, parent: { _id, replyCount, lastReplyAt } }; Viewers 403; replying to a reply 400

Task comments (US-20, FR-7). backend/src/models/TaskComment.js: project, task, author, body (≤ 2000), mentions (Users), createdAt. Comments are members-only, like channels: a Public project's visitors see its tasks but not the comments.

Call (/api/projects/:id/tasks/:taskId/comments) Who Notes
GET /?limit=100&after=<date> Members { comments (oldest first), nextAfter }; each has author { name, username, avatarUrl } and mentions [{ name, username }]
POST / Owner, Admin, Contributor { body } → 201 { comment }; Viewers 403

@mentions. Write @username in a comment. backend/src/lib/mentions.js matches only current project members (emails such as a@b.com are not mentions). Each mentioned member gets a Mention notification through createNotification() (M7 Part 1 storage) that opens the task. Mentioning yourself notifies no one, and mentioning someone twice notifies them once. The helper is ready for chat mentions (US-18).

Live and feed. New socket events, members only: message:reply { projectId, channelId, parentId, reply, replyCount, lastReplyAt } and comment:created { projectId, taskId, comment }. Replies are logged as message.posted ("replied in a thread in #general: …") and comments as comment.created.

UI. In Chat, each message shows Reply (on hover) or N replies · Last reply …. Clicking it opens the thread beside the channel (stacked below on small screens). The task window has a Comments section showing author, relative time (exact time on hover) and highlighted mentions. Typing @ suggests project members (↑/↓, Enter/Tab to pick, Esc to close). Viewers read only. Demo projects (mock tasks) show no comments.

Tests: backend/test/threads.test.js, backend/test/taskComments.test.js, frontend/src/components/Threads.test.jsx, frontend/src/components/TaskComments.test.jsx.

Sprint 3 — M4 extra: @mentions in chat (US-18)

Mentions in chat. The channel message box and the thread reply box suggest project members when you type @, the same as task comments. A sent message stores mentions ([{ _id, name, username }], returned by the channel and thread APIs) and highlights them. Each mentioned member gets a Mention notification: from a channel message it opens ?tab=chat&channel=…&message=…, and from a thread reply it adds &thread=<parentId>, so the thread opens too. Mentions and notifications use backend/src/lib/mentions.js (findMentionedMembers, notifyMentions).

Tests: backend/test/mentionNotifications.test.js, frontend/src/components/ChatMentions.test.jsx.

Sprint 3 — M5: task due dates and the 24-hour deadline query (US-23)

What a due date is. A calendar day, not a moment. The API stores it as midnight UTC of that day ("2026-10-28" → 2026-10-28T00:00:00.000Z), so every member sees the same day in any timezone. A task is due at the end of that day (UTC): it is overdue after that, unless it is Done. All rules are in backend/src/lib/dueDates.js.

Setting and editing (POST / PATCH /api/projects/:id/tasks, Owner, Admin, Contributor)

dueDate sent Result
"YYYY-MM-DD" saved as that day (leap days fine, 2026-02-30 is 400)
a timestamp that is exactly midnight UTC (what the API returns) same day, so a task can be sent back unchanged; sending the same day again is not a change (no new version)
any other timestamp, e.g. 2026-10-28T00:00:00+05:30 400 "Due date must be a calendar date (YYYY-MM-DD)." (turning local midnight into a day would quietly give 27 Oct)
a year before 2000 or after 2100 400
null or "" clears it (PATCH)

A due-date-only edit appears in the Activity Feed as set the due date of "X" to Oct 28, 2026 or removed the due date of "X".

Deadline query: due within 24 hours, not Done (FR-10). A task qualifies when it is not Done and now < deadline < now + 24 h (exactly 24 h away does not count; a passed deadline is overdue instead). With midnight-UTC dates this is the whole due day in UTC (05:30 to 05:30 in India).

  • findTasksDueSoon({ now, projectIds, assignee, assignedOnly = true, limit }) in backend/src/lib/deadlines.js, for M7 to send Deadline notifications. Each task has assignee, project { name, color }, deadline and dedupeKey (deadline:<taskId>:<dueDate ISO>, ready for createNotification: one notification per task and day, and a new one if the date moves).
  • GET /api/tasks/due-soon → { tasks, windowHours: 24, now }: the caller's own open tasks in that window, from their current projects only.

On screen. The day is read in UTC (formatDue), so a teammate in California still sees Oct 28; "today", "tomorrow" and "overdue" use the viewer's own calendar day (isOverdue, frontend/src/dueDates.js). Task rows and Kanban cards now show Oct 28 · due today / Oct 29 · due tomorrow (orange) besides Oct 27 · overdue (red); Done tasks are never overdue. In the task form the date has a Clear button and a line underneath that says how the day will read ("Due tomorrow (Oct 29).", "Oct 20 has already passed."). Viewers see the date but cannot change it. The dashboard's Upcoming deadlines panel is unchanged.

Tests: backend/test/dueDates.test.js (parsing, timezones, leap days, exact 24-hour boundaries, the Mongo range checked against the rule for every hour of a month), backend/test/dueDateApi.test.js (set/clear/400s/activity, the deadline query with a fixed clock, GET /api/tasks/due-soon), frontend/src/dueDates.test.js (India, California, midnight and DST boundaries), frontend/src/components/TaskDueDates.test.jsx (rows, cards, form, Clear, Viewer).

Sprint 3 — M1: project invitations (US-10, US-11)

An Owner or Admin invites an existing DevOrbit user with a role. The person joins only after they accept; they can also decline. A pending invitation expires after 7 days.

Model (backend/src/models/Invitation.js): project, invitee (User), invitedBy (User), role (Admin | Contributor | Viewer, default Contributor), status (Pending | Accepted | Declined | Expired), expiresAt (sent + 7 days), respondedAt, createdAt / updatedAt. A unique index allows only one pending invitation per person per project. A Pending invitation past expiresAt is reported as Expired straight away; the row is stored as Expired the next time it is touched, which frees the slot for a new invitation.

Owner / Admin (/api/projects/:id/invitations)

Call Notes
POST / { identifier, role? } → 201 { invitation }. identifier is an email or username (@name is fine, any case). Owner may invite as Admin/Contributor/Viewer, Admin as Contributor/Viewer (same rules as US-09). 409 already a member or already has a pending invitation (nothing is created); 404 no such user; 400 no identifier or bad role; 403 Contributors, Viewers, non-members
GET /?status=all { invitations }, newest first; status = all (default), Pending, Accepted, Declined, Expired. This is where the inviter sees that an invitation was declined

Invited user (/api/invitations)

Call Notes
GET /?status=Pending { invitations } — my invitations, newest first (same status values)
POST /:invitationId/accept → { invitation, project: { _id, name }, joined }. I become a member with the invited role and the invitation is closed (Accepted). If I had joined some other way meanwhile, joined is false and my role is not changed
POST /:invitationId/decline → { invitation }. I am not added; the invitation is closed (Declined)

Accept/decline answer 404 for an invitation that is not mine and 409 when it was already answered or has expired ("This invitation has expired. Ask the project team to invite you again."). Only one answer wins, even if Accept and Decline are clicked at once. Each invitation comes with invitee / invitedBy { name, username, avatarUrl } and project { name, color, visibility, description } — never emails. Every step is logged in the Activity Feed (invite.sent, invite.accepted, invite.declined).

UI. On a project page, Owners and Admins get an Invitations panel under Team & roles: email-or-username box, role picker, Send invite, and the list of sent invitations with their state (Pending · expires …, Accepted, Declined, Expired). It replaces the old "Add to project" form in Team & roles, because people now join by accepting (the POST /members API from US-09 is unchanged). On the dashboard, Your invitations lists pending invitations (project, who invited you, role, days left) with Accept and Decline; after accepting, the project list reloads and Open project takes you there. The panel is hidden when there is nothing pending and for the demo account.

Tests: backend/test/invitations.test.js, frontend/src/components/Invitations.test.jsx, frontend/src/pages/DashboardPage.test.jsx (invitations panel).

Sprint 3 — Decisions (M8: US-35, US-36)

Project members keep a record of why a choice was made: a title, a description, the people involved, the date, and optionally a link to the task or chat thread where it was discussed.

Model (backend/src/models/Decision.js): project, title (required, up to 120 characters), description (required, up to 2,000), participants (Users, at least one), date (required calendar day, YYYY-MM-DD, stored as midnight UTC like task due dates), relatedTask or relatedMessage + relatedChannel (a thread = its top-level channel message), createdBy, updatedBy (null until edited), createdAt / updatedAt. The field rules are in backend/src/lib/decisions.js.

API (/api/projects/:id/decisions, members only)

Call Who Notes
GET / All members (Viewers too) { decisions }, newest date first
GET /:decisionId All members { decision }
POST / Owner, Admin, Contributor { title, description, participants: [userId], date, link? } → 201 { decision }
PATCH /:decisionId Owner, Admin, Contributor any of the same fields; link: null removes the link → { decision }

link is { type: 'task', id: <taskId> } or { type: 'thread', id: <top-level messageId> }. Each decision comes back with participants / createdBy / updatedBy { name, username, avatarUrl } and link: { type, id, label, href, missing }, where href uses the existing deep links (?tab=tasks&task=…, ?tab=chat&channel=…&message=…&thread=…). If the linked task or message is deleted later, the decision is kept and the link comes back with missing: true and href: null.

Errors: 400 for a missing or invalid field (nothing is saved, including on edit), a participant who is not a project member (on edit, people already on the decision may stay after they leave the project), a task or thread from another project, or a reply used instead of the thread's first message; 403 for Viewers on POST/PATCH and for non-members; 404 for a decision of another project. Recording and editing are logged in the Activity Feed as decision.created ("recorded decision …") and decision.updated ("updated decision …"); an edit that changes nothing is not logged.

UI. The project page has a Decisions tab (between Milestones and Activity). It lists every decision with its title, date, participants, description, link and "Recorded by … · Edited by …". Owners, Admins and Contributors get Record decision and an edit button. The form has title, description, date, participant checkboxes (project members), and Related task or thread (No link / Task / Channel thread → channel → message). Missing fields show an error in the form and nothing is sent. Viewers only see the list. Demo projects show a placeholder.

Tests: backend/test/decisionRules.test.js (field rules, dates, links; no database), backend/test/decisions.test.js (all five acceptance criteria, roles, participants, cross-project links, reply-as-thread, deleted task, activity events), frontend/src/components/DecisionsPanel.test.jsx (list, record, validation, server errors, edit, Viewer, link hrefs, thread picker).

Sprint 3 — M7 Part 2: notification integration (US-30, FR-10, FR-33, NFR-6)

Builds on M7 Part 1 (storage + /api/notifications), M4's mention notifications and M5's deadline query.

Generated automatically (all through createNotification(), so only current project members are notified, nobody about their own action, and a failure never breaks the request)

Type When Link Duplicates
Mention @username in a channel message, thread reply or task comment (M4, lib/mentions.js) the message / thread / task mention:<type>:<id>
Assignment a task is created with an assignee, or its assignee changes (lib/taskNotifications.js, called from routes/tasks.js). Assigning yourself tells nobody the task assignment:<taskId>:v<version>: a retried request never notifies twice, a later re-assignment does
Deadline an assigned task that is not Done is less than 24 hours from its deadline (M5 rules). Sent at once when a task is saved inside that window (create, edit, moved out of Done), and by a check every 5 minutes (startDeadlineNotifier(), started in server.js) for tasks that enter the window as time passes the task M5's deadline:<taskId>:<due day>: once per task and day; a new due date notifies again

Live. Every authenticated socket now joins its own room user:<id> on connect (the id comes from the verified token). New notifications are pushed only there as notification:created { notification, unreadCount } (same shape as the REST list), and marking one read sends notification:read { notificationId, unreadCount } to the user's other tabs. A notification returned again for a repeated dedupeKey is not pushed again. Same shared Socket.IO server as before.

UI. The bell in the top bar (NotificationBell.jsx) shows the unread count (9+ above nine) and opens the newest notifications, labelled Mention, Assignment or Deadline, with who did it, the project and when. Selecting one marks it read and opens its task, message or thread. New ones appear live; "Show older" pages back. The demo workspace keeps the static bell.

Tests: backend/test/notificationDelivery.test.js, frontend/src/components/NotificationBell.test.jsx.

Sprint 4 — M1: Authorize GitHub (US-31, FR-11, NFR-4)

A project Owner connects FORGE to their GitHub account with GitHub OAuth so FORGE can read their repositories. Picking the repository (US-32) and view / disconnect / re-authorize (US-33) build on this.

GitHub OAuth setup (once per environment)

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App (or the team organisation's settings).
  2. Homepage URL: the frontend, e.g. http://localhost:5173. Authorization callback URL: the API callback, e.g. http://localhost:5000/api/github/callback (in production the HTTPS API URL + /api/github/callback).
  3. Copy the Client ID and generate a Client secret, then add them to backend/.env only (never frontend/.env, never committed):
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GITHUB_CALLBACK_URL=http://localhost:5000/api/github/callback   # must match the app's callback URL exactly
GITHUB_TOKEN_KEY=...   # 64 hex chars: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Until all four are set, the API logs which ones are missing (names only) and the panel says GitHub is not set up. Use one OAuth App per environment (local / deployed), because each has its own callback URL. Keep GITHUB_TOKEN_KEY the same once tokens are stored: a new key makes stored tokens unreadable.

How it works

Step What happens
Connect GitHub (Owner only) POST /api/projects/:id/github/authorize creates a random single-use state (stored only as a SHA-256 hash, expires after 10 minutes), puts the same state in an httpOnly cookie that is only sent to /api/github/callback, and returns the GitHub authorize URL (scope=repo, the fixed callback URL). The browser goes there.
Approve on GitHub GitHub redirects to GET /api/github/callback?code&state. The state must exist, be unexpired and unused, and match the cookie (constant-time compare). It is deleted on first use. The user who started must still be the project Owner. The code is exchanged for a token server-side, the GitHub account is read, the token is encrypted with AES-256-GCM and saved in GithubConnection. Redirect to /project/:id?github=connected.
Deny on GitHub ?error=access_denied → nothing is stored (an earlier authorization is left as it was). Redirect to ?github=denied.
Anything wrong Missing/mismatched cookie → invalid; unknown, used or expired state → expired; Owner changed → forbidden; GitHub refused the code → error. Nothing is stored in any of these cases.

The callback builds its redirect only from CLIENT_ORIGIN and the project stored with the state, never from the query string, and answers with Cache-Control: no-store and Referrer-Policy: no-referrer.

Token safety (NFR-4). encryptedToken is select: false and removed from toJSON / toObject; it lives in its own collection (not on Project), so no project response can include it. No API response contains the token, and error logs contain only a short reason (for example bad_verification_code), never the token, the code or the client secret. decryptToken() in lib/githubCrypto.js is for server-side GitHub calls in US-32 / US-34.

API

Call Who Answer
GET /api/projects/:id/github Owner, Admin { configured, canAuthorize, connection }; connection is null or { githubLogin, scopes, authorizedAt, authorizedBy { _id, name, username } }
POST /api/projects/:id/github/authorize Owner { url }; 403 for others, 503 if the server is not configured
GET /api/github/callback GitHub redirect 303 to /project/:id?github=connected|denied|invalid|expired|forbidden|error

UI. Owners and Admins see a GitHub panel (Integrations) in the right column of the project page. The Owner gets Connect GitHub; Admins see the status only. After GitHub sends the Owner back, the panel shows "GitHub is connected…" or "The GitHub connection was not made … Nothing was saved." and removes ?github from the URL. Once authorized it shows "Authorized as @login · by … · date".

Files: backend/src/routes/github.js, backend/src/lib/githubOAuth.js, backend/src/lib/githubCrypto.js, backend/src/models/GithubConnection.js, backend/src/models/GithubOAuthState.js, frontend/src/components/GithubIntegrationPanel.jsx, githubApi in frontend/src/api.js.

Tests: backend/test/githubOAuth.test.js (both acceptance criteria, encryption at rest, no token or secret in any response or log, state cookie + hash + single use + expiry, no open redirect, roles, missing config; GitHub is faked), backend/test/githubCrypto.test.js (encryption and state helpers, no database), frontend/src/components/GithubIntegrationPanel.test.jsx.

Sprint 4 — M2: Connect a repository (US-32, FR-11)

After the Owner has authorized GitHub (US-31, M1), the GitHub panel on the project page offers Choose repository. The Owner picks one repository from the authorized GitHub account's list, and the panel shows it with an Active badge. Admins see the repository and the state but cannot change it. Picking again replaces the repository (a project always has at most one).

When is the connection Active? (repositoryStatus() in backend/src/lib/githubRepos.js)

connectionState Meaning
not_connected GitHub has not been authorized for this project yet (US-31)
unavailable an authorization is stored, but the server's GitHub settings are missing, so it cannot be used
needs_repository authorized, but no repository is chosen, or it was chosen under a different GitHub account than the one authorized now
active authorized, server configured, and a repository chosen under the current GitHub account

The chosen repository is stored on the project's GithubConnection (repository field: GitHub repository ID, owner/name, link, private, default branch, whether the account can administer it, the GitHub account it was chosen under, who connected it and when). Project.githubRepo is set to owner/name so the project header shows it. Connecting or changing it is logged in the activity feed as project.updated.

API (Owner only; needs a stored authorization and the server GitHub settings)

Call Answer
GET /api/projects/:id/github (M1, extended) adds { repository, connectionState, active, canSelectRepository }; Owner and Admin
GET /api/projects/:id/github/repositories { githubLogin, repositories: [{ id, name, fullName, owner, private, htmlUrl, defaultBranch, description, archived, admin, updatedAt }], truncated, selectedId }; up to 500, most recently updated first
PUT /api/projects/:id/github/repository body { repositoryId }; the server checks the ID against the authorized account's list, saves it, and answers with the status body (now active: true)

Errors are { message, code }: 403 owner_only, 503 github_not_configured, 409 github_not_authorized, 409 github_reauthorize (GitHub rejects the token, or it can no longer be decrypted), 409 github_changed (re-authorized while choosing), 400 invalid_repository, 400 repository_not_available, 503 github_rate_limited (with Retry-After), 502 github_forbidden / github_unavailable. The access token is decrypted only for the request to api.github.com; it is never returned or logged.

Files: backend/src/lib/githubRepos.js, backend/src/routes/githubRepository.js, the repository field in backend/src/models/GithubConnection.js, the status fields in backend/src/routes/github.js, frontend/src/components/GithubRepositoryPicker.jsx, the repository part of frontend/src/components/GithubIntegrationPanel.jsx, githubApi.repositories / githubApi.selectRepository in frontend/src/api.js.

Tests: backend/test/githubRepos.test.js (GitHub client and Active rule, no database), backend/test/githubRepository.test.js (API: acceptance criterion, Owner only, validation, persistence, GitHub errors, re-authorization; GitHub is faked), frontend/src/components/GithubRepository.test.jsx.

Sprint 4 — M3: Manage the GitHub connection (US-33, FR-35)

Owner/Admin can view status, disconnect and re-authorize. After disconnect, new events stop coming in but old feed entries stay.

Builds on M1 (US-31 OAuth, encrypted token) and M2 (US-32 repository, Active rule). It reuses M1's OAuth flow and M2's status function rather than adding new ones.

What the Owner and Admins can do (GitHub panel on the project page):

Action What happens
View Repository name, "Connection status: …" and the badge. States are M2's not_connected / unavailable / needs_repository / active, plus disconnected, needs_reauthorization (GitHub rejected the token, or it cannot be decrypted), repository_unavailable (the account can no longer read the repository) and unverified (re-authorized, but the repository could not be checked yet).
Check connection POST /api/projects/:id/github/verify. Checks the token (GET /user) and read access to the connected repository (GET /repositories/{id}). A good token alone never makes the connection Active. If GitHub is rate-limited or down, the request returns 503/502 and nothing changes (NFR-11b).
Disconnect Confirmed in a dialog, then POST /api/projects/:id/github/disconnect with { confirm: true }. This is a soft disconnect:
• status becomes disconnected and the encrypted token is deleted (and revoked on GitHub when possible).
• Pending Connect / Re-authorize attempts are cancelled.
• Project.githubRepo is cleared if it still names the repository.
• One project.updated feed entry is written.
• The GitHub account and repository are kept so re-authorizing can restore them.
• No Activity document is ever deleted.
Re-authorize The same POST /api/projects/:id/github/authorize as M1. The first connection stays Owner only. Once a record exists (connected, expired or disconnected), the Owner or an Admin may run it again.
• The state stores purpose: 'reauthorize' and the callback re-checks the role for that purpose.
• The callback clears the disconnected / needs-re-authorization flags and checks the kept repository with the new token, then redirects with ?github=reauthorized.
• Same GitHub account and the repository is readable: Active again. A different account: needs_repository (M2's rule).

Event gate — interface with M4 (US-34). backend/src/lib/githubEventGate.js:

projectsAcceptingGithubEvents(githubRepositoryId) // -> [projectId], [] after a disconnect
canRecordGithubEvent(projectId, githubRepositoryId?) // -> boolean

How it is wired into M4's webhook pipeline (US-34, already on main):

  1. activeProjectsFor() in routes/githubWebhook.js keeps M2's Active rule and also applies the US-33 gate (githubEventsAccepted()). A delivery for a disconnected project is recorded as ignored, not retried.
  2. logActivity() itself refuses every github.* event for a project whose connection does not accept events. So nothing can add a GitHub entry after a disconnect, even on another code path. meta.repositoryId, when present, must be the connected repository.
  3. Disconnect calls M4's removeProjectWebhook() with the token before it is revoked, so the hook is taken off GitHub (unless another project still uses the repository), and clears webhook on the connection.
  4. Re-authorize / reconnect sets the webhook up again with M4's setupProjectWebhook() once the kept repository is readable and has no active hook on it.
  5. No Activity document is ever deleted, so earlier GitHub entries stay in the feed.

Known limit: removeProjectWebhook() keeps a hook while any other project's connection names the same repository, including a disconnected one, so in that rare case the hook stays on GitHub. The gate still drops its deliveries.

Who: Owner and Admin view, check, disconnect and re-authorize. Contributors, Viewers and visitors get 403. Choosing the repository stays Owner only (US-32).

Token safety (NFR-4): unchanged from M1. The token is decrypted only for the GitHub call or the revoke request. It is never returned or logged, and it is deleted on disconnect.

Files: backend/src/lib/githubConnection.js (rules: states, roles per OAuth purpose, verification, revoke; no database), backend/src/lib/githubEventGate.js. Marked additions in:

  • routes/github.js: status body, Owner/Admin authorize + callback, disconnect, verify
  • routes/githubRepository.js: disconnected → 409 github_disconnected, 401 / unreadable token recorded, shared status body
  • models/GithubConnection.js: status, disconnectedAt/By, needsReauthorization, reauthorizationReason, lastCheckedAt, repositoryCheck, index on repository.githubId
  • models/GithubOAuthState.js: purpose
  • lib/activity.js: gate for github.*
  • routes/githubWebhook.js (M4): activeProjectsFor() also applies the US-33 gate
  • lib/permissions.js: canManageGithubConnection

Frontend: components/GithubIntegrationPanel.jsx, githubApi.disconnect / githubApi.verify in api.js, styles.css.

Tests:

  • backend/test/githubConnection.test.js: rules, no database.
  • backend/test/githubConnectionManagement.test.js: API, covering all three acceptance criteria, requirement 5, roles, in-flight OAuth after a disconnect, rate limits, and no secrets in responses or logs. GitHub is faked.
  • frontend/src/components/GithubConnectionManagement.test.jsx.
  • One assertion in backend/test/githubOAuth.test.js: M1's exact GET body now includes the US-33 fields (and M4's webhook).

Sprint 4 — M4: GitHub events in the feed (US-34, FR-12, NFR-11a, NFR-11b)

When a repository is connected (US-32) and a commit is pushed, or an issue or pull request is opened or updated, a feed entry with the event type, title, GitHub author and an "Open on GitHub" link appears in the project's Activity feed. It is saved and pushed live over socket.io a moment after GitHub's delivery arrives, well within 60 seconds.

Setup (once per environment)

Add to backend/.env only:

GITHUB_WEBHOOK_SECRET=...   # at least 20 characters: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
GITHUB_WEBHOOK_URL=https://<public API host>/api/github/webhook

On Render this is the API's public URL + /api/github/webhook. GitHub cannot reach localhost, so for local testing forward deliveries with one of these:

  • smee.io: open https://smee.io, click Start a new channel, set GITHUB_WEBHOOK_URL to the channel URL, then run npx smee-client --url <channel URL> --target http://localhost:5000/api/github/webhook.
  • ngrok: ngrok http 5000, then set GITHUB_WEBHOOK_URL=https://<id>.ngrok-free.app/api/github/webhook.

Restart the API, then (as the Owner) choose the repository again in the GitHub panel. That creates the webhook. GitHub's first delivery is a ping, which shows under Recent deliveries.

How the webhook gets created

When the Owner chooses a repository (PUT /api/projects/:id/github/repository, M2), the API also calls POST /repos/{owner}/{repo}/hooks with the events push, issues and pull_request, content_type: json and the shared secret. If a hook with our URL already exists (another project connected the same repository, or an earlier attempt), it is reused and its secret/events refreshed. Changing to another repository removes our hook from the old one, unless another project still uses that repository. Choosing the repository never fails because of the webhook. The outcome is saved on GithubConnection.webhook, and the panel shows it:

webhook.status Panel shows
active Webhook active: commits, issues and pull requests appear in the feed
not_admin not set up: the authorized GitHub account is not an admin of the repository (GitHub only lets admins add webhooks)
not_configured not set up: GITHUB_WEBHOOK_SECRET / GITHUB_WEBHOOK_URL missing on the server
failed not set up: GitHub unreachable, rate limited or the authorization was revoked. Choose the repository again to retry

For US-33 (disconnect, M3): call removeProjectWebhook({ connection, accessToken }) from backend/src/lib/githubWebhookSetup.js before deleting the GithubConnection (load it with .select('+encryptedToken') and decrypt the token). It never throws. Even without it, a disconnected project gets no new entries, because the endpoint only adds entries for projects whose connection is still Active. Old entries are never touched.

Receiving a delivery: POST /api/github/webhook

Step What happens
1 Mounted before express.json() and the /api rate limiter (it has its own: 300/min), using express.raw(), so the HMAC is computed on the exact bytes GitHub signed.
2 503 if the webhook settings are missing. X-Hub-Signature-256 is verified first (HMAC-SHA256 with GITHUB_WEBHOOK_SECRET, crypto.timingSafeEqual). Missing or wrong → 401, nothing stored.
3 400 for missing X-GitHub-Event / X-GitHub-Delivery or a body that is not JSON.
4 The delivery is recorded in WebhookDelivery (metadata only). deliveryId is unique: the same delivery again → 200 { duplicate: true }, not processed twice.
5 ping → 200. Everything else → 202 straight away (GitHub waits 10 s at most), then processed in the background.
6 Every project whose GitHub connection is Active for repository.id gets the mapped entries through logActivity() (saved + pushed live). Each entry first claims a GithubFeedKey (unique per project + event), so the same commit or issue update never shows twice, even under a new delivery ID.

Event mapping (backend/src/lib/githubEvents.js)

GitHub event Actions Feed type Example sentence
push each new commit on a branch (max 20 per push; branch deletes and tags skipped) github.commit octocat pushed commit "Fix login bug" to main
issues opened, edited, closed, reopened github.issue riya-gh opened issue #12 "Login fails on Safari"
pull_request opened, edited, closed (shown as merged when merged), reopened, synchronize github.pull_request dev-gh merged pull request #7 "Add webhooks"

Other events and actions (labels, stars, ...) are recorded as ignored. actor is null (no FORGE user), and meta holds { author, title, url, action, repository, sha | number }. url is only ever an https://github.com/... link, and the browser checks that again before showing Open on GitHub (rel="noopener noreferrer").

Retry, logging and rate limits

  • Retry (NFR-11a): if processing fails (e.g. the database is briefly unavailable), it is retried 3 times with 1 s / 2 s / 4 s backoff. Only the final failure is logged, as one line GitHub webhook delivery <id> (<event>) failed after 4 attempts: <short reason>, and the delivery is marked failed with lastError. Logs never contain the payload, the webhook secret or a token.
  • Delivery log: WebhookDelivery keeps deliveryId, event, action, repoFullName, projects, status (received | processed | ignored | failed), attempts, entries, reason, lastError, receivedAt, processedAt for 30 days. Owner/Admin see the last 20 under Recent deliveries in the GitHub panel (GET /api/projects/:id/github/deliveries). To re-run a failed delivery, use Redeliver in GitHub → repository → Settings → Webhooks → Recent deliveries. GitHub sends it with a new delivery ID, and GithubFeedKey stops duplicates.
  • Rate limits (NFR-11b): receiving webhooks makes no GitHub API calls. Only creating/removing the hook does. On a rate-limit answer (403/429 with x-ratelimit-remaining: 0 or Retry-After) it waits once if the reset is ≤ 10 s, otherwise it stops and records failed ("rate limit reached") instead of retrying hard.
  • Known gaps: GitHub does not redeliver failed deliveries automatically, and the periodic reconciliation with the GitHub API that NFR-11a mentions is not built (not in the Sprint 4 plan for M4). A delivery whose processing was cut off by a server restart stays received. Use GitHub's Redeliver.

Files: backend/src/routes/githubWebhook.js, backend/src/lib/githubEvents.js, backend/src/lib/githubWebhooks.js, backend/src/lib/githubWebhookSetup.js, backend/src/models/WebhookDelivery.js, backend/src/models/GithubFeedKey.js, the webhook field in backend/src/models/GithubConnection.js, the hook call in backend/src/routes/githubRepository.js, webhook in GET /api/projects/:id/github (routes/github.js), mounting in backend/src/app.js, frontend/src/components/GithubWebhookStatus.jsx, the link in frontend/src/components/ActivityPanel.jsx + activityGithubLink() in activityDisplay.js, githubApi.deliveries in frontend/src/api.js.

Tests: backend/test/githubEvents.test.js (event mapping, no database), backend/test/githubWebhooks.test.js (signature, retry/backoff, rate limits, hook create/reuse/delete; GitHub faked; no database), backend/test/githubWebhook.test.js (API: acceptance criterion for commits, issues and PRs; bad/missing signature; duplicate and redelivered events; unconnected / disconnected / inactive repositories; retry + final failure log; hook creation on connect, not_admin, not_configured, GitHub failure, repository change; delivery log roles; no token or secret in responses or logs), frontend/src/components/GithubEvents.test.jsx.

Sprint 4 — M7: project dashboard (US-39, FR-15)

A Dashboard tab on the project page (/project/<id>?tab=dashboard) shows, from live database data:

  • Milestone progress — one progress bar per milestone: Done tasks ÷ tasks attached to it, rounded (a milestone with no tasks is 0%). Same calculation as the Milestones tab (both use lib/projectDashboard.js).
  • Open tasks — the number of tasks that are not Done (To do + In progress + Blocked), with the per-status breakdown.
  • Due in the next 7 days — open tasks due today up to and including 7 days ahead, soonest first, with title, due date, status, assignee and milestone. Click one to open it in the Tasks tab.

Due dates are calendar days stored as midnight UTC (see Sprint 3 — M5), so "today" is the UTC day. The 7-day window starts exactly where isOverdue() stops, so a dated open task is always either overdue (project health, US-40) or upcoming — never both. Rules and unit tests: backend/src/lib/dashboardRules.js, backend/test/dashboardRules.test.js. The tab reloads after your own task/milestone changes and when teammates' task changes arrive live.

Call Who Response
GET /api/projects/:id/dashboard Signed-in project members (any role). 401 not signed in, 400 bad ID, 404 no project, 403 non-members, even for a Public project { project: { _id, name, status }, membershipRole, milestones: { total, completed, items: [{ _id, title, description, dueDate, taskCount, doneCount, progress, completed }] }, tasks: { total, open, done, byStatus }, dueSoon: { days: 7, from, to, total, limit: 50, tasks: [{ _id, title, status, priority, dueDate, assignee, milestone }] }, generatedAt }

from / to are YYYY-MM-DD. dueSoon.tasks holds at most 50 tasks; dueSoon.total is the full count. Overdue and blocked lists and the activity level are project health (US-40, M8), not this endpoint.

Files: backend/src/routes/dashboard.js, backend/src/lib/projectDashboard.js, backend/src/lib/dashboardRules.js (milestone stats moved here from routes/milestones.js; GET /api/tasks/upcoming now uses the same 7-day window), frontend/src/components/ProjectDashboard.jsx, dashboardApi in frontend/src/api.js, the Dashboard tab in frontend/src/pages/ProjectPage.jsx.

Tests: backend/test/dashboardRules.test.js (rules, no database), backend/test/projectDashboard.test.js (API: auth, membership, calculations, 7-day boundaries with a fixed clock, empty project), frontend/src/components/ProjectDashboard.test.jsx, frontend/src/pages/ProjectPage.tabs.test.js, e2e/tests/project-dashboard.spec.js.

Sprint 4 — M8: complete / reopen a project and project health (US-15, US-40, FR-3, FR-21, FR-22)

Mark complete / reopen (US-15). On a project page the Owner sees Mark as completed (or Reopen project once it is Completed) next to Add task, with a confirmation dialog. Completing sets status: 'Completed', records completedAt and remembers the active status the project had (reopenStatus); reopening restores that status (Planning or In progress; "Active" in the user story means any status that is not Completed, which are the two this app already uses). Both actions appear in the activity feed (project.completed, project.reopened). Admins can still edit other details, but PATCH /api/projects/:id refuses to move a project into or out of Completed (403 for non-Owners, 400 for the Owner, who is pointed at the actions). Archived (FR-3) is not built: it is not in US-15 and the sprint plan says to confirm its scope first.

Portfolio-ready (FR-21). GET /api/projects?status=Completed returns my Completed projects (index on members.user, status, completedAt); without ?status the list is unchanged.

Project health (US-40). Under the dashboard (Dashboard tab), Owners and Admins see:

  • Overdue tasks: past their due day (UTC) and not Done — the same isOverdue() the dashboard uses, so a task is never both overdue and "due in the next 7 days".
  • Blocked tasks: status Blocked or label Blocked (any letter case); Done tasks are left out.
  • Activity level: activity feed events in the last 7 days (rolling): Low < 5, Medium 5–20, High > 20.
Endpoint Who Response
POST /api/projects/:id/complete Owner { project }; 403 others, 409 already Completed
POST /api/projects/:id/reopen Owner { project }; 403 others, 409 not Completed
GET /api/projects?status=Completed Signed in { projects } (my projects with that status); 400 unknown status
GET /api/projects/:id/health Owner, Admin (403 Contributor, Viewer, non-members) { project, membershipRole, overdue: { total, limit: 50, tasks: [{ _id, title, status, label, priority, dueDate, daysOverdue, assignee }] }, blocked: { total, limit, tasks }, activity: { days: 7, since, count, level, thresholds: { mediumFrom: 5, highAbove: 20 } }, generatedAt }

Files: backend/src/lib/projectCompletion.js, backend/src/lib/healthRules.js, backend/src/lib/projectHealth.js, backend/src/routes/health.js; small marked additions in routes/projects.js, models/Project.js, models/Activity.js, lib/permissions.js, app.js. Frontend: components/ProjectCompletion.jsx, components/ProjectHealth.jsx; marked additions in pages/ProjectPage.jsx, api.js (projectCompletionApi, healthApi), roles.js, styles.css.

Tests: backend/test/projectCompletion.test.js, backend/test/projectHealth.test.js, frontend/src/components/ProjectCompletion.test.jsx, frontend/src/components/ProjectHealth.test.jsx.

Sprint 4 — M5: upload a project file (US-37, FR-14a, NFR-8)

The project page has a Files tab (?tab=files). Every project member sees the project's files (name, size, who uploaded it, when) and can download any of them. Owners, Admins and Contributors also get Upload file; Viewers see a note that they can download but not upload.

10 MB limit (US-37 AC2, NFR-8). One file per upload, at most 10 MB (exactly 10 MB is allowed). The server enforces it: a bigger file answers 413 with This file is larger than the 10 MB limit. Files must be 10 MB or smaller. and nothing is stored. The Files tab checks the size before sending and shows the same message, so a too-big file is not even uploaded.

Persistent storage (NFR-8). File bytes are stored in MongoDB GridFS (bucket projectFiles, i.e. the projectFiles.files / projectFiles.chunks collections), in the same database as the rest of the app — MongoDB Atlas when deployed. Nothing is written to the server's disk, so files survive a Render redeploy. No new service, account or environment variable is needed. (Profile photos still use UPLOAD_DIR on disk; moving them is not part of US-37.)

Call Who Notes
GET /api/projects/:id/files Members (all roles) { files, membershipRole, canUpload, maxBytes }, newest first
POST /api/projects/:id/files Owner, Admin, Contributor multipart/form-data, one file in the field file → 201 { file }. 413 { message, code: 'file_too_large', maxBytes } over 10 MB; 400 no file / empty file / extra fields or files; 403 Viewer
GET /api/projects/:id/files/:fileId/download Members (all roles) The bytes, as an attachment with the file's name. 404 unknown file or a file of another project

Each file: { _id, project, name, size, contentType, uploadedBy { _id, name, username, avatarUrl } | null, createdAt, downloadUrl }. Non-members get 403 even for a Public project; signed-out requests get 401.

Safety. Membership and role are checked before the upload is read. File names are cleaned (no folders, no control characters, max 200 characters; non-English names are kept). Downloads are always application/octet-stream + Content-Disposition: attachment + nosniff, so an uploaded HTML or SVG file can never run as a page on the API's address. The GridFS id is never sent to the browser. Downloads need the access token, so the Files tab fetches them through fileApi.download() (same token refresh as every other call) and hands the result to the browser as a normal download.

Activity Feed. Every upload is logged as file.uploaded (shown as "File uploaded").

Files: backend/src/lib/projectFiles.js (limit, messages, name cleaning, GridFS helpers), backend/src/models/ProjectFile.js, backend/src/routes/projectFiles.js; small marked additions in lib/permissions.js (canUploadFiles), models/Activity.js (file.uploaded, target file), app.js. Frontend: components/FilesPanel.jsx; marked additions in api.js (fileApi), roles.js (canUploadFiles), components/activityDisplay.js, pages/ProjectPage.jsx (Files tab).

Tests: backend/test/projectFiles.test.js (both acceptance criteria incl. exactly 10 MB and 10 MB + 1 byte, GridFS storage, every role, non-members, other project's file, bad input, name cleaning), frontend/src/components/FilesPanel.test.jsx, frontend/src/pages/ProjectPage.tabs.test.js.

Sprint 4 — M6: Markdown documentation pages (US-38, FR-14b)

Scope note: US-38 is Should in the user stories and Could in the corrected FR-14b; it is part of the Sprint 4 plan (M6).

The project page has a Docs tab (?tab=docs, and ?tab=docs&doc=<id> opens one page). Every project member sees the list of pages (title, short preview, who created / last edited it, when) and can open any page; it is shown as formatted text. Owners, Admins and Contributors also get New page and Edit: a title, a Markdown box with Write / Preview, and a live 1,234 / 10,000 counter that turns red over the limit. Viewers only read.

10,000-character limit. The content may be at most 10,000 characters (exactly 10,000 is allowed; Windows \r\n line endings count as one). The server enforces it: longer content answers 400 Content must be 10,000 characters or fewer. and nothing is saved. The Docs tab blocks saving over the limit with the same message.

Call Who Notes
GET /api/projects/:id/docs Members (all roles) { docs }, most recently updated first; each has title, preview, length, createdBy, updatedBy, createdAt, updatedAt (no full content)
GET /api/projects/:id/docs/:docId Members (all roles) { doc } with the same fields plus content (Markdown text)
POST /api/projects/:id/docs Owner, Admin, Contributor { title, content } → 201 { doc }
PATCH /api/projects/:id/docs/:docId Owner, Admin, Contributor { title?, content? } → { doc }; updatedBy is set when something changed

Title: required, up to 120 characters. Content: required Markdown. 400 missing / blank / too long (nothing is saved, also on edit), 403 Viewers on POST/PATCH and non-members (even for a Public project), 404 a page of another project, 400 bad ID.

Safe rendering. The server stores the Markdown as plain text and never turns it into HTML. The browser renders it with frontend/src/components/markdown.jsx, a small renderer that builds React elements only (no dangerouslySetInnerHTML, no new npm package). So any HTML in a page (<script>, <img onerror=…>, <iframe> …) is shown as text and never runs, and links are made only for http:, https: and mailto: (opened in a new tab with rel="noopener noreferrer"); javascript:, data: and other addresses stay plain text. Supported: #–### headings, paragraphs and line breaks, **bold**, *italic*, `code`, fenced code blocks, - / 1. lists, > quotes, --- and [links](https://…).

Files: backend/src/lib/docs.js (rules, limit, messages, preview), backend/src/models/DocPage.js, backend/src/routes/docs.js; small marked additions in lib/permissions.js (canWriteDocs) and app.js. Frontend: components/DocsPanel.jsx, components/markdown.jsx; marked additions in api.js (docApi), roles.js (canWriteDocs), pages/ProjectPage.jsx (Docs tab, ?doc=).

Tests: backend/test/docRules.test.js (rules, exactly 10,000 vs 10,001, \r\n, preview; no database), backend/test/docs.test.js (every acceptance criterion, every role, non-members, other project's page, bad input, edits that change nothing on error, list without content), frontend/src/components/markdown.test.jsx (each Markdown feature and the XSS cases), frontend/src/components/DocsPanel.test.jsx (list, read, ?doc=, create, edit, counter and limit, Viewer, server errors), frontend/src/pages/ProjectPage.tabs.test.js (Docs tab).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages