WorkNest is a multi-tenant workspace for projects, tasks, chat and meetings. Teams create an organization, invite people with roles, and manage work with plan limits, organization settings and timezone-aware dates.
Live Application: worknest-web-gilt.vercel.app · Repository: github.com/sprahasingh/WorkNest
Built with: React 19, TypeScript, Node.js, Express 5, MongoDB, Mongoose and Socket.IO.
Engineering highlights
- Fail-closed tenancy: one central Mongoose plugin scopes org-owned data; org-owned queries without tenant context fail unless explicitly marked as cross-org.
- Server-side authorization: RBAC and task ownership are enforced by the API.
- Concurrency-safe limits: atomic updates and transactions protect seats, projects and plan limits.
- Session security: refresh tokens rotate, and reuse detection revokes the token chain.
- Transactional audit: records are written with the change, so rolled-back actions leave no entry.
- Plan lifecycle: paid plans expire to Free, then follow a 10-day grace and safe archival process.
Start with the screenshots, then explore the architecture, local setup, testing and known limitations.
| Section | What you'll find |
|---|---|
| Screenshots | Product views on desktop and phone |
| Why I Built This · Features | Project goals and product behavior |
| Tech Stack · Architecture | Tools, request flow, tenancy, permissions and concurrency |
| Plans · Paying for a plan | Limits, expiry, grace period and payment flow |
| API Example · Local Setup | Error response and development setup |
| Testing · Project Structure | Verification commands and code map |
| Known Limitations | Current trade-offs |
Everything below is from a demo organization called Sunshine, so none of it is real data. Each picture also has a dark version, and the live site shows more of every page in its product tour and "How to use" guide.
Admins and managers get two views. Tasks shows open, completed, created and overdue tasks with charts for status, priority and workload. Projects shows project stages and where the open work is. Both follow a period you choose (7 to 90 days, all time or custom dates).
Tasks: overview
|
Projects: overview
|
Projects hold the work. Every task is a card on the project board, grouped by status, with its priority, due date and assignees.
Project list
|
Project board
|
Managers can ask for an update on one task or a whole project. Assignees answer right on the task, and the project feed collects everything in one place.
Updates on a task
|
Project updates
|
Private chats and groups that arrive live, with replies, reactions, @mentions, files and search. Only the people in a chat can read it.
Group chat
|
Search
|
Schedule with a join link and an agenda, repeat a meeting, and see the month at a glance. Invitees reply Accept, Maybe or Decline, or suggest another time.
A meeting
|
Calendar
|
Roles, invites, plans with their limits, and an audit log of every important change.
Members
|
Invites
|
Plans
|
Audit log
|
Every page adapts to small screens. The menu button at the top left switches between pages, and Messages shows the chat list first, then the chat you open. Pulling down from the top of a page refreshes it.
Dashboard
|
Project board
|
Messages
|
Meetings
|
To change or add a picture, see the screenshots guide.
I wanted a project that went further than CRUD and made me deal with the parts of a B2B product that are easy to get wrong. That meant keeping each organization's data apart, checking permissions on the server, handling two people grabbing the last free seat at the same time, and keeping a record of who changed what. WorkNest is built around those four problems.
One Mongoose plugin scopes every query, write and aggregation to the current org. The current org lives in AsyncLocalStorage for the length of the request, and a query with no org context fails instead of leaking data.
Email verification is required before an account or its first organization is created. Account protections include:
- Sign-up asks for the password twice. Password fields have a Show button, and verification, password-reset and email-change links can be resent after a minute, with a reminder to check spam.
- The browser that started sign-up receives a secret that lets it sign in after the email link is opened on any device. This supports sign-up on one device and confirmation on another.
- The form suggests corrections for likely provider typos such as
gmial.comandgmail.con. Common passwords, repeated or sequential patterns, and passwords containing the person's name or email are refused with an explanation. A strength bar and info hint explain the rules, which the server enforces. - Logging in before email confirmation gives a specific message and offers a new link instead of reporting an invalid password. Addresses in
EMAIL_VERIFICATION_BYPASS_EMAILSbypass verification and these password rules for shared demo logins. - Password reset email requests are limited to one per minute per account. Settings lists signed-in devices and can sign out one device or all devices.
- Email changes require the current password and confirmation at the new address. Access JWTs last 15 minutes; refresh tokens rotate and only their hashes are stored. Reuse of an old refresh token signs out the entire session chain.
- People can soft-delete their own account. The audit history remains, and the email becomes available for a new sign-up.
Admin, manager and member, checked on the server for every request. The UI hides what a role can't use, but the API is what actually says no.
Free, Pro and Premium plans with limits on seats, active projects and active tasks per project (see Plans). Admins can set the organization name and time zone in Settings. Pro and Premium are paid monthly or yearly through Razorpay (in test mode, so no real money moves), confirmed on the server before the plan changes and written to the audit log; see Paying for a plan. Plans do not renew automatically. When paid time runs out, the workspace returns to Free. A paid plan runs to its end date and cannot be switched to a lower plan or cancelled early.
Admins get a one-time invite link (only a hash of the token is stored), and a seat is reserved safely even if several invites go out at once. If the person already has an account, the invitation also shows up in their app under the bell and on their organizations page, where they can join or decline. Inviting an email that already has a pending invite offers a fresh link instead of a vague error.
Removing someone takes them out of that org only. Their account and other orgs stay as they are, their tasks in that org become unassigned, and they can be invited back later. If it happens while they're using the org, they're sent to their organizations page with a short note.
Project cards show active and total task counts, priority and due date. Project lists and lifecycle behavior include:
- Projects with tasks appear under Completed when all their tasks are done. Lists include Active, Completed, Archived and Bin, with sorting by created date, due date, priority and relevant lifecycle dates. Card actions are under More.
- Completed, archived and binned projects do not use an active project slot. Unarchiving or restoring a project with unfinished work, or reopening work in a completed project, uses a slot again.
- Projects in the Bin can be restored for 30 days. After that, the project and its tasks are permanently deleted.
Task boards have To do, In progress and Done columns, cursor pagination, sorting, and filters for priority, assignee and My tasks.
- Tasks can have several assignees, a priority and a due date. Status changes move tasks between Active and Completed; Archived and Bin have separate lists.
- More on a task card provides the available edit, archive, unarchive, restore and delete actions.
- Each plan limits active tasks per project. Done, archived and binned tasks do not count toward that limit. Reopening or restoring an active task uses capacity again.
Admins and managers can request an update on a task or project. Assignees can post updates or ask questions, and conversation participants can reply to a specific message.
- By default, a message goes to all assignees and the creator. An @mention sends it only to those people, and then only they and the author can reply.
- Existing thread participants are notified about replies. The person who asked a question can mark its answer, and the person who requested an update can remind people who have not replied, once an hour.
- Opening task or project updates marks notifications as read. A notification opens at the relevant message.
Anyone can mute a busy project or task. Mentions, replies to you, update requests sent to you and reminders about your own tasks still come through.
Tasks by status and priority, top assignees, overdue tasks, and plan usage for admins and managers. Project usage shows active projects only. The charts cover the last 7 to 90 days, all time or a custom date range, grouped by week or month for long spans, and counts days in your own time zone. Chart numbers show on double-click or double-tap, so a stray tap doesn't pop them up.
Anyone in an org can start one-to-one or group chats with other members. Chats are visible only to their participants, including when org admins are not in the conversation. Messages arrive over Socket.IO with typing indicators, online dots, unread counts and "Seen" receipts.
- Messages support replies, emoji reactions and @mentions. You can edit your own message for 10 minutes, or delete it for everyone for 30 minutes; other participants then see a deletion note. Delete for me hides an individual message from your view at any time.
- Press and hold a message (or right-click it) to open its options. Group admins can rename a group and add or remove people; anyone can leave.
- Chats can be muted (mentions still come through), searched, or used to start a meeting. Press and hold a chat (or right-click it) to mute, mark it read or unread, view group details, or delete it. Deleting a conversation clears it only for you; a new message brings it back with only the new messages.
- The tab title and icon show the unread count. A soft sound is optional, and desktop notifications are available on computers. Notifications are not offered on phones or tablets because this project does not use push notifications.
- If the live connection drops, the app falls back to refreshing every few seconds.
Images and files are uploaded straight from the browser to Cloudinary as private files. There is no public link: the API checks that you are still in the organization and the conversation every time you open one, and the links it hands out stop working after about 10 to 20 minutes. Files are limited to 10 MB, checked on the server as well as in the browser. Drag them onto a chat or paste them, and click a picture to see it larger. The feature switches itself off if Cloudinary isn't set up.
Admins can choose how long messages are kept (forever by default, or 1 year, 6 months or 90 days). Older messages and their files are deleted automatically once a limit is set. The limit applies to everyone, and every member can see it in Settings and at the bottom of the chat list.
Meetings include a time, agenda, location and join link (paste one or create a free Jitsi room). Only the organizer and invitees can see a meeting.
- Meetings can repeat daily, weekly or monthly, and can link to a project or task. Linked meetings appear in the task's Meetings tab.
- Invitees can Accept, Maybe, Decline or suggest another time for the organizer to accept or turn down. People are notified when a meeting is created, changed or cancelled, and get a reminder 15 minutes before it starts. Changing the time asks invitees to reply again.
- The app has upcoming and past lists, a month calendar, a double-booking warning, "Meet now" for an instant call, and an "Add to calendar" download.
If a member is removed or leaves an org, they're taken out of its group chats and meeting invites. If they organize upcoming meetings, the admin removing them (or they, when leaving) picks between cancelling those meetings and handing them to someone who stays. One to one chats stay for the other person but can't be continued.
Every change to orgs, members, invites, projects, tasks and plans is written in the same transaction as the change, so an action that rolls back never leaves an entry behind. The UI shows plain-language rows with filters.
WorkNest includes light and dark themes, onboarding, in-product help and a landing page.
- The role-aware main onboarding tour has detailed page tours. Page tours highlight relevant controls, adapt to the screen, and let people skip a page tour, return to the main tour or skip the whole tour. Replay the main tour from "Show the tour" in the sidebar.
- The "How to use" guide includes screenshots and the full permission table. The landing page has a product tour and a "Go to your workspace" link to return to the last-used organization.
- The "Send feedback" form emails the author. Pages work on phones, where navigation uses a menu and Messages opens a chat after the conversation list.
Backend: Node.js, TypeScript (strict), Express 5, MongoDB Atlas, Mongoose, Zod, JWT and bcrypt, Socket.IO, Vitest and Supertest
Frontend: React 19, Vite, TypeScript (strict), React Router, TanStack Query, React Hook Form with Zod, Axios, Socket.IO client, Tailwind CSS v4, Recharts
Here's what happens to a request to an org endpoint like PATCH /api/orgs/:orgId/tasks/:taskId before it touches the database:
flowchart TD
A["Client request<br/>/api/orgs/:orgId/..."] --> B["authenticate<br/>verify JWT access token"]
B --> C["resolveTenant<br/>validate :orgId, look up Membership"]
C --> D{"Membership found?"}
D -->|no| E["404 Not Found"]
D -->|yes| F["runWithTenant<br/>AsyncLocalStorage context"]
F --> G["requirePermission<br/>RBAC check"]
G -->|denied| H["403 Forbidden"]
G -->|allowed| I["Route handler<br/>controller / service"]
I --> J["Mongoose tenant plugin<br/>adds tenantId to the<br/>query, write, or aggregate"]
J --> K[("MongoDB<br/>tenant-scoped read/write")]
K --> L["Response"]
If you aren't a member of an org, you get a 404 whether or not that org exists. From the outside, "not yours" and "doesn't exist" look the same, so the API never tells a stranger that an org is there.
All organizations share the same database and collections. Each document carries a tenantId, and one Mongoose plugin on every org-owned model does the enforcing:
- Query hooks (
find,updateOne,deleteMany,aggregateand the rest) add the current org's ID to the filter. - Document hooks stamp
tenantIdon new documents and refuse to save one that belongs to a different org. tenantIdisimmutablein every schema, so an update can't move a document to another org.- A query that runs without an org context throws an error instead of quietly returning every org's data, unless it's explicitly marked as cross-org.
The org ID comes from the URL (/api/orgs/:orgId/...), gets checked against your membership, and is kept in an AsyncLocalStorage context for the rest of the request. That way it doesn't have to be passed through every function by hand.
| Permission | admin | manager | member |
|---|---|---|---|
| org:read | ✓ | ✓ | ✓ |
| org:update, plan:change | ✓ | – | – |
| member:read | ✓ | ✓ | ✓ |
| member:manage | ✓ | – | – |
| invite:manage | ✓ | – | – |
| project:read | ✓ | ✓ | ✓ |
| project:write | ✓ | ✓ | – |
| task:read, task:create | ✓ | ✓ | ✓ |
| task:update:any, task:delete, task:assign | ✓ | ✓ | – |
| task:update:own | ✓ | ✓ | ✓ |
| task:request-update | ✓ | ✓ | – |
| task:comment | ✓ | ✓ | ✓ |
| audit:read | ✓ | – | – |
| dashboard:read | ✓ | ✓ | – |
Roles and ownership are two separate checks. The role decides whether you can do something at all, and ownership decides whether you can do it to a particular task. A member can edit and move tasks they're assigned to, but can't reassign them, and doesn't see tasks that are only assigned to admins or managers.
An org can never end up without an admin. The check runs as a conditional update inside the same transaction as the role change or removal, so two admins demoting each other at the same moment can't both succeed.
Seat and project limits are enforced with atomic MongoDB updates ($expr conditions) inside transactions. That closes the usual gap between "check if there's room" and "take the spot" when two requests go for the last seat or project slot at once.
Plan lifecycle at a glance
- Pro and Premium are prepaid for one month or one year. Plans do not renew automatically.
- When paid time ends, the organization returns to Free and enters a 10-day grace period.
- If usage still exceeds Free limits after grace, excess projects and tasks are force-archived, not deleted.
- After upgrading, eligible force-archived projects and tasks can be restored, subject to the new plan's capacity. Manually archived resources are excluded.
| Plan | Per month | Per year | Seats | Active projects | Active tasks per project |
|---|---|---|---|---|---|
| Free | ₹0 | ₹0 | 5 | 3 | 10 |
| Pro | ₹449 | ₹4,499 | 30 | 25 | 50 |
| Premium | ₹1,149 | ₹11,499 | 100 | 50 | Unlimited |
Only active projects use a project slot. Completed, archived, and binned projects do not count. If a project becomes active again, it uses a slot and may need to wait until one is free.
Each project also has a separate active-task limit. Done, archived, and binned tasks do not count toward it. Reopening or restoring an active task uses a task slot again. The same rule is used everywhere the limit is checked, including the downgrade check and the check after a plan ends: open tasks in an active project count, and open tasks in an archived or binned project don't, because that project isn't in use. Restoring a project is what checks its open tasks against the plan.
Pro and Premium are bought for one month or one year at a time through Razorpay. Nothing renews by itself, so you pay again to renew; renewing adds a period on top of the current end date. Going from Pro to Premium on the same period costs the difference and keeps the end date. The app is meant to run with Razorpay test keys, so you can try the whole flow with fake payments and no real money moves.
A paid plan can't be switched to a lower one, or cancelled, before it ends, because that period is already paid for. Premium to Pro and any paid plan to Free are refused (409 PLAN_ACTIVE_UNTIL_END), and Settings shows a popup saying so: the plan stays active until it ends, you move to Free by yourself when it expires, and a smaller paid plan can be bought after that. Plans with no end date (set up before plans expired, or simulated without Razorpay keys) can still be changed, and a downgrade without a payment is still blocked while usage is over the smaller plan's limits.
When a paid plan ends, the workspace returns to Free on the next request or expiry sweep and gets a 10-day grace period. People can keep working within the rules below.
An orange banner shows how much time remains, which Free plan limits are exceeded, and how to renew.
During the grace period usage can't grow past the Free limits, while everything that reduces or maintains it keeps working:
- While the workspace is over any Free limit (more than 3 active projects, a project with more than 10 open tasks, or more than 5 seats), nothing new can be added anywhere: no new projects, no new tasks (not even in a project that is under its own limit), no invites, and nothing brought back from Archived or the bin. The server answers
403 PLAN_GRACE_RESTRICTED, and the New project, New task and Send invite buttons are disabled with a hint. - Once usage is back within the limits, adding is allowed again up to the limits: the usual "project limit reached" and "task limit reached" messages apply at 3 projects and 10 open tasks per project. Reopening a finished task is refused at the limit too.
- Editing, completing, archiving and deleting stay available, so people can bring usage down themselves. Open tasks can be archived (the Archive button on a task, not only on finished ones), and an archived task can come back from Archived only when its project has room for another open task.
- Restoring an archived project is refused if its open tasks wouldn't fit the plan's per-project limit; it works after upgrading.
- Seats: invites are refused while the workspace is over its seats, and nobody is ever removed automatically.
If the workspace is still over the Free limits when the 10 days are up, the extras are archived automatically (on the next request or in the 30 minute sweep, whichever comes first):
- Projects: only projects that use a plan slot count. The 3 most recently active are kept and the rest are force-archived. Projects archived manually are not selected by this rule.
- Tasks: in every project that remains active, the 10 most recently active open tasks are kept and the rest are force-archived. Tasks in a project that was already archived manually or by plan enforcement are left alone. Archived projects do not count toward the task limit.
- No pause while archiving runs. The archiving takes a short hold and is marked done only when it has finished, so requests that arrive meanwhile still follow the grace rules instead of seeing a paused workspace. If a workspace is marked done but is still over on projects or tasks, the next change archives the rest.
- What "recently active" means: the latest change to the project or task, or a comment or update on it. Ties are broken by id, so the same data always gives the same result.
- Nothing is deleted. Force-archived projects and tasks keep their data and are marked as archived because of the plan. After a paid upgrade, an admin is offered the Review archived projects prompt to select force-archived projects and tasks in active projects, with restore-all options for both. Restoring a project also restores its force-archived tasks automatically, up to the upgraded plan's per-project task limit. Tasks that do not fit remain Archived until capacity is available. Manually archived projects and tasks are excluded from this flow and remain archived.
- Seats are not touched. People are never removed automatically, so a workspace with more than 5 members stays paused (read, delete, archive and pay only, as described below) until members are removed or a plan is bought.
- Renewing clears the grace period and the notice.
If the workspace is over the limits and has no grace period left (seats, as above), it is paused: people can still look around and delete or archive projects and tasks (or remove members), and an admin can buy a plan, but nothing else can be changed until usage fits Free or a plan is bought. Workspaces that were on a paid plan before plans expired have no end date and are left alone. A workspace whose plan ended before the grace period existed has none left, so its extras are archived the first time it is checked.
Admins receive a notification, and an email when delivery is configured, 7 days before the plan ends and again 1 day before. Each reminder replaces the previous one, so there is only one renewal notice per admin.
When the plan ends, the notice explains the 10-day grace period and automatic archival. After grace enforcement, it says that excess projects and tasks were archived and points admins to the Review archived projects prompt. That prompt lets them choose eligible projects and force-archived tasks in active projects. Restoring a project restores its force-archived tasks automatically while task capacity allows; tasks that do not fit remain Archived. Manually archived resources are excluded.
Buying or renewing a plan removes the reminder. A dismissed notice is not sent again for the same step. These are written by the same 30-minute sweep, so they can be up to half an hour late.
Expiry doesn't wait for someone to open the app: a sweep runs every 30 minutes and moves ended plans back to Free. While a workspace is paused, the buttons that create or change things (new project, new task, schedule meeting, send invite, the save buttons in the forms, and sending a chat message) are disabled with a hint, and a red banner at the top says what is over the limit.
Accounts whose email is in EMAIL_VERIFICATION_BYPASS_EMAILS (and only those) get a "Test account" box under the plans in Settings, for admins. It moves the plan's end date, so expiry, the grace period, the reminders and the archiving can be tried in minutes:
- While on a paid plan: Ends in 6 days (the 7 day reminder), Ends in 20 hours (the 1 day reminder), Ends now (the plan expires and the workspace goes back to Free), or any date and time you pick.
- Once the plan has ended: Ended 5 days ago (inside the grace period), Ended 11 days ago (the grace period is over, so the extras are archived), or any date. Moving the date re-arms the archiving.
- Leave "Run the checks right away" ticked and the expiry, grace period and reminder checks for that workspace run straight after the date is set, instead of at the next 30 minute sweep.
Under the box it is POST /api/orgs/:orgId/billing/test-plan-dates with { planExpiresAt?, planExpiredAt?, run? } (ISO dates, null clears one). The server only accepts it from an admin whose own email is in EMAIL_VERIFICATION_BYPASS_EMAILS; for anyone else it answers 404, and the box never shows. It can't be used on a workspace on the wrong side of the date (the end date while on Free, or the ended date while on a paid plan), and every use is written to the audit log. Don't put real customers' addresses in that list.
How it works:
- An admin picks Monthly or Yearly and clicks a buy button in Settings, such as "Upgrade to Pro · ₹449" (every buy button shows its price, and the current plan has a "Renew" button). The API creates a Razorpay order for the exact amount and records it as a pending payment.
- Razorpay's checkout window opens in the browser. Card details go to Razorpay only and never touch this server.
- After payment, the browser sends back the order id, payment id and a signature. The API checks the signature with the secret key and only then moves the organization to the new plan, in a transaction that also writes an audit entry naming the payment.
- As a safety net, Razorpay also calls
POST /api/billing/webhookafter a payment (signed withRAZORPAY_WEBHOOK_SECRET), so the plan still upgrades if the browser closed first. The browser and the webhook can both arrive: only the first one to mark the payment as paid applies the plan.
Other rules: an organization can only confirm its own orders, only admins can start a payment, and while payments are switched on the plain POST /api/orgs/:orgId/plan call refuses upgrades with 402 PAYMENT_REQUIRED (downgrades still work). With no Razorpay keys, upgrades are simulated and free in development, but refused in production unless ALLOW_SIMULATED_UPGRADES=true, so a lost key can't turn every plan free.
To try it with test keys, set these on the API (the first two are in the Razorpay dashboard under API Keys, in test mode):
RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...
RAZORPAY_WEBHOOK_SECRET=... # optional, but recommended
For the webhook, add https://your-api.example.com/api/billing/webhook in the Razorpay dashboard under Webhooks, tick payment.captured and order.paid, and use the same secret you put in RAZORPAY_WEBHOOK_SECRET. In test mode, pay with the Indian test card Visa 4386 2894 0766 0153 (any future expiry, any CVV, any name) or the test UPI id success@razorpay.
Razorpay's test mode for India rejects most international test cards, such as 4111 1111 1111 1111, with "International cards are not supported". Their Test Cards page lists more. The (i) next to "Plan" in Settings gives the short version to the people using the app.
In test mode Settings also shows a small box with the test card number (one tap to copy) and a note that the expiry, CVV and name can be anything and that any 6 digits work if an OTP is asked for. The card number is also copied to the clipboard when you press Upgrade, since Razorpay's window covers the screen once it opens.
Every error comes back in the same shape, and the details array depends on the error code. Here's what you get when you try to downgrade while you're using more than the smaller plan allows:
POST /api/orgs/:orgId/plan
{ "plan": "free" }
409 Conflict
{
"error": {
"code": "PLAN_DOWNGRADE_BLOCKED",
"message": "Current usage exceeds the limits of the target plan",
"details": [
{
"seatsUsed": 6,
"projectCount": 4,
"projectsOverTaskLimit": 1,
"targetSeatLimit": 5,
"targetProjectLimit": 3,
"targetActiveTaskLimit": 10
}
]
}
}You'll need Node 24 (see .nvmrc) and a MongoDB Atlas cluster. The free M0 tier is fine, since it runs as a replica set and MongoDB transactions need one.
git clone https://github.com/sprahasingh/WorkNest.git
cd WorkNestBackend:
cd api
npm install
cp .env.example .env # fill in MONGODB_URI, JWT_ACCESS_SECRET, and email delivery settings
npm run dev # runs on http://localhost:4000Frontend (in a second terminal):
cd web
npm install
npm run dev # runs on http://localhost:5173 and proxies /api to localhost:4000Open http://localhost:5173 and register, or load the demo data first (below).
In development the live connection for Messages goes through the same Vite proxy, so there's nothing extra to set up.
For paid upgrades, add Razorpay test keys (see Paying for a plan). For file attachments, add CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET to api/.env. Without them, Messages works but the attach button is hidden. Files are uploaded as private, so no upload preset is needed.
When the frontend is on Vercel, its /api rewrite can't carry websockets. Set VITE_SOCKET_URL in Vercel to the API's address (for example https://your-api.onrender.com) and make sure CLIENT_ORIGIN on the API matches your Vercel URL exactly. If VITE_SOCKET_URL is left out, Messages still works, it just refreshes every few seconds instead of updating instantly.
Behind Vercel's /api rewrite, set TRUST_PROXY_HOPS=2 on the API so the rate limits see each visitor's own address rather than Vercel's. New registrations and email changes require verification email delivery. In-app feedback ("Send feedback") is emailed to the address in FEEDBACK_TO_EMAIL; without it the form shows a plain email address instead. For hosted deployments, configure Brevo in api/.env with BREVO_API_KEY and BREVO_FROM (a sender address verified in Brevo). Alternatively, configure SMTP_URL and SMTP_FROM for an SMTP provider.
cd api
npm run seedThis creates two demo organizations. Every account uses the password password123.
- Acme Corp (Free plan):
admin@acme.demo,manager@acme.demo,member@acme.demo - Globex Corporation (Pro plan):
admin@globex.demo
cd api
npm test # runs against an in-memory MongoDB replica set
npm run typecheck
npm run lintcd web
npm run build # includes a full TypeScript build
npm run lintcd e2e
npm ci && npx playwright install chromium
npm test # starts an in-memory MongoDB, the API and the web app, then drives a browserThe browser tests (e2e/) cover an upgrade through a stand-in for Razorpay's checkout window, sign-up on one device and confirming on another, the email typo hint, refusing common passwords, the "confirm your email" message at login and the signed-in devices list. They run in CI as their own job. Emails are caught in a file instead of being sent.
- tenant isolation, including failing closed when there's no org context
- the full permission table, and which tasks members can see and edit
- refresh token rotation and reuse detection
- seats and project slots under concurrent requests, and the last-admin rule under concurrent demotions
- plan limits, including active tasks per project and blocked downgrades
- plan expiry: ended plans going back to Free (on a request and in the background), the 10-day grace period, blocking growth over Free limits, force-archiving the least recently active extras, restoring eligible projects and tasks within plan limits, the pause while a workspace is over its plan, and renewal reminders
- the sign-up flows: pending sign-ups, cross-device sign-in, resend limits, common passwords, the password reset cooldown, signed-in devices and feedback screenshots
- in-app invitations, declining, duplicate invites, and removing and re-inviting members
- the project bin, restore, permanent delete and the 30-day cleanup
- dashboard numbers, including time zones
- what happens to chats and meetings when someone leaves an organization
- direct and group chats staying private (even from admins), unread counts, replies, reactions, the 10 minute edit window and deleting
- muting, @mentions and message search
- private chat files: signed links, who can open them, and the server-side size check
- chat retention and the cleanup of old messages
- meeting visibility, RSVPs, rescheduling, cancelling and the reminder before a meeting starts
- repeating meetings (replying to, editing and cancelling one date or all later ones), suggested times, and links to tasks and projects
- paying for a plan: orders, signature checks, the webhook, repeated confirmations, one organization confirming another's order, blocked unpaid upgrades, monthly and yearly prices, and renewals
api/
src/
tenancy/ AsyncLocalStorage context, the isolation plugin, tenant resolution middleware
auth/ authentication middleware, RBAC, ownership checks
modules/ one folder per area (auth, orgs, members, invites, projects, tasks, notifications, audit, dashboard, chat, meetings, people, billing, feedback)
realtime/ the Socket.IO server (live messages, typing, who's online)
lib/ small helpers (email, tokens, Cloudinary, timezones)
models/ one Mongoose schema per collection
db/ database connection and startup migrations
tests/ Vitest and Supertest, against mongodb-memory-server
e2e/ Playwright browser tests for the sign-up and account screens
web/
src/
auth/ AuthProvider, route guards
features/ one folder per area, each with its own api, queries and components
components/ shared UI (layout, modal, logo link, error boundary) and the landing page pieces in marketing/
assets/screens/ the product screenshots used on the landing page, the guide and this README (see the README in that folder to add or change one)
pages/ landing, login, register, invite, organizations and the how-to-use guide
hooks/ useCan (permission checks), useOrg (current org)
These are the main trade-offs and scaling limitations in the current implementation.
- Single-instance Socket.IO: online presence and live delivery rely on in-memory state in one server process. That's fine on a single instance. To scale out horizontally, I'd add Redis and the Socket.IO Redis adapter, and keep presence in Redis.
- Recurring meetings: editing all upcoming dates of a repeating meeting across a daylight-saving change is a known edge case. The time shift is a fixed offset, with no special handling for the clock change, so a date after it can end up an hour off.
- Payments: each payment buys one month or one year, with no automatic renewal, invoices, tax or refunds. Razorpay's test mode is used, so nothing real is charged. Going live would mean a real Razorpay account and, depending on where you sell, taxes and terms of sale.
- Single-region, single database: the free tiers used here sleep when idle (the first request after a while can take up to a minute, and meeting reminders don't go out while the API is asleep), and the free MongoDB tier has no continuous backups. Run
npm run sync-indexesonce after deploying schema changes to a new database; a few safety-critical indexes are also created at startup. - Several API servers: the background jobs (reminders, bin cleanup, chat retention) take a short database lock, so each runs once per tick. Live chat does not: sockets, who is online and live updates are per server, so the API should run as a single instance unless a Redis adapter is added.
- Sign-up reveals existing accounts: registering with an email that already has an account says so, so people can log in instead. It is a deliberate usability choice; the login, password reset and resend forms don't reveal anything.
- Message search: search uses application-level text matching over the chats you're in. At larger message volumes, MongoDB text indexes or Atlas Search would be the right tool.
Spraha Singh · GitHub

















