A GitHub/Linear-style team collaboration app: teams, projects, tasks, comments, activity history, in-app notifications and an analytics dashboard. It's a portfolio project on a pnpm monorepo, Express + MongoDB on the backend and Vue 3 on the frontend, with a shared Zod schema package so the same validation rules run on both sides instead of getting duplicated (and drifting).
Live demo: dev-collab.tech · API: api.dev-collab.tech
Auth Email/password signup with mandatory email verification (Nodemailer + Gmail SMTP), plus Google OAuth that auto-links to an existing account by email. Access tokens live in memory only, refresh tokens are rotating httpOnly cookies, and there's a route guard for platform-admin-only pages.
Teams & projects Create a team, invite existing users by search, assign per-team roles (admin/member) that are independent of a user's platform-wide role, promote/demote members. Projects can be archived instead of deleted so historical data stays queryable.
Tasks Kanban board (todo / in-progress / done) plus a paginated list view. Priority levels (P1-P5), due dates, an assignee picker scoped to the task's own team, file attachments, and a per-task activity timeline.
Comments & activity Threaded, cursor-paginated comments on tasks, and a read-only activity feed that logs status and assignee changes.
Notifications An in-app bell backed by a real background job queue (BullMQ + Redis) with a separate worker process. Task assignment and new comments enqueue a job, the worker turns it into a notification document, and the frontend polls when the dropdown opens.
Analytics A team-scoped dashboard built on MongoDB aggregation pipelines: status/priority breakdowns, an 8-week completion trend, overdue count, and a per-project completion table.
Admin panel Platform-wide user and team oversight (not team-scoped), user activate/deactivate that also revokes sessions, and aggregate stats. Runs on a completely separate permission system from team roles.
Responsive UI
Every screen works from a 320px phone up to a 2000px+ desktop, with dedicated mobile card layouts replacing data tables below sm instead of forcing horizontal scroll.
It's a pnpm workspace, three packages:
apps/backend Express API — TypeScript, ESM, Mongoose, JWT auth
apps/frontend Vue 3 + TypeScript + Vite + Pinia + Vue Router + Tailwind v4
packages/shared @dev-collab/shared — Zod schemas + response types, imported by both
The notification worker runs from the same apps/backend codebase but is a separate entry point (src/worker.ts vs src/server.ts). It never sits on the HTTP request path, so if Redis goes down, notifications degrade but the API keeps working.
Some of this exists because of a bug I actually hit, not because it looked good in a tutorial:
- Two permission systems, kept separate on purpose.
User.role(user/admin) is global and checked straight off the JWT, no DB hit.Team.members[].role(admin/member) is per-team and always re-checked against the database. A platform admin gets no automatic power inside a team they haven't joined. Getting from a comment down to its team takes a few hops of middleware (requireTeamRole→requireProjectTeamRole→requireTaskAccess→requireCommentAccess), each one built on the last instead of copy-pasted. - I found and closed a real privilege-escalation bug. Any team member could self-assign a task, and that satisfied the "assignee can manage" check, which unlocked delete. Fixed by splitting out a narrower
assertCanAdministerTask(team-admin or project-owner only, no assignee shortcut) that guards assignment and delete specifically, while edit stays assignee-inclusive. - Access token in memory, refresh token rotated and hashed. The JWT never touches
localStorage. The refresh token is an httpOnly cookie, rotates on every use, and is stored as a SHA-256 hash with a Mongo TTL index, not the raw value. There's a single-flight guard in the axios interceptor so two tabs hitting a 401 at the same time share one refresh call instead of racing each other into a logout. - Multi-collection writes share one Mongo transaction. Team deletion cascades, and task status/assignee changes (which also write an ActivityLog row), run inside
session.withTransaction. Even in tests this hits a real single-node replica set viamongodb-memory-server'sMongoMemoryReplSet, not a mock. - Two pagination strategies. Teams, projects, and tasks are filterable/sortable lists, so they use plain offset pagination (
page/pageSize/total). Comments, notifications, activity, and admin lists are append-only feeds, so they use opaque base64url keyset cursors ({createdAt, id}) instead. Offset pagination on a feed that's still growing skips or duplicates rows. - Notifications fail quietly.
enqueueNotification()wraps the BullMQ.add()in a try/catch and just logs on failure. I tested this by killing the Redis container mid-session and confirming task creation still returned 201. - One Zod schema per request contract. Every mutating endpoint's validation lives once in
packages/shared, used by the backend'svalidate()middleware and by the frontend forms. Never redeclared on either side.
| Layer | Choices |
|---|---|
| Backend | Express 4, TypeScript 5.9, Mongoose 9, Zod 4, JWT, bcrypt, Multer, Nodemailer, BullMQ + ioredis, Helmet, pino |
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router, Tailwind v4, shadcn-vue (reka-ui), Chart.js, VeeValidate, vue-sonner |
| Shared | Zod 4 schemas/types — consumed as source in dev, compiled dist/ in production |
| Data | MongoDB (single-node replica set, needed for transactions), Redis (BullMQ queue) |
| Testing | Jest 30 + Supertest + mongodb-memory-server (a real in-memory replica set, not a mocked driver) |
| CI | GitHub Actions — backend and frontend jobs run in parallel |
123 backend tests across 11 files, run against a real in-memory MongoDB replica set so transactions actually get exercised, with Redis and outbound email mocked.
| File | Tests | Covers |
|---|---|---|
team.test.ts |
28 | CRUD, membership, roles, cascade delete, promote/demote |
task.test.ts |
23 | CRUD, status, assignees, attachments, the administer/manage OR-rule |
auth.test.ts |
18 | register/verify/login/refresh/logout, rate limits |
project.test.ts |
14 | CRUD, archive, owner-OR-admin |
upload.test.ts |
9 | avatar + task attachments, size/type limits, access control |
oauth.test.ts |
7 | Google sign-in, account auto-linking, unverified-email rejection |
admin.test.ts |
6 | platform admin oversight, self-deactivation guard |
comment.test.ts |
6 | create/list/delete, four-hop access, cursor pagination |
notification.test.ts |
6 | enqueue diffing, ownership, mark-read |
activity.test.ts |
3 | timeline correctness, cursor pagination |
analytics.test.ts |
3 | aggregation correctness against known seeded data |
pnpm --filter backend test # full suite
pnpm --filter backend exec jest tests/team.test.ts # one file
pnpm --filter backend exec jest -t "removes a member" # one testPrerequisites: Node 22, pnpm 11, Podman or Docker (for Mongo + Redis).
git clone <this-repo>
cd dev-collab
pnpm install
# Mongo (single-node replica set, needed for transactions) + Redis
podman-compose up -d # or: docker compose up -d
cp apps/backend/.env.example apps/backend/.env
cp apps/frontend/.env.example apps/frontend/.env
# fill in apps/backend/.env — see the table below
pnpm dev # backend + frontend, in parallel
pnpm dev:worker # notification worker — separate terminal
pnpm --filter backend seed:dev # optional: realistic demo dataApp runs at http://localhost:5173, API at http://localhost:4000.
| Variable | Required | Notes |
|---|---|---|
MONGO_URI |
yes | server fails fast at boot without it |
JWT_SECRET |
yes | server fails fast at boot without it |
PORT |
no | defaults to 4000 |
REDIS_URL |
no | warns and falls back to 127.0.0.1:6379; notifications silently no-op if wrong |
FRONTEND_URL |
yes in prod | CORS origin + email/OAuth redirect target |
CROSS_SITE_COOKIES |
no | set true only when frontend and backend are on different domains |
CORS_EXTRA_ORIGINS |
no | comma-separated extra origins CORS also accepts alongside FRONTEND_URL, e.g. both the old and new frontend host during a domain cutover |
SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS |
yes for email | verification + resend emails |
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALLBACK_URL |
no | Google Sign-In; app works without it |
| Variable | Notes |
|---|---|
VITE_API_URL |
e.g. http://localhost:4000/api; baked in at build time, not runtime |
apps/backend/src/
├── routes/ Express routers — one file per resource
├── middlewares/ auth.middleware, requireTeamRole, requireProjectTeamRole,
│ requireTaskAccess, requireCommentAccess, requireNotificationOwner,
│ requireRole, validate, rateLimiter, handleMulterUpload
├── controllers/ thin — req/res only, no business logic
├── services/ business logic, Mongoose calls, transactions, job enqueues
├── models/ 8 Mongoose schemas
├── jobs/ BullMQ queue (producer) + processor (consumer)
├── config/ db, redis, mailer, googleOAuth, multer, logger
└── utils/ AppError + errorHandler, jwt, constants
apps/frontend/src/
├── views/ 14 route-level pages
├── components/ reusable UI, incl. shadcn-vue primitives under ui/
├── stores/ 7 Pinia stores (auth/team/project/task/comment/activity/notification)
├── services/ axios instance + one *.service.ts per resource
└── lib/ permissions composable, Chart.js theming, Zod↔VeeValidate adapter
packages/shared/src/
├── schemas/ Zod request-validation schemas (client-submitted input)
└── types/ plain response-shape types (backend-controlled, compile-time only)
Permission-middleware shapes and the full data model are in the architecture diagrams above.
Live on Heroku (web dyno + worker dyno) + MongoDB Atlas (free M0, but a real 3-node replica set so transactions run against genuine multi-node semantics) + Upstash Redis (free tier) for the BullMQ queue + Vercel for the frontend build. Both sides share a domain: dev-collab.tech for the frontend, api.dev-collab.tech for the API.
API + worker (Heroku):
Procfileat the repo root defines both processes (web: node apps/backend/dist/server.js,worker: node apps/backend/dist/worker.js).heroku-postbuildbuilds@dev-collab/sharedthen the backend. The frontend isn't built here, it deploys separately.CROSS_SITE_COOKIES=trueis set since frontend and backend live on different subdomains of the same domain.
Frontend (Vercel):
- Build command:
pnpm install && pnpm --filter @dev-collab/shared build && pnpm --filter frontend build - Publish directory:
apps/frontend/dist VITE_API_URLgets baked into the bundle at build time, so changing it means a redeploy, not just a dashboard edit.apps/frontend/vercel.jsonadds the SPA rewrite (/*→/index.html) thatvue-router'screateWebHistory()needs, or refreshing a deep link like/tasks/:id404s.
packages/shared ships compiled output. Its package.json exports point at dist/, not src/, so both builds above depend on building it first. That's enforced in CI and in heroku-postbuild.
CI → Heroku auto-deploy: the deploy job in .github/workflows/ci.yml is gated on needs: [backend, frontend], so a push to main only reaches Heroku once typecheck, the full Jest suite, and the real production build have all passed on that commit. It pushes over Heroku's git remote with a long-lived HEROKU_API_KEY, and it's actually fired on real pushes, not just in theory. The Vercel frontend deploys independently through its own GitHub integration.
- File uploads have no UI. The backend routes, Multer config, and
Task.attachmentsschema are built and tested, but I pulled the upload controls from the frontend. Local disk storage doesn't survive a PaaS's ephemeral filesystem or multiple instances, and I haven't picked an object store (S3/Cloudinary/etc.) yet. - No real email for task/comment notifications, only the in-app bell. Auth email (verification, resend) is the only email that actually sends.
- No websockets. The notification bell only fetches when you open the dropdown. No push, no polling interval.
- No self-service admin promotion, on purpose.
role: "admin"is a direct database write, not an app-reachable endpoint. - No headless-browser test suite wired into CI. I did verify the responsive layout (320px-2000px, every route, every table/dialog/popover) with a real Playwright pass against the running dev server, but that was a one-off audit, not a regression test. Future layout changes won't get automatically re-checked.
- VeeValidate on the Team/Project/Task/Comment modals (currently only Login/Register have it)
- Actually pick an object store for uploads
- An email-invite-to-join-team flow (right now an admin has to add an existing verified user by search)
This was my first time building a monorepo with a genuinely shared package between frontend and backend, and the first time I've had to think hard about two independent permission systems living in the same app. A few things that stuck:
- Finding the self-assign privilege-escalation bug taught me to think about authorization from the attacker's side, not just "does the happy path work."
- Testing against a real in-memory Mongo replica set (instead of mocking the driver) caught transaction bugs that a mock never would have.
- Splitting auth into access-token-in-memory + rotated-hashed-refresh-cookie was more work than
localStorage, but it's the difference between "looks secure" and actually being harder to exploit. - Shipping the worker as a separate process from day one, instead of bolting it on later, made the "Redis dies, API keeps working" behavior almost free instead of a retrofit.











