Skip to content

Repository files navigation

🤝 dev-collab

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).

CI

Live demo: dev-collab.tech · API: api.dev-collab.tech

📸 Screenshots

Dashboard Teams Teams list view Projects Projects list view Task board Task detail Team members

✨ Features

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.

🧱 Architecture

Architecture overview System topology Request lifecycle Data model

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.

🧠 A few things worth explaining

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 (requireTeamRolerequireProjectTeamRolerequireTaskAccessrequireCommentAccess), 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 via mongodb-memory-server's MongoMemoryReplSet, 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's validate() middleware and by the frontend forms. Never redeclared on either side.

🛠 Tech stack

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

🧪 Testing

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 test

🚀 Local setup

Prerequisites: 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 data

App runs at http://localhost:5173, API at http://localhost:4000.

Backend environment variables

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

Frontend environment variables

Variable Notes
VITE_API_URL e.g. http://localhost:4000/api; baked in at build time, not runtime

🗂️ Project structure

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.

🌐 Deployment

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):

  • Procfile at the repo root defines both processes (web: node apps/backend/dist/server.js, worker: node apps/backend/dist/worker.js).
  • heroku-postbuild builds @dev-collab/shared then the backend. The frontend isn't built here, it deploys separately.
  • CROSS_SITE_COOKIES=true is 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_URL gets baked into the bundle at build time, so changing it means a redeploy, not just a dashboard edit.
  • apps/frontend/vercel.json adds the SPA rewrite (/*/index.html) that vue-router's createWebHistory() needs, or refreshing a deep link like /tasks/:id 404s.

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.

⚠️ Known limitations

  • File uploads have no UI. The backend routes, Multer config, and Task.attachments schema 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.

🎯 Up next

  • 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)

🎓 What I learned

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.

About

Full-stack team collaboration platform — Express/MongoDB API + Vue 3 client, pnpm monorepo

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages