A multi-tenant SaaS application for managing field teams. Managers can assign tasks to field workers, track their live GPS locations on an interactive map, and communicate with them in real time via chat.
MVP complete — managers assign tasks, track workers live on a map, and chat in real time. Future ideas live in
LATER.md.
- Tech Stack
- Project Structure
- Architecture Overview
- Dual Interface Design
- Auth Strategy
- Database Schema
- API Endpoints
- Socket.IO Events
- Environment Variables
- Getting Started
- Module Breakdown
- Implementation Status
- Roadmap
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Frontend | Next.js (App Router) | 16.2.6 | React framework, SSR, routing |
| Frontend UI | Tailwind CSS v4 + shadcn/ui | 4.x | Design system & components |
| Forms | React Hook Form + Zod | — | Form state + schema validation |
| State | React Context API | — | Auth state (user, role) + shared Socket.IO instance |
| Backend | Hono | 4.12.25 | Lightweight HTTP API server |
| Backend Runtime | Node.js via @hono/node-server |
1.19.14 | Node adapter for Hono |
| Backend Validation | Zod | — | Request body schema validation |
| Database | PostgreSQL (Neon serverless) | — | Primary persistent storage |
| ORM | Drizzle ORM | 0.45.2 | Type-safe DB queries & migrations |
| Cache / Session | Redis (Upstash) via ioredis | 5.11.1 | Session store, live location cache |
| Password Hashing | bcrypt | — | Secure password hashing |
| HTTP Client | axios | — | API calls with cookie support |
| Maps | Google Maps JavaScript API | — | Location picker, live map, navigation |
| Toast | Sonner | — | Toast notifications |
| Real-time | Socket.IO | — | Live location + real-time DM chat |
| File Storage | Cloudflare R2 | — | User uploads (planned) |
| Nodemailer (Gmail SMTP) | — | Invitation emails |
fieldforce/
├── client/ # Next.js 16 frontend
│ ├── app/
│ │ ├── auth/
│ │ │ ├── signup/page.tsx # [DONE] Signup form page
│ │ │ └── signin/page.tsx # [DONE] Signin form page
│ │ ├── dashboard/ # Manager interface
│ │ │ ├── layout.tsx # [DONE] Sidebar layout (persists open/closed state)
│ │ │ ├── page.tsx # [DONE] Manager dashboard home
│ │ │ ├── tasks/page.tsx # [DONE] Task table + filter + search + create + edit panel
│ │ │ ├── maps/page.tsx # [DONE] Live map — workers + tasks + useSocket()
│ │ │ ├── chats/page.tsx # [DONE] Manager chat — ConversationList + MessageThread + useSocket()
│ │ │ └── team/page.tsx # [DONE] Team management — members table + invite + pending list
│ │ ├── chats/page.tsx # [DONE] Worker chat — MessageThread + useSocket() (mobile)
│ │ ├── profile/page.tsx # [DONE] Worker profile (settings UI only — see LATER.md)
│ │ ├── tasks/[id]/page.tsx # [DONE] Worker task detail page
│ │ ├── globals.css # [DONE] Tailwind v4 + theme tokens
│ │ ├── layout.tsx # [DONE] Root layout (Providers + GoogleMapsScript)
│ │ └── page.tsx # [DONE] Worker home — task list with stats
│ ├── components/
│ │ ├── ui/
│ │ │ ├── avatar.tsx # [DONE] Avatar + AvatarGroup
│ │ │ ├── breadcrumb.tsx # [DONE] Breadcrumb nav
│ │ │ ├── button.tsx # [DONE] Button (CVA variants)
│ │ │ ├── card.tsx # [DONE] Card layout
│ │ │ ├── collapsible.tsx # [DONE] Collapsible/accordion
│ │ │ ├── dropdown-menu.tsx # [DONE] Dropdown menu system
│ │ │ ├── form.tsx # [DONE] react-hook-form integration
│ │ │ ├── input.tsx # [DONE] Input field
│ │ │ ├── label.tsx # [DONE] Label
│ │ │ ├── separator.tsx # [DONE] Horizontal/vertical divider
│ │ │ ├── sheet.tsx # [DONE] Slide-out sheet/drawer
│ │ │ ├── sidebar.tsx # [DONE] Full sidebar system
│ │ │ ├── skeleton.tsx # [DONE] Loading skeleton
│ │ │ ├── sonner.tsx # [DONE] Toast provider
│ │ │ └── tooltip.tsx # [DONE] Tooltip
│ │ ├── chat/
│ │ │ ├── conversation-list.tsx # [DONE] Shared conversation list (manager + worker)
│ │ │ └── message-thread.tsx # [DONE] Shared message thread (manager + worker)
│ │ ├── dashboard/
│ │ │ ├── dahsboard-overview.tsx # [DONE] Dashboard home — aggregates stat cards + charts
│ │ │ ├── stat-card.tsx # [ENHANCED] Compact stat cards with live data (smaller, cuter design)
│ │ │ ├── task-status-chart.tsx # [ENHANCED] Pie chart showing task distribution by status
│ │ │ ├── recent-tasks-table.tsx # [DONE] Table listing recent tasks
│ │ │ ├── worker-status-list.tsx # [ENHANCED] Worker list with real-time online/offline status
│ │ │ ├── sidebar.tsx # [DONE] Dashboard sidebar layout
│ │ │ ├── manager-task-panel.tsx # [DONE] Task edit side panel
│ │ │ └── map/
│ │ │ ├── live-map.tsx # [DONE] Google Maps live worker + task markers
│ │ │ └── worker-list-sidebar.tsx # [DONE] Worker list with online/offline filter
│ │ ├── worker/
│ │ │ ├── bottom-navigation.tsx # [DONE] Mobile tab bar
│ │ │ ├── task-detail-sheet.tsx # [DONE] Full-screen task detail
│ │ │ └── task-map.tsx # [DONE] Google Maps task map
│ │ ├── app-sidebar.tsx # [DONE] Manager sidebar (nav + user)
│ │ ├── create-task-modal.tsx # [DONE] Task creation modal with maps
│ │ ├── google-maps-script.tsx # [DONE] Async Google Maps loader
│ │ ├── nav-main.tsx # [DONE] Sidebar main nav items
│ │ ├── nav-projects.tsx # [DONE] Sidebar projects section
│ │ ├── nav-user.tsx # [DONE] Sidebar user footer
│ │ ├── providers.tsx # [DONE] Root providers wrapper
│ │ ├── roleGate.tsx # [DONE] Role-based render guard
│ │ ├── team-switcher.tsx # [DONE] Sidebar team/org display
│ │ └── theme-provider.tsx # [DONE] Dark mode provider
│ ├── context/
│ │ ├── authContext.ts # [DONE] AuthProvider + useUser hook
│ │ └── socketContext.tsx # [DONE] SocketProvider + useSocket hook (shared singleton)
│ ├── hooks/
│ │ ├── auth/
│ │ │ ├── signup.ts # [DONE] useSignup()
│ │ │ ├── signin.ts # [DONE] useSignin()
│ │ │ ├── signout.ts # [DONE] useSignout()
│ │ │ └── acceptInvitation.ts # [DONE] useAcceptInvitation() (wires /join → /invitations/accept)
│ │ ├── dashboard/tasks/
│ │ │ └── useTasks.ts # [DONE] Fetch task list
│ │ ├── use-mobile.ts # [DONE] useIsMobile() hook
│ │ ├── useUpdateTaskStatus.ts # [DONE] Patch task status
│ │ └── useWorkers.ts # [DONE] Fetch worker list
│ ├── interfaces/
│ │ └── index.ts # [DONE] ITask, IWorker, TaskStatus etc.
│ ├── lib/
│ │ ├── api.ts # [DONE] axios instance (withCredentials)
│ │ ├── chat-service.ts # [DEPRECATED] Replaced by direct API + Socket.IO calls
│ │ └── utils.ts # [DONE] cn, handleAxiosError, etc.
│ ├── validations/
│ │ └── zod.ts # [DONE] Zod schemas (auth + tasks)
│ ├── middleware.ts # [DONE] Route protection middleware
│ ├── .env # Frontend env vars
│ ├── components.json # shadcn/ui config
│ ├── next.config.ts # [DONE] API + Socket.IO rewrites for Vercel deployment
│ └── package.json
│
├── server/ # Hono REST API
│ ├── src/
│ │ ├── index.ts # [DONE] Entry — CORS + routes + Socket.IO (auth + location events)
│ │ ├── db/
│ │ │ ├── schema.ts # [DONE] 7 tables + 2 pgEnums + relations
│ │ │ └── index.ts # [DONE] pg.Pool + Drizzle instance
│ │ ├── errors/
│ │ │ └── index.ts # [DONE] HTTP error handler functions
│ │ ├── lib/
│ │ │ ├── redis.ts # [DONE] Redis singleton
│ │ │ ├── auth.ts # [DONE] bcrypt + token utils
│ │ │ └── session.ts # [DONE] Redis session CRUD
│ │ ├── middleware/
│ │ │ └── auth.ts # [DONE] authMiddileware + requiredRoles
│ │ ├── routes/
│ │ │ ├── auth.ts # [DONE] /auth/*
│ │ │ ├── invitations.ts # [DONE] /invitations/*
│ │ │ ├── tasks.ts # [DONE] /tasks/*
│ │ │ ├── memberships.ts # [DONE] /memberships/* (workers + manager)
│ │ │ ├── locations.ts # [DONE] /locations/*
│ │ │ └── messages.ts # [DONE] /messages/:userId
│ │ ├── controllers/
│ │ │ ├── index.ts # [DONE] Re-exports all controllers
│ │ │ ├── auth.ts # [DONE] signup/signin/signout/fetchMe
│ │ │ ├── invitations.ts # [DONE] create/list/accept
│ │ │ ├── tasks.ts # [DONE] create/list/updateStatus/patch
│ │ │ ├── memberships.ts # [DONE] getWorkerController + getManagerController
│ │ │ ├── locations.ts # [DONE] updateLocation, getLocations
│ │ │ └── messages.ts # [DONE] getMessagesController
│ │ └── services/
│ │ ├── index.ts # [DONE] Re-exports all services
│ │ ├── auth.ts # [DONE] signup/signin + password flows
│ │ ├── invitations.ts # [DONE] create/accept/fetchInvitations
│ │ ├── tasks.ts # [DONE] create/list/updateStatus/patch
│ │ ├── memberships.ts # [DONE] getMembershipService (role param)
│ │ ├── locations.ts # [DONE] updateLocation (Redis) + getLocations
│ │ └── messages.ts # [DONE] getMessagesService (DB query)
│ ├── validations/
│ │ └── index.ts # [DONE] Centralized Zod schemas
│ ├── types/
│ │ └── index.ts # [DONE] IRoles type
│ ├── .env
│ ├── drizzle.config.ts
│ └── package.json
│
├── LATER.md # Deferred features log
└── README.md
┌─────────────────────────────────────────────────────────────┐
│ Client (Next.js) │
│ │
│ MANAGER WORKER │
│ /dashboard/* (sidebar) / (bottom nav) │
│ ├─ Tasks table + create ├─ Task list + stats │
│ ├─ /dashboard/maps ├─ /tasks/[id] detail │
│ │ WorkerListSidebar + ├─ /chats (mobile DM) │
│ │ LiveMap + useSocket() └─ /profile │
│ └─ /dashboard/chats │
│ ConversationList + MessageThread + useSocket() │
│ │
│ AuthProvider → SocketProvider (singleton socket, keyed │
│ on userId — null when logged out, auto-reconnect) │
│ middleware.ts — cookie route guard (Edge Runtime) │
│ next.config.ts — rewrites /api/v1/* + /socket.io/* to │
│ BACKEND_URL (Vercel proxy for deployment) │
└───────────────┬────────────────────────┬────────────────────┘
│ REST :8000 │ Socket.IO :8000
┌───────────────▼────────────────────────▼────────────────────┐
│ Server (Hono + Socket.IO) │
│ CORS → Routes → authMiddileware → requiredRoles │
│ /auth /invitations /tasks /memberships /locations │
│ │
│ Socket.IO middleware — validates session cookie → join │
│ org:{organizationId} room │
│ "location-update" → Redis SET + broadcast "worker-location"│
└────────┬─────────────────────┬───────────────────────────────┘
│ │
┌────────▼──────┐ ┌───────────▼──────────────────────────────┐
│ PostgreSQL │ │ Redis (Upstash) │
│ (Neon) │ │ session:{hex64} → { userId, orgId, role}│
│ 7 tables │ │ location:{orgId}:{userId} → { lat, lng }│
│ │ │ TTL: session 7d / location 1h │
└───────────────┘ └──────────────────────────────────────────┘
FieldForce has two completely separate UIs sharing the same API:
| Manager | Worker | |
|---|---|---|
| Entry point | /dashboard |
/ (root) |
| Navigation | Collapsible sidebar | Mobile bottom tab bar |
| Task view | Table with filter/search, create modal, edit panel | Card list with progress stats |
| Task detail | Edit panel (side-by-side) | Full-screen sheet with map |
| Maps | /dashboard/maps — live worker + task markers, worker sidebar |
Task map in detail sheet + navigate button |
| Chat | /dashboard/chats — ConversationList + MessageThread (desktop layout) |
/chats — same components, mobile-first (list → thread navigation) |
| Profile | — | Profile card + settings |
FieldForce uses Redis-backed session authentication (no JWT tokens).
POST /auth/signup or /auth/signin
→ Zod validate → DB query → bcrypt → create Redis session
→ set signed httpOnly cookie (7 days)
→ return { user, org/role }
GET request with cookie → authMiddileware
→ getSignedCookie → redis.get("session:{id}")
→ expired? delete cookie + 401
→ found? c.set("user", session) → next()
POST /auth/signout
→ redis.del("session:{id}") + deleteCookie
client/middleware.ts (Next.js Edge Runtime) checks cookie presence:
- No cookie on
/dashboard/*,/my-tasks,/team,/map,/chat→ redirect/auth/signin?from=path - Cookie on
/auth/signinor/auth/signup→ redirect/dashboard
AuthProvider (context/authContext.ts) calls GET /auth/me on mount and stores full user object in React context.
Server: requiredRoles("manager") middleware on manager-only routes.
Client: <RoleGate allow={["manager"]}> wraps UI elements — renders null (or fallback) if role doesn't match.
Defined in server/src/db/schema.ts.
pgEnums:
roleEnum—"manager" | "worker"invitationStatuses—"pending" | "accepted" | "declined"
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK, auto |
name |
VARCHAR(255) | |
email |
VARCHAR(255) | Unique |
password |
VARCHAR(255) | bcrypt hashed |
created_at |
TIMESTAMP | |
updated_at |
TIMESTAMP |
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
name |
VARCHAR(255) | |
owner_id |
UUID | FK → users |
created_at |
TIMESTAMP | |
updated_at |
TIMESTAMP |
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
user_id |
UUID | FK → users |
organization_id |
UUID | FK → organizations |
role |
ENUM (roleEnum) |
manager or worker |
joined_at |
TIMESTAMP |
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
organization_id |
UUID | FK → organizations |
email |
VARCHAR(255) | |
token |
VARCHAR(255) | 64-char hex |
role |
ENUM (roleEnum) |
Role on accept |
status |
ENUM (invitationStatuses) |
Default "pending" |
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
organization_id |
UUID | FK → organizations |
title |
VARCHAR(255) | |
description |
VARCHAR(1000) | |
creator_id |
UUID | FK → users (manager) |
assigned_to |
UUID | FK → users (nullable) |
status |
VARCHAR(50) | pending / in_progress / completed |
latitude |
INTEGER | Nullable |
longitude |
INTEGER | Nullable |
deadline |
INTEGER | Unix timestamp, nullable |
created_at |
TIMESTAMP | |
updated_at |
TIMESTAMP |
Relations defined: tasks.assignedWorker, tasks.creator, tasks.organization (used in Drizzle relational queries)
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
user_id |
UUID | FK → users |
organization_id |
UUID | FK → organizations |
latitude |
INTEGER | |
longitude |
INTEGER | |
recorded_at |
TIMESTAMP |
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
organization_id |
UUID | FK → organizations |
sender_id |
UUID | FK → users |
receiver_id |
UUID | FK → users |
content |
VARCHAR(1000) | |
read_at |
TIMESTAMP | NULL until read |
Base path: /api/v1
| Method | Path | Auth | Status |
|---|---|---|---|
| GET | /health |
None | DONE |
| Method | Path | Auth | Status | Description |
|---|---|---|---|---|
| POST | /auth/signup |
None | DONE | Register + org + session cookie |
| POST | /auth/signin |
None | DONE | Login + session cookie |
| POST | /auth/signout |
Cookie | DONE | Delete session + clear cookie |
| GET | /auth/me |
Cookie | DONE | Current user from session |
| Method | Path | Auth | Role | Status | Description |
|---|---|---|---|---|---|
| POST | /invitations/register |
Cookie | manager | DONE | Create invite + email link (Nodemailer) + return link |
| GET | /invitations/list |
Cookie | manager | DONE | List org invitations |
| POST | /invitations/accept |
None | — | DONE | Accept → create user + session |
| POST | /invitations/decline |
Cookie | manager | DONE | Decline invitation (marks declined) |
| Method | Path | Auth | Role | Status | Description |
|---|---|---|---|---|---|
| POST | /tasks/register |
Cookie | manager | DONE | Create task (with optional assignee + location) |
| GET | /tasks/list |
Cookie | any | DONE | List tasks — managers see all, workers see assigned |
| PATCH | /tasks/:id/status |
Cookie | any | DONE | Update task status (workers: own tasks only) |
| PATCH | /tasks/:id |
Cookie | manager | DONE | Full update — status + assignedTo together |
Request body for POST /tasks/register:
{
"title": "Meter Reading – Zone 4",
"description": "Read electric meters on block D.",
"assignedTo": "uuid-of-worker",
"status": "pending",
"latitude": 23.8103,
"longitude": 90.4125,
"deadline": 1751234567
}| Method | Path | Auth | Role | Status | Description |
|---|---|---|---|---|---|
| GET | /memberships/workers |
Cookie | manager | DONE | List all workers in the org |
| GET | /memberships/manager |
Cookie | any | DONE | Get manager(s) in the org (used by worker chat) |
| Method | Path | Auth | Role | Status | Description |
|---|---|---|---|---|---|
| POST | /locations |
Cookie | any | DONE | Worker pushes GPS coordinates (stored in Redis) |
| GET | /locations |
Cookie | manager | DONE | Manager gets latest location of all workers |
| Method | Path | Auth | Status | Description |
|---|---|---|---|---|
| GET | /messages/:userId |
Cookie | DONE | Fetch conversation history with another user |
| POST | /messages |
Cookie | DONE | Send message via REST (mirrors Socket.IO send-message) |
| PATCH | /messages/:id/read |
Cookie | DONE | Mark a message as read (sets read_at) |
All Socket.IO connections are authenticated via session cookie. On connect, each client joins org:{organizationId} room.
Each client joins two rooms on connect:
org:{organizationId}— shared room for live location broadcastsuser:{userId}— personal inbox for DM delivery
| Event | Sender | Payload | Description |
|---|---|---|---|
location-update |
worker | { latitude, longitude } |
Worker pushes GPS position; server writes to Redis and broadcasts to org room |
send-message |
any | { receiverId, content } |
Send a DM; server inserts to DB and emits new-message to both parties |
| Event | Receiver | Payload | Description |
|---|---|---|---|
worker-location |
manager (org room) | { userId, latitude, longitude, updatedAt } |
Broadcast on every worker location update |
worker-status-changed |
manager (org room) | { userId, status: 'Online'|'Away'|'Offline', lastSeen } |
Broadcast on worker connect/disconnect (real-time presence) |
worker-tasks-updated |
manager (org room) | { userId, activeTasks: number } |
Broadcast when worker's active task count changes |
new-message |
sender + receiver (user room) | IChatMessage DB row |
Delivered to both parties immediately on insert |
| Event | Direction | Status | Description |
|---|---|---|---|
chat:read |
client → server | DONE | Mark a message read — server sets read_at (DB) and emits message-read to both parties |
chat:typing |
client → server | DONE | Typing indicator — server broadcasts chat:typing (with senderId) to the peer's user:{id} room |
| Event | Receiver | Payload | Description |
|---|---|---|---|
message-read |
sender + reader | IChatMessage DB row |
Emitted by chat:read so both sides can update read state |
chat:typing |
peer | { senderId } |
Emitted by chat:typing so the peer can show a typing indicator |
PORT=8000
NODE_ENV=development
CLIENT_ORIGIN=http://localhost:3000
DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
REDIS_URL=rediss://user:pass@host:6380
SESSION_SECRET=your-long-random-secret
EMAIL_USER=your@gmail.com # Nodemailer sender for invitation emails
EMAIL_PASS=your-gmail-app-password # Gmail App Password (not your login password)
GOOGLE_MAPS_API_KEY=your-key
R2_ACCESS_KEY=key
R2_SECRET_KEY=secret
R2_ENDPOINT=https://account-id.r2.cloudflarestorage.com
R2_BUCKET=fieldforceNEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_SOCKET_URL=http://localhost:8000
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-key
# Deployment only (Vercel) — enables API + Socket.IO rewrites in next.config.ts
# When set, the client proxies through Next.js instead of hitting the backend directly
BACKEND_URL=https://your-backend.up.railway.app- Node.js 20+, pnpm 9+
- Neon PostgreSQL database
- Upstash Redis instance
- Google Maps API key (Maps JavaScript API + Places API enabled)
# Server
cd server && cp .env.example .env
pnpm install && pnpm dev # http://localhost:8000
# Client (new terminal)
cd client && cp .env.example .env
pnpm install && pnpm dev # http://localhost:3000cd server
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
pnpm drizzle-kit studio # browser UI- CORS middleware with
CLIENT_ORIGINwhitelist,credentials: true - Routes:
/auth,/invitations,/tasks,/memberships,/locations - Global 404 handler via
notFoundError - Socket.IO server attached to the same Node.js HTTP server:
- Auth middleware: parses signed
sessioncookie from handshake headers → validates Redis session → attachessocket.data.user - On connect: joins
org:{organizationId}(location room) anduser:{userId}(personal inbox) "location-update"event: worker-only; writes to Redis viaupdateLocationService+ broadcasts"worker-location"to org room"send-message"event: inserts row intomessagesDB table → emits"new-message"to bothuser:{receiverId}anduser:{senderId}rooms (sender gets echo)
- Auth middleware: parses signed
Consistent JSON response shapes for every error type. All controllers use these.
| Function | Status | Shape |
|---|---|---|
serverError(c, err) |
500 | { success, message, stack? } |
badRequestError(c, { message, fields }) |
400 | { success, error: { message, code }, fields } |
conflictError(c, { message, fields }) |
409 | same as above with code 409 |
notFoundError(c) |
404 | { success, message } |
authenticationError(c, message) |
401 | { success, error: { message, code } } |
authorizationError(c, message) |
403 | same |
schemaValidationError(zodErr, msg) |
helper | Converts Zod issues → { message, fields[] } |
| Schema | Used for |
|---|---|
ZUserSchema / TUser |
Signup |
ZSignin / ISignin |
Signin |
ZChangePassword / TChangePassword |
Password change |
ZForgotPassword / TForgotPassword |
Forgot password |
zPasswordReset |
Reset password |
zResetToken |
Token validation |
zTasks |
Task create/update |
BDPhoneRegex |
BD phone number validation |
All functions validate input via zTasks.safeParse and return { error } / { serverError } / success.
taskCreateService({ user, body })
- Zod validates body (title required, status enum, optional assignedTo/lat/lng/deadline)
- If
assignedToprovided → verifies that user is a worker in the same org - Inserts task with
organizationIdandcreatorIdfrom session
fetchTasksService({ user })
- Manager → fetches all org tasks with relations (assignedWorker, creator, organization)
- Worker → fetches only tasks where
assigned_to = userId
updateTaskService({ user, taskId, body })
- Updates only
statusfield - Worker: can only update their own assigned task
patchTaskService({ user, taskId, body })
- Manager-only: updates both
statusandassignedToatomically - Validates new assignee is a worker in the org before updating
updateLocationService({ organizationId, userId, latitude, longitude })
- Writes
{ userId, lat, lng, updatedAt }to Redis keylocation:{orgId}:{userId}with TTL 1hr - Also called by Socket.IO
"location-update"handler directly (bypassing HTTP)
getLocationsService(organizationId)
KEYS location:{orgId}:*→MGET→ parse all → returns array of location objects
getMembershipService({ organizationId, role })
- Queries
membershipsjoined withuserswhererolematches the param ("worker"or"manager") - Returns
[{ id, name, email, role }] - Used by
getWorkerController(manager-only) andgetManagerController(any auth user)
getMessagesService({ organizationId, userId, userB })
- Queries
messagestable for all rows where(senderId = userId AND receiverId = userB) OR (senderId = userB AND receiverId = userId)within the same org - Ordered by
createdAtascending (chronological) - Returns
{ success, data: IChatMessage[] }
authMiddileware — reads signed cookie → Redis session lookup → attaches user to context
requiredRoles(...roles) — factory middleware, reads c.get("user").role → 403 if not allowed
Previously held mock chat data. Now superseded — both chat pages call the real API and Socket.IO directly. The file remains but is no longer imported.
Used by the manager chat page. Accepts IConversation[] (real DB worker records) and renders a searchable list.
- Name-based color avatar with online dot
- Search input filters by name
- Unread count badge (updated via Socket.IO
new-message) headerLeftslot forSidebarTrigger- Selected conversation highlighted with blue left border
Used by both manager and worker chat pages. Accepts IChatMessage[] (real DB rows).
currentUserIdprop determines which side is "mine" (compares vsmsg.senderId)- Groups messages by calendar day with date separators ("Today", "Yesterday", date)
- Sent bubbles (blue, right-aligned) vs received bubbles (gray, left-aligned)
onBackprop (optional) — showsChevronLeftfor mobile navigation- Auto-scrolls to bottom on new messages
- Empty state when no conversation selected
Fully wired to real backend. Desktop two-column layout: ConversationList + MessageThread.
- Loads worker list from
GET /memberships/workers(conversation roster) - On select: loads history from
GET /messages/:workerId - Gets the shared socket via
useSocket()— registers"new-message"listener with cleanup;send-messageviasocket.emit - Unread counter increments on incoming messages for non-active conversations
Simplified to a single DM with the manager. Mobile-first full-screen layout.
- Loads manager from
GET /memberships/manager - Loads history from
GET /messages/:managerId - Gets the shared socket via
useSocket()—send-messageon send,new-messageappended (filtered to manager ID only)
Google Maps component for the manager's live map page.
Worker markers — custom SVG:
- Colored circle with initials (color determined by name, teal border if online, gray if offline)
- First-name pill label below the circle
- Click → fires
onWorkerClick(workerId)
Task markers — teardrop pin:
- Color by status:
pending= blue,in_progress= orange,completed= green,cancelled= red
InfoWindow — opens on worker marker click:
- Shows avatar, name, online/offline status
- Current task reference (e.g.
FF-A3B2) + title if assigned - "Message" and "Assign task" buttons (UI only)
Pan + zoom — when selectedWorkerId changes, map pans to that worker and zooms to 15
Legend — bottom-left overlay listing task pin colors
Left panel on the maps page.
- Live count chip in header (online workers)
- Search by name or email
- Filter tabs: All / Online / Offline with per-tab counts
- Each row: avatar + name + status line (current task title if online+busy, "no active task" if online+idle, "Offline · Xm ago" if offline)
- Crosshair "Locate" button per row → calls
onLocate(workerId)to pan map
Manager-only page at /dashboard/maps.
- Fetches workers (
GET /memberships/workers), locations (GET /locations), tasks (GET /tasks/list) in parallel viaPromise.allSettled - Merges into
WorkerWithLocation[]— worker is "online" if last location update < 5 minutes ago; current task is the firstpendingorin_progresstask assigned to them - Gets the shared socket via
useSocket()— subscribes to"worker-location"events, updateslocationMapstate in real time → triggers re-render of markers selectedWorkerIdstate — shared between sidebar click, locate button, and map marker click- Role guard: renders "only available to managers" if a worker somehow reaches this page
AuthProvider component:
- Calls
GET /auth/meon mount to hydrate user state - Provides
{ user, loading, refresh, logout }to all children usershape:{ userId, name, email, organizationId, role }
useUser() hook — accesses context; throws if called outside AuthProvider.
SocketProvider creates a single shared Socket.IO connection for the entire app. Previously each page (maps, chat) created its own io() call — now they all share one via context.
- Instantiated with
useMemokeyed onuser.userId— one socket per user session - Returns
nullwhen the user is not authenticated (no wasted connection) - Auto-disconnects when the user logs out or the component unmounts
- Passes
withCredentials: true+ reconnection config (5 attempts, 1–5s delay) - Reads
NEXT_PUBLIC_SOCKET_URLfor the server address (falls back toundefinedso Vercel proxy works)
useSocket() hook — returns Socket | null. All pages/components that need real-time events call this instead of creating their own connections.
const socket = useSocket()
socket?.emit("send-message", { receiverId, content })
socket?.on("new-message", handler)Nav items: Dashboard (/dashboard), Maps (/dashboard/maps), Tasks (/dashboard/tasks), Team (/dashboard/team), Chats (/dashboard/chats), Settings (/dashboard/settings).
Collapsed state shows icons only. Mobile: drawer with overlay.
Wraps all /dashboard/* pages with SidebarProvider → AppSidebar + SidebarInset. Reads sidebar_state cookie server-side to persist the user's last open/closed preference across page loads.
Full task management interface for managers:
- Filter tabs: All / Pending / In Progress / Completed (with counts)
- Search: title search input in the header
- Task table: assignee avatar + name, status dot + label, lat/lng coords, deadline (red if overdue)
- Create button → opens
CreateTaskModal - Row click → opens
ManagerTaskPanel(right-side edit panel)
Dialog modal for managers to create tasks. Fields:
- Title (required)
- Description
- Assignee — worker dropdown from
useWorkers() - Status —
pending / in_progress / completed - Deadline — date picker
- Location — Google Places autocomplete input + mini map with draggable marker pin + "Use current location" button
On submit: POST /tasks/register → calls onCreated() callback → closes modal.
Manager's dashboard home at /dashboard:
- Displays real-time task statistics using
DashboardOverviewcomponent - Shows total tasks, completed, pending, in progress, and overdue counts
- Includes task status pie chart and recent tasks table
- Fetches live data via
useTasks()and renders stat cards with real-time updates
Aggregates all dashboard components and manages data flow:
- Fetches tasks and workers from API
- Groups tasks by status (pending, in_progress, completed, overdue)
- Renders stat cards showing task counts by status
- Displays task status distribution chart
- Shows worker status and recent tasks
- Provides a complete executive summary for managers
Compact, animated stat card component displaying a single metric:
- Enhancements: Reduced size (smaller, cuter design), smooth hover animations (scale + shadow)
- Icon badge: Colored circular background with status icon, scales on hover with slight rotation
- Stats display: Value in large bold text, optional subtitle, optional trend percentage with arrow
- Colors: Configurable accent colors (blue, green, purple, orange) with dark mode support
- Animation: Scale-up on hover (105%), dual-dot background accents, smooth transitions
Used for: Total Tasks, Completed, Pending, In Progress, Overdue counts.
Pie chart showing task distribution by status:
- Enhancements: Fixed data key from
statustocount, proper color rendering with hex values - Status breakdown: Pending (orange), In Progress (blue), Completed (green), Overdue (red)
- Legend: Bottom legend showing count per status with colored dots
- Total counter: Displays total task count with trending indicator
- Responsive: Auto-scales to container, handles zero-count statuses gracefully
Table displaying the most recently created or updated tasks:
- Shows task title, assignee, status, deadline, and progress
- Supports sorting and filtering
- Integrates with the manager task panel for inline editing
Sidebar component showing real-time worker status and activity:
- Enhancements: Now tracks real-time online/offline status via Socket.IO events
- Status badges: 🟢 Online, 🟡 Away, ⚫ Offline with color-coded styling
- Live info: Shows active task count per worker, last seen timestamp when offline
- Avatar with status dot: Worker initials in colored circle with status indicator
- Count header: Live count of online workers updated in real-time
- Socket.IO integration: Listens to
worker-status-changedandworker-tasks-updatedevents
Collapsible sidebar layout for the dashboard:
- Persists open/closed state to browser cookie
- Houses navigation items and team switcher
- Mobile-responsive drawer on small screens
Right-side slide-in panel for managers editing a selected task.
- Editable:
status(dropdown),assignedTo(worker dropdown) - Read-only: description, deadline, location coords, creator name, created date
- Save →
PATCH /tasks/:idwith both fields - Dirty state indicator when values have changed
Full-screen slide-up sheet (mobile) for workers viewing a task:
TaskMapif lat/lng present- Status indicator + colored badge
- Task metadata: location, deadline (with overdue warning), assigned by
- Status progression:
pending → in_progress → completed - "Navigate" → opens Google Maps directions URL
Worker's primary task list screen (/):
- Progress bar: completed / total tasks
- Stats row: done today, on-time %, this week
- Task cards with status badge, location, deadline (overdue warning)
- Loading skeleton + empty state
- Tap a task card → opens
TaskDetailSheet
Rewrites configured for Vercel deployment. Only active when BACKEND_URL env var is set (local dev skips them, hitting the backend directly):
| Source | Destination |
|---|---|
/api/v1/:path* |
${BACKEND_URL}/api/v1/:path* |
/socket.io/:path* |
${BACKEND_URL}/socket.io/:path* |
This lets the frontend and backend live on different domains without CORS issues in production — Vercel proxies both REST and Socket.IO through the Next.js server.
Next.js Edge Runtime. Checks session cookie presence.
/dashboard/*,/my-tasks,/team,/map,/chat— protected/auth/signin,/auth/signup— redirect to/dashboardif already logged in
const { tasks, setTasks, loading, refresh } = useTasks()Calls GET /tasks/list on mount. Returns task array, manual setter (for optimistic updates), loading flag, and refresh().
const updated = await updateStatus(taskId, "in_progress")Calls PATCH /tasks/:id/status. Returns updated ITask or null. Loading state + success/error toasts.
const { workers, loading } = useWorkers()Calls GET /memberships/workers. Returns IWorker[] for assignee dropdowns.
| Schema | Fields |
|---|---|
signupSchema |
name (≥2), email, organizationName (≥2), password (6–20) |
singinSchema |
email, password (6–20) |
zTasks |
title, description, assignedTo?, status (enum), latitude?, longitude?, deadline? |
- PostgreSQL + Drizzle schema (7 tables + 2 pgEnums + task relations)
- Redis session management
- Centralized error handler (
errors/index.ts) - Centralized Zod schemas (
server/validations/index.ts) - Auth — signup, signin, signout, me
- Auth middleware + RBAC (
requiredRoles) - Auth context (
AuthProvider+useUser) - Socket context (
SocketProvider+useSocket) — shared singleton connection, keyed onuserId, null when logged out - Invitations — create, list, accept
- Tasks — create, list, update status, full patch
- Memberships — list workers
- Locations REST —
POST /locations+GET /locations(Redis-backed, TTL 1hr) - Socket.IO — real location events: server auth middleware (session cookie), org rooms,
"location-update"→ Redis + broadcast"worker-location" - Live map page —
WorkerListSidebar+LiveMap+useSocket()for real-time worker location - Deployment proxy —
next.config.tsrewrites/api/v1/*and/socket.io/*toBACKEND_URLfor Vercel - Messages backend —
GET /messages/:userId(DB history),send-messageSocket.IO event → DB insert +new-messagebroadcast - Memberships —
GET /memberships/manageradded alongside/workers - Manager chat page (
/dashboard/chats) — real-time DMs: loads worker roster, history from DB, Socket.IO send/receive - Worker chat page (
/chats) — single DM with manager; loads manager via/memberships/manager, history from DB, Socket.IO send/receive - Next.js route protection middleware
-
RoleGatecomponent (client-side RBAC guard) - Google Maps — loader, location picker, live map markers, task map, navigate
- Manager sidebar — all nav links wired to correct routes
- Manager task table — filter by status, search by title, create modal, edit panel
- Dashboard stat cards [ENHANCED] — real-time task counts with smaller, cuter design + smooth animations
- Task status chart [ENHANCED] — pie chart showing task distribution with fixed colors + legend
- Worker status list [ENHANCED] — real-time online/offline presence via Socket.IO (
worker-status-changed,worker-tasks-updatedevents) - Real-time online/offline presence — Socket.IO events for worker status changes, integrated with live map + dashboard
- Worker home page — task list with progress + stats
- Worker task detail — map + status progression + navigate
- Worker profile page (UI only)
- Worker bottom navigation
-
useTasks,useWorkers,useUpdateTaskStatus,useIsMobilehooks - TypeScript interfaces (
ITask,IWorker,TaskStatus, etc.) - Tailwind CSS v4 + full shadcn/ui component set
- Team page — members table + filter/search + create-invitation modal + pending-invitations list
- Accept-invitation flow —
/joinpage wired touseAcceptInvitation()→POST /invitations/accept(previously a missing hook; now fixed) - Invitation decline —
POST /invitations/decline(service + controller + route) - Invitation emails — Nodemailer (Gmail SMTP) sends a personalized HTML invite link on
POST /invitations/register; delivery is best-effort so the invite + link still return if SMTP fails - Messages REST —
POST /messages(send) +PATCH /messages/:id/read(mark read) - Read receipts —
chat:readSocket.IO event → DBread_at+message-readbroadcast - Typing indicator —
chat:typingSocket.IO event broadcast to peer -
BottomNavigationlinks to real/chatspage (legacy/worker/chatremoved) - Removed legacy
chat-list/page.tsxmock
-
changePassword,forgotPassword,resetPassword— defined in service, not yet exposed via routes (needs persistent reset-token storage + email) - Worker profile settings wired to API (Availability toggle, Notifications, Vehicle are UI-only)
- Location history from DB (current: latest position only via Redis)
- File uploads (Cloudflare R2)
- Dashboard analytics / notifications
- Query / search / filter / pagination on task endpoints
All of the above are non-blocking for the core MVP flow (manager assigns tasks → sees workers on a map → chats with them). They are catalogued with context in
LATER.md.
| Week | Feature | Status |
|---|---|---|
| 1 | Project setup (server + client + DB schema) | Done |
| 2 | Auth (signup, signin, signout, session, Zod) | Done |
| 3 | Invitations + Tasks CRUD + Worker/Manager UI | Done |
| 4 | Real-time location (Socket.IO + Google Maps live tracking) | Done |
| 5 | Real-time chat (DMs via Socket.IO + DB persistence, history REST endpoint) | Done |
| 6 | MVP completion — invite-accept UI fix, invitation decline, message read receipts + typing | Done |
| 7–13 | Polish, testing, deployment, extras (see LATER.md) |
Pending |