Sughan-0077 commited on
Commit
fe1a2e0
·
0 Parent(s):

Initial commit for Hugging Face Space deployment

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +33 -0
  2. DEPLOY.md +246 -0
  3. Dockerfile +93 -0
  4. README.md +24 -0
  5. backend/.env.example +21 -0
  6. backend/Dockerfile +15 -0
  7. backend/package-lock.json +1573 -0
  8. backend/package.json +27 -0
  9. backend/src/controllers/adminController.js +269 -0
  10. backend/src/controllers/authController.js +193 -0
  11. backend/src/controllers/commentController.js +127 -0
  12. backend/src/controllers/taskController.js +347 -0
  13. backend/src/middleware/auth.js +61 -0
  14. backend/src/middleware/validate.js +14 -0
  15. backend/src/routes/admin.js +41 -0
  16. backend/src/routes/auth.js +33 -0
  17. backend/src/routes/comments.js +12 -0
  18. backend/src/routes/tasks.js +36 -0
  19. backend/src/server.js +62 -0
  20. backend/src/utils/auditLogger.js +42 -0
  21. backend/src/utils/db.js +23 -0
  22. backend/src/utils/jwt.js +23 -0
  23. backend/src/utils/migrate.js +115 -0
  24. backend/src/utils/seed.js +87 -0
  25. docker-compose.dev.yml +44 -0
  26. docker-compose.yml +90 -0
  27. frontend/.env.example +1 -0
  28. frontend/Dockerfile +14 -0
  29. frontend/Dockerfile.dev +7 -0
  30. frontend/nginx.conf +33 -0
  31. frontend/package-lock.json +0 -0
  32. frontend/package.json +28 -0
  33. frontend/public/index.html +14 -0
  34. frontend/src/App.jsx +76 -0
  35. frontend/src/components/layout/AppLayout.jsx +73 -0
  36. frontend/src/components/layout/AppLayout.module.css +71 -0
  37. frontend/src/components/tasks/TaskModal.jsx +140 -0
  38. frontend/src/components/tasks/TaskModal.module.css +42 -0
  39. frontend/src/components/ui/components.css +94 -0
  40. frontend/src/components/ui/index.jsx +86 -0
  41. frontend/src/context/AuthContext.jsx +49 -0
  42. frontend/src/index.css +90 -0
  43. frontend/src/index.jsx +11 -0
  44. frontend/src/pages/AcceptInvitePage.jsx +76 -0
  45. frontend/src/pages/AdminPage.jsx +257 -0
  46. frontend/src/pages/AdminPage.module.css +57 -0
  47. frontend/src/pages/AuthPage.module.css +44 -0
  48. frontend/src/pages/BoardPage.jsx +217 -0
  49. frontend/src/pages/BoardPage.module.css +214 -0
  50. frontend/src/pages/DashboardPage.jsx +140 -0
.gitignore ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ **/node_modules/
4
+
5
+ # Build outputs
6
+ build/
7
+ dist/
8
+ **/build/
9
+
10
+ # Environment files
11
+ .env
12
+ **/.env
13
+ !**/.env.example
14
+
15
+ # PostgreSQL data
16
+ postgres_data/
17
+
18
+ # Logs
19
+ *.log
20
+ npm-debug.log*
21
+
22
+ # OS artifacts
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # IDE
27
+ .vscode/
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+
32
+ # Docker volumes
33
+ postgres_data/
DEPLOY.md ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying Taskflow to Hugging Face Spaces
2
+
3
+ This folder contains everything needed to run the full Taskflow app on
4
+ Hugging Face Spaces for free — no local Docker, no paid server.
5
+
6
+ When you push these files to a HF Space, HF automatically:
7
+ 1. Builds the Docker image
8
+ 2. Initialises PostgreSQL
9
+ 3. Runs migrations and seeds demo data
10
+ 4. Starts nginx, the Node API, and the database
11
+ 5. Serves your app at `https://your-username-taskflow.hf.space`
12
+
13
+ ---
14
+
15
+ ## What runs inside the container
16
+
17
+ ```
18
+ Port 7860 (public)
19
+ └── Nginx
20
+ ├── /api/* → Node.js Express (port 5000, internal)
21
+ └── /* → React SPA (static files)
22
+
23
+ PostgreSQL (port 5432, internal only)
24
+ ```
25
+
26
+ All three services are managed by supervisord so they restart
27
+ automatically if they crash.
28
+
29
+ ---
30
+
31
+ ## Step-by-step deployment
32
+
33
+ ### Step 1 — Create a Hugging Face account
34
+
35
+ Go to https://huggingface.co and sign up (free).
36
+
37
+ ---
38
+
39
+ ### Step 2 — Create a new Space
40
+
41
+ 1. Click your profile picture → **New Space**
42
+ 2. Fill in the form:
43
+ - **Space name**: `taskflow` (or any name you like)
44
+ - **License**: MIT
45
+ - **SDK**: Select **Docker** ← this is the important one
46
+ - **Docker template**: Blank
47
+ - **Hardware**: CPU Basic (free)
48
+ - **Visibility**: Public or Private
49
+ 3. Click **Create Space**
50
+
51
+ HF will create an empty git repository for your space.
52
+
53
+ ---
54
+
55
+ ### Step 3 — Get the repository URL
56
+
57
+ After creating the space, you'll see a page with a Git URL like:
58
+
59
+ ```
60
+ https://huggingface.co/spaces/YOUR_USERNAME/taskflow
61
+ ```
62
+
63
+ The git remote URL will be:
64
+
65
+ ```
66
+ https://huggingface.co/YOUR_USERNAME/taskflow
67
+ ```
68
+
69
+ ---
70
+
71
+ ### Step 4 — Copy these files into the space repository
72
+
73
+ You have two options:
74
+
75
+ #### Option A — Upload via the HF web UI (easiest, no git needed)
76
+
77
+ 1. Open your Space on huggingface.co
78
+ 2. Click **Files** tab
79
+ 3. Click **+ Add file → Upload files**
80
+ 4. Upload all files from this `huggingface/` folder:
81
+ - `Dockerfile`
82
+ - `hf.nginx.conf`
83
+ - `supervisord.conf`
84
+ - `start.sh`
85
+ - `README.md`
86
+ 5. Also upload the entire `backend/` folder
87
+ 6. Also upload the entire `frontend/` folder (without `node_modules`)
88
+ 7. Commit the changes
89
+
90
+ The folder structure in your Space should look like:
91
+
92
+ ```
93
+ your-space/
94
+ ├── README.md ← the one from this folder (has the --- header block)
95
+ ├── Dockerfile
96
+ ├── hf.nginx.conf
97
+ ├── supervisord.conf
98
+ ├── start.sh
99
+ ├── backend/
100
+ │ ├── package.json
101
+ │ ├── src/
102
+ │ └── ...
103
+ └── frontend/
104
+ ├── package.json
105
+ ├── public/
106
+ ├── src/
107
+ └── ...
108
+ ```
109
+
110
+ #### Option B — Use Git (if you have Git installed)
111
+
112
+ ```bash
113
+ # Clone your HF space repository
114
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/taskflow
115
+ cd taskflow
116
+
117
+ # Copy everything from the huggingface/ folder to the root
118
+ cp path/to/taskflow/huggingface/Dockerfile .
119
+ cp path/to/taskflow/huggingface/hf.nginx.conf .
120
+ cp path/to/taskflow/huggingface/supervisord.conf .
121
+ cp path/to/taskflow/huggingface/start.sh .
122
+ cp path/to/taskflow/huggingface/README.md .
123
+
124
+ # Copy backend and frontend
125
+ cp -r path/to/taskflow/backend ./backend
126
+ cp -r path/to/taskflow/frontend ./frontend
127
+
128
+ # Remove node_modules if they exist
129
+ rm -rf backend/node_modules frontend/node_modules frontend/build
130
+
131
+ # Push
132
+ git add .
133
+ git commit -m "Initial deployment"
134
+ git push
135
+ ```
136
+
137
+ ---
138
+
139
+ ### Step 5 — Watch the build
140
+
141
+ After pushing, go to your Space URL and click the **Logs** tab.
142
+
143
+ You'll see:
144
+ ```
145
+ ▶ Initialising PostgreSQL data directory...
146
+ ▶ Starting PostgreSQL for setup...
147
+ PostgreSQL is ready.
148
+ ▶ Setting up database...
149
+ ▶ Running migrations...
150
+ ▶ Seeding demo data...
151
+ ▶ Starting all services via supervisord...
152
+ ```
153
+
154
+ The build typically takes **3–6 minutes** the first time (npm install + React build).
155
+ After that, rebuilds are faster because HF caches Docker layers.
156
+
157
+ ---
158
+
159
+ ### Step 6 — Open the app
160
+
161
+ Once the build finishes, click **App** in your Space.
162
+
163
+ Your app will be live at:
164
+ ```
165
+ https://YOUR_USERNAME-taskflow.hf.space
166
+ ```
167
+
168
+ Log in with any demo account:
169
+
170
+ | Email | Password | Role |
171
+ |---|---|---|
172
+ | alice@acme.com | Password123! | Admin |
173
+ | bob@acme.com | Password123! | Member |
174
+ | carol@globex.com | Password123! | Admin |
175
+
176
+ ---
177
+
178
+ ## Important notes about HF Spaces
179
+
180
+ ### Data persistence
181
+
182
+ HF Spaces on the **free tier use ephemeral storage** — if the space restarts
183
+ or is rebuilt, the PostgreSQL data is wiped and the seed data is re-applied.
184
+
185
+ This is fine for demo and internship purposes. For permanent data, you would
186
+ connect to an external PostgreSQL service (Neon, Supabase, Railway, etc.)
187
+ by setting environment variables in the Space settings.
188
+
189
+ ### Environment variables / secrets
190
+
191
+ You can override any environment variable from the Space settings:
192
+
193
+ 1. Go to your Space → **Settings** → **Variables and Secrets**
194
+ 2. Add secrets (they won't appear in logs):
195
+ - `JWT_SECRET` → set a strong random value for any real use
196
+ - `DB_PASSWORD` → only needed if using external Postgres
197
+ - `DB_HOST`, `DB_NAME`, `DB_USER` → for external Postgres
198
+
199
+ ### Waking up from sleep
200
+
201
+ Free HF Spaces pause after ~30 minutes of inactivity.
202
+ The first request after sleeping takes ~30 seconds to wake up.
203
+ This is normal for the free tier.
204
+
205
+ ### Port
206
+
207
+ HF Spaces only allows port **7860** to be public. The Dockerfile
208
+ and nginx config are already set up correctly for this.
209
+
210
+ ---
211
+
212
+ ## Connecting to an external PostgreSQL (optional)
213
+
214
+ For data that survives restarts, use a free external Postgres:
215
+
216
+ **Neon** (recommended, free tier): https://neon.tech
217
+ 1. Create a free project
218
+ 2. Copy the connection string
219
+ 3. In your HF Space settings → Secrets, add:
220
+ - `DB_HOST` = your-neon-host.neon.tech
221
+ - `DB_PORT` = 5432
222
+ - `DB_NAME` = neondb
223
+ - `DB_USER` = your-neon-user
224
+ - `DB_PASSWORD` = your-neon-password
225
+
226
+ Then update `start.sh` to skip the local postgres setup when `DB_HOST`
227
+ is not `127.0.0.1`.
228
+
229
+ ---
230
+
231
+ ## Troubleshooting
232
+
233
+ | Problem | Fix |
234
+ |---|---|
235
+ | Build fails with "permission denied" | Check start.sh has Unix line endings (LF not CRLF) |
236
+ | App loads but API returns 502 | Backend didn't start — check Logs for Node.js errors |
237
+ | White screen | React build failed — check Logs for npm build errors |
238
+ | "Cannot connect to database" | PostgreSQL init failed — check Logs for postgres errors |
239
+ | Space shows "Building" for >10 min | Click **Factory rebuild** in Space settings |
240
+
241
+ If you edited files on Windows, run this to fix line endings before uploading:
242
+
243
+ ```bash
244
+ # PowerShell
245
+ (Get-Content start.sh -Raw).Replace("`r`n","`n") | Set-Content start.sh -NoNewline
246
+ ```
Dockerfile ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────
2
+ # Taskflow — All-in-one Dockerfile for Hugging Face Spaces
3
+ #
4
+ # HF Spaces rules this file must satisfy:
5
+ # - Expose exactly port 7860
6
+ # - Run as non-root user (we use "user" uid 1000)
7
+ # - Single container (no docker-compose)
8
+ #
9
+ # Architecture inside this container:
10
+ # PostgreSQL 16 → internal port 5432
11
+ # Node/Express → internal port 5000
12
+ # Nginx → port 7860 (serves React SPA + proxies /api to Express)
13
+ #
14
+ # Process manager: supervisord keeps all three services alive.
15
+ # ─────────────────────────────────────────────────────────────────
16
+
17
+ FROM ubuntu:22.04
18
+
19
+ ENV DEBIAN_FRONTEND=noninteractive
20
+ ENV NODE_ENV=production
21
+ ENV PORT=5000
22
+ ENV DB_HOST=127.0.0.1
23
+ ENV DB_PORT=5432
24
+ ENV DB_NAME=taskflow
25
+ ENV DB_USER=taskflow_user
26
+ ENV DB_PASSWORD=taskflow_hf_secret
27
+ ENV JWT_SECRET=taskflow_huggingface_jwt_secret_change_for_production_use
28
+ ENV JWT_EXPIRES_IN=7d
29
+ ENV FRONTEND_URL=http://localhost:7860
30
+
31
+ # ── System packages ────────────────────────────────────────────────
32
+ RUN apt-get update && apt-get install -y \
33
+ curl wget gnupg2 lsb-release ca-certificates \
34
+ nginx supervisor \
35
+ postgresql-14 postgresql-client-14 \
36
+ && rm -rf /var/lib/apt/lists/*
37
+
38
+ # ── Node.js 20 ─────────────────────────────────────────────────────
39
+ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
40
+ && apt-get install -y nodejs \
41
+ && rm -rf /var/lib/apt/lists/*
42
+
43
+ # ── Working directories ────────────────────────────────────────────
44
+ WORKDIR /app
45
+ RUN mkdir -p /app/backend /app/frontend /var/log/supervisor \
46
+ /run/postgresql /var/lib/postgresql/data
47
+
48
+ # ── Backend ────────────────────────────────────────────────────────
49
+ COPY backend/package*.json /app/backend/
50
+ RUN cd /app/backend && npm ci --only=production
51
+
52
+ COPY backend/ /app/backend/
53
+
54
+ # ── Frontend — build React app ─────────────────────────────────────
55
+ COPY frontend/package*.json /app/frontend/
56
+ RUN cd /app/frontend && npm ci
57
+
58
+ COPY frontend/ /app/frontend/
59
+ # Point the API at the same origin so nginx can proxy it
60
+ RUN echo "REACT_APP_API_URL=/api" > /app/frontend/.env.production
61
+ RUN cd /app/frontend && npm run build
62
+
63
+ # ── Nginx config ───────────────────────────────────────────────────
64
+ RUN rm -f /etc/nginx/sites-enabled/default
65
+ COPY hf.nginx.conf /etc/nginx/sites-enabled/taskflow.conf
66
+
67
+ # ── Supervisord config ─────────────────────────────────────────────
68
+ COPY supervisord.conf /etc/supervisor/conf.d/taskflow.conf
69
+
70
+ # ── Startup script ─────────────────────────────────────────────────
71
+ COPY start.sh /app/start.sh
72
+ RUN chmod +x /app/start.sh
73
+
74
+ # ── HF Spaces: must run as non-root with uid 1000 ──────────────────
75
+ RUN useradd -m -u 1000 hfuser \
76
+ && chown -R hfuser:hfuser /app \
77
+ && chown -R hfuser:hfuser /var/log/nginx \
78
+ && chown -R hfuser:hfuser /var/lib/nginx \
79
+ && chown -R hfuser:hfuser /run \
80
+ && chown -R postgres:postgres /var/lib/postgresql \
81
+ && chown -R postgres:postgres /run/postgresql \
82
+ && chmod 777 /var/log/supervisor \
83
+ && chmod 777 /tmp
84
+
85
+ # Nginx needs to write to these as non-root
86
+ RUN chown -R hfuser:hfuser /var/log/nginx /var/lib/nginx /run/nginx* 2>/dev/null || true \
87
+ && touch /run/nginx.pid && chown hfuser:hfuser /run/nginx.pid 2>/dev/null || true
88
+
89
+ USER hfuser
90
+
91
+ EXPOSE 7860
92
+
93
+ CMD ["/app/start.sh"]
README.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Taskflow
3
+ emoji: ✅
4
+ colorFrom: yellow
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: Multi-tenant task management with RBAC and Kanban board
10
+ ---
11
+
12
+ # Taskflow — Multi-Tenant Task Management
13
+
14
+ A full-stack task management platform. Multiple organizations, role-based access, Kanban board, task comments, and audit logs.
15
+
16
+ **Demo accounts (password: `Password123!`)**
17
+
18
+ | Email | Role | Org |
19
+ |---|---|---|
20
+ | alice@acme.com | Admin | Acme Corp |
21
+ | bob@acme.com | Member | Acme Corp |
22
+ | carol@globex.com | Admin | Globex Inc |
23
+
24
+ Built with React · Node.js · PostgreSQL · JWT auth.
backend/.env.example ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PORT=5000
2
+ NODE_ENV=development
3
+
4
+ # PostgreSQL
5
+ DB_HOST=postgres
6
+ DB_PORT=5432
7
+ DB_NAME=taskflow
8
+ DB_USER=taskflow_user
9
+ DB_PASSWORD=taskflow_secret
10
+
11
+ # JWT
12
+ JWT_SECRET=your_super_secret_jwt_key_change_in_production_min_32_chars
13
+ JWT_EXPIRES_IN=7d
14
+
15
+ # Optional: OAuth (Google)
16
+ GOOGLE_CLIENT_ID=
17
+ GOOGLE_CLIENT_SECRET=
18
+ GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/google/callback
19
+
20
+ # Frontend URL for CORS
21
+ FRONTEND_URL=http://localhost:3000
backend/Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies first (layer caching)
6
+ COPY package*.json ./
7
+ RUN npm ci --only=production
8
+
9
+ # Copy source
10
+ COPY . .
11
+
12
+ EXPOSE 5000
13
+
14
+ # Run migrations then start server
15
+ CMD ["sh", "-c", "node src/utils/migrate.js && node src/server.js"]
backend/package-lock.json ADDED
@@ -0,0 +1,1573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "taskflow-backend",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "taskflow-backend",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "bcryptjs": "^2.4.3",
12
+ "cors": "^2.8.5",
13
+ "dotenv": "^16.3.1",
14
+ "express": "^4.18.2",
15
+ "express-rate-limit": "^7.1.5",
16
+ "express-validator": "^7.0.1",
17
+ "helmet": "^7.1.0",
18
+ "jsonwebtoken": "^9.0.2",
19
+ "pg": "^8.11.3",
20
+ "uuid": "^9.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "nodemon": "^3.0.2"
24
+ }
25
+ },
26
+ "node_modules/accepts": {
27
+ "version": "1.3.8",
28
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
29
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "mime-types": "~2.1.34",
33
+ "negotiator": "0.6.3"
34
+ },
35
+ "engines": {
36
+ "node": ">= 0.6"
37
+ }
38
+ },
39
+ "node_modules/anymatch": {
40
+ "version": "3.1.3",
41
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
42
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
43
+ "dev": true,
44
+ "license": "ISC",
45
+ "dependencies": {
46
+ "normalize-path": "^3.0.0",
47
+ "picomatch": "^2.0.4"
48
+ },
49
+ "engines": {
50
+ "node": ">= 8"
51
+ }
52
+ },
53
+ "node_modules/array-flatten": {
54
+ "version": "1.1.1",
55
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
56
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
57
+ "license": "MIT"
58
+ },
59
+ "node_modules/balanced-match": {
60
+ "version": "4.0.4",
61
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
62
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
63
+ "dev": true,
64
+ "license": "MIT",
65
+ "engines": {
66
+ "node": "18 || 20 || >=22"
67
+ }
68
+ },
69
+ "node_modules/bcryptjs": {
70
+ "version": "2.4.3",
71
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
72
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
73
+ "license": "MIT"
74
+ },
75
+ "node_modules/binary-extensions": {
76
+ "version": "2.3.0",
77
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
78
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
79
+ "dev": true,
80
+ "license": "MIT",
81
+ "engines": {
82
+ "node": ">=8"
83
+ },
84
+ "funding": {
85
+ "url": "https://github.com/sponsors/sindresorhus"
86
+ }
87
+ },
88
+ "node_modules/body-parser": {
89
+ "version": "1.20.4",
90
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
91
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
92
+ "license": "MIT",
93
+ "dependencies": {
94
+ "bytes": "~3.1.2",
95
+ "content-type": "~1.0.5",
96
+ "debug": "2.6.9",
97
+ "depd": "2.0.0",
98
+ "destroy": "~1.2.0",
99
+ "http-errors": "~2.0.1",
100
+ "iconv-lite": "~0.4.24",
101
+ "on-finished": "~2.4.1",
102
+ "qs": "~6.14.0",
103
+ "raw-body": "~2.5.3",
104
+ "type-is": "~1.6.18",
105
+ "unpipe": "~1.0.0"
106
+ },
107
+ "engines": {
108
+ "node": ">= 0.8",
109
+ "npm": "1.2.8000 || >= 1.4.16"
110
+ }
111
+ },
112
+ "node_modules/brace-expansion": {
113
+ "version": "5.0.5",
114
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
115
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
116
+ "dev": true,
117
+ "license": "MIT",
118
+ "dependencies": {
119
+ "balanced-match": "^4.0.2"
120
+ },
121
+ "engines": {
122
+ "node": "18 || 20 || >=22"
123
+ }
124
+ },
125
+ "node_modules/braces": {
126
+ "version": "3.0.3",
127
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
128
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
129
+ "dev": true,
130
+ "license": "MIT",
131
+ "dependencies": {
132
+ "fill-range": "^7.1.1"
133
+ },
134
+ "engines": {
135
+ "node": ">=8"
136
+ }
137
+ },
138
+ "node_modules/buffer-equal-constant-time": {
139
+ "version": "1.0.1",
140
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
141
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
142
+ "license": "BSD-3-Clause"
143
+ },
144
+ "node_modules/bytes": {
145
+ "version": "3.1.2",
146
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
147
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
148
+ "license": "MIT",
149
+ "engines": {
150
+ "node": ">= 0.8"
151
+ }
152
+ },
153
+ "node_modules/call-bind-apply-helpers": {
154
+ "version": "1.0.2",
155
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
156
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
157
+ "license": "MIT",
158
+ "dependencies": {
159
+ "es-errors": "^1.3.0",
160
+ "function-bind": "^1.1.2"
161
+ },
162
+ "engines": {
163
+ "node": ">= 0.4"
164
+ }
165
+ },
166
+ "node_modules/call-bound": {
167
+ "version": "1.0.4",
168
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
169
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
170
+ "license": "MIT",
171
+ "dependencies": {
172
+ "call-bind-apply-helpers": "^1.0.2",
173
+ "get-intrinsic": "^1.3.0"
174
+ },
175
+ "engines": {
176
+ "node": ">= 0.4"
177
+ },
178
+ "funding": {
179
+ "url": "https://github.com/sponsors/ljharb"
180
+ }
181
+ },
182
+ "node_modules/chokidar": {
183
+ "version": "3.6.0",
184
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
185
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
186
+ "dev": true,
187
+ "license": "MIT",
188
+ "dependencies": {
189
+ "anymatch": "~3.1.2",
190
+ "braces": "~3.0.2",
191
+ "glob-parent": "~5.1.2",
192
+ "is-binary-path": "~2.1.0",
193
+ "is-glob": "~4.0.1",
194
+ "normalize-path": "~3.0.0",
195
+ "readdirp": "~3.6.0"
196
+ },
197
+ "engines": {
198
+ "node": ">= 8.10.0"
199
+ },
200
+ "funding": {
201
+ "url": "https://paulmillr.com/funding/"
202
+ },
203
+ "optionalDependencies": {
204
+ "fsevents": "~2.3.2"
205
+ }
206
+ },
207
+ "node_modules/content-disposition": {
208
+ "version": "0.5.4",
209
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
210
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
211
+ "license": "MIT",
212
+ "dependencies": {
213
+ "safe-buffer": "5.2.1"
214
+ },
215
+ "engines": {
216
+ "node": ">= 0.6"
217
+ }
218
+ },
219
+ "node_modules/content-type": {
220
+ "version": "1.0.5",
221
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
222
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
223
+ "license": "MIT",
224
+ "engines": {
225
+ "node": ">= 0.6"
226
+ }
227
+ },
228
+ "node_modules/cookie": {
229
+ "version": "0.7.2",
230
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
231
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
232
+ "license": "MIT",
233
+ "engines": {
234
+ "node": ">= 0.6"
235
+ }
236
+ },
237
+ "node_modules/cookie-signature": {
238
+ "version": "1.0.7",
239
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
240
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
241
+ "license": "MIT"
242
+ },
243
+ "node_modules/cors": {
244
+ "version": "2.8.6",
245
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
246
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
247
+ "license": "MIT",
248
+ "dependencies": {
249
+ "object-assign": "^4",
250
+ "vary": "^1"
251
+ },
252
+ "engines": {
253
+ "node": ">= 0.10"
254
+ },
255
+ "funding": {
256
+ "type": "opencollective",
257
+ "url": "https://opencollective.com/express"
258
+ }
259
+ },
260
+ "node_modules/debug": {
261
+ "version": "2.6.9",
262
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
263
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
264
+ "license": "MIT",
265
+ "dependencies": {
266
+ "ms": "2.0.0"
267
+ }
268
+ },
269
+ "node_modules/depd": {
270
+ "version": "2.0.0",
271
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
272
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
273
+ "license": "MIT",
274
+ "engines": {
275
+ "node": ">= 0.8"
276
+ }
277
+ },
278
+ "node_modules/destroy": {
279
+ "version": "1.2.0",
280
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
281
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
282
+ "license": "MIT",
283
+ "engines": {
284
+ "node": ">= 0.8",
285
+ "npm": "1.2.8000 || >= 1.4.16"
286
+ }
287
+ },
288
+ "node_modules/dotenv": {
289
+ "version": "16.6.1",
290
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
291
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
292
+ "license": "BSD-2-Clause",
293
+ "engines": {
294
+ "node": ">=12"
295
+ },
296
+ "funding": {
297
+ "url": "https://dotenvx.com"
298
+ }
299
+ },
300
+ "node_modules/dunder-proto": {
301
+ "version": "1.0.1",
302
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
303
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
304
+ "license": "MIT",
305
+ "dependencies": {
306
+ "call-bind-apply-helpers": "^1.0.1",
307
+ "es-errors": "^1.3.0",
308
+ "gopd": "^1.2.0"
309
+ },
310
+ "engines": {
311
+ "node": ">= 0.4"
312
+ }
313
+ },
314
+ "node_modules/ecdsa-sig-formatter": {
315
+ "version": "1.0.11",
316
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
317
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
318
+ "license": "Apache-2.0",
319
+ "dependencies": {
320
+ "safe-buffer": "^5.0.1"
321
+ }
322
+ },
323
+ "node_modules/ee-first": {
324
+ "version": "1.1.1",
325
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
326
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
327
+ "license": "MIT"
328
+ },
329
+ "node_modules/encodeurl": {
330
+ "version": "2.0.0",
331
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
332
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
333
+ "license": "MIT",
334
+ "engines": {
335
+ "node": ">= 0.8"
336
+ }
337
+ },
338
+ "node_modules/es-define-property": {
339
+ "version": "1.0.1",
340
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
341
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
342
+ "license": "MIT",
343
+ "engines": {
344
+ "node": ">= 0.4"
345
+ }
346
+ },
347
+ "node_modules/es-errors": {
348
+ "version": "1.3.0",
349
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
350
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
351
+ "license": "MIT",
352
+ "engines": {
353
+ "node": ">= 0.4"
354
+ }
355
+ },
356
+ "node_modules/es-object-atoms": {
357
+ "version": "1.1.1",
358
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
359
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
360
+ "license": "MIT",
361
+ "dependencies": {
362
+ "es-errors": "^1.3.0"
363
+ },
364
+ "engines": {
365
+ "node": ">= 0.4"
366
+ }
367
+ },
368
+ "node_modules/escape-html": {
369
+ "version": "1.0.3",
370
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
371
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
372
+ "license": "MIT"
373
+ },
374
+ "node_modules/etag": {
375
+ "version": "1.8.1",
376
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
377
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
378
+ "license": "MIT",
379
+ "engines": {
380
+ "node": ">= 0.6"
381
+ }
382
+ },
383
+ "node_modules/express": {
384
+ "version": "4.22.1",
385
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
386
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
387
+ "license": "MIT",
388
+ "dependencies": {
389
+ "accepts": "~1.3.8",
390
+ "array-flatten": "1.1.1",
391
+ "body-parser": "~1.20.3",
392
+ "content-disposition": "~0.5.4",
393
+ "content-type": "~1.0.4",
394
+ "cookie": "~0.7.1",
395
+ "cookie-signature": "~1.0.6",
396
+ "debug": "2.6.9",
397
+ "depd": "2.0.0",
398
+ "encodeurl": "~2.0.0",
399
+ "escape-html": "~1.0.3",
400
+ "etag": "~1.8.1",
401
+ "finalhandler": "~1.3.1",
402
+ "fresh": "~0.5.2",
403
+ "http-errors": "~2.0.0",
404
+ "merge-descriptors": "1.0.3",
405
+ "methods": "~1.1.2",
406
+ "on-finished": "~2.4.1",
407
+ "parseurl": "~1.3.3",
408
+ "path-to-regexp": "~0.1.12",
409
+ "proxy-addr": "~2.0.7",
410
+ "qs": "~6.14.0",
411
+ "range-parser": "~1.2.1",
412
+ "safe-buffer": "5.2.1",
413
+ "send": "~0.19.0",
414
+ "serve-static": "~1.16.2",
415
+ "setprototypeof": "1.2.0",
416
+ "statuses": "~2.0.1",
417
+ "type-is": "~1.6.18",
418
+ "utils-merge": "1.0.1",
419
+ "vary": "~1.1.2"
420
+ },
421
+ "engines": {
422
+ "node": ">= 0.10.0"
423
+ },
424
+ "funding": {
425
+ "type": "opencollective",
426
+ "url": "https://opencollective.com/express"
427
+ }
428
+ },
429
+ "node_modules/express-rate-limit": {
430
+ "version": "7.5.1",
431
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
432
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
433
+ "license": "MIT",
434
+ "engines": {
435
+ "node": ">= 16"
436
+ },
437
+ "funding": {
438
+ "url": "https://github.com/sponsors/express-rate-limit"
439
+ },
440
+ "peerDependencies": {
441
+ "express": ">= 4.11"
442
+ }
443
+ },
444
+ "node_modules/express-validator": {
445
+ "version": "7.3.2",
446
+ "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
447
+ "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==",
448
+ "license": "MIT",
449
+ "dependencies": {
450
+ "lodash": "^4.18.1",
451
+ "validator": "~13.15.23"
452
+ },
453
+ "engines": {
454
+ "node": ">= 8.0.0"
455
+ }
456
+ },
457
+ "node_modules/fill-range": {
458
+ "version": "7.1.1",
459
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
460
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
461
+ "dev": true,
462
+ "license": "MIT",
463
+ "dependencies": {
464
+ "to-regex-range": "^5.0.1"
465
+ },
466
+ "engines": {
467
+ "node": ">=8"
468
+ }
469
+ },
470
+ "node_modules/finalhandler": {
471
+ "version": "1.3.2",
472
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
473
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
474
+ "license": "MIT",
475
+ "dependencies": {
476
+ "debug": "2.6.9",
477
+ "encodeurl": "~2.0.0",
478
+ "escape-html": "~1.0.3",
479
+ "on-finished": "~2.4.1",
480
+ "parseurl": "~1.3.3",
481
+ "statuses": "~2.0.2",
482
+ "unpipe": "~1.0.0"
483
+ },
484
+ "engines": {
485
+ "node": ">= 0.8"
486
+ }
487
+ },
488
+ "node_modules/forwarded": {
489
+ "version": "0.2.0",
490
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
491
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
492
+ "license": "MIT",
493
+ "engines": {
494
+ "node": ">= 0.6"
495
+ }
496
+ },
497
+ "node_modules/fresh": {
498
+ "version": "0.5.2",
499
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
500
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
501
+ "license": "MIT",
502
+ "engines": {
503
+ "node": ">= 0.6"
504
+ }
505
+ },
506
+ "node_modules/fsevents": {
507
+ "version": "2.3.3",
508
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
509
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
510
+ "dev": true,
511
+ "hasInstallScript": true,
512
+ "license": "MIT",
513
+ "optional": true,
514
+ "os": [
515
+ "darwin"
516
+ ],
517
+ "engines": {
518
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
519
+ }
520
+ },
521
+ "node_modules/function-bind": {
522
+ "version": "1.1.2",
523
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
524
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
525
+ "license": "MIT",
526
+ "funding": {
527
+ "url": "https://github.com/sponsors/ljharb"
528
+ }
529
+ },
530
+ "node_modules/get-intrinsic": {
531
+ "version": "1.3.0",
532
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
533
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
534
+ "license": "MIT",
535
+ "dependencies": {
536
+ "call-bind-apply-helpers": "^1.0.2",
537
+ "es-define-property": "^1.0.1",
538
+ "es-errors": "^1.3.0",
539
+ "es-object-atoms": "^1.1.1",
540
+ "function-bind": "^1.1.2",
541
+ "get-proto": "^1.0.1",
542
+ "gopd": "^1.2.0",
543
+ "has-symbols": "^1.1.0",
544
+ "hasown": "^2.0.2",
545
+ "math-intrinsics": "^1.1.0"
546
+ },
547
+ "engines": {
548
+ "node": ">= 0.4"
549
+ },
550
+ "funding": {
551
+ "url": "https://github.com/sponsors/ljharb"
552
+ }
553
+ },
554
+ "node_modules/get-proto": {
555
+ "version": "1.0.1",
556
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
557
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
558
+ "license": "MIT",
559
+ "dependencies": {
560
+ "dunder-proto": "^1.0.1",
561
+ "es-object-atoms": "^1.0.0"
562
+ },
563
+ "engines": {
564
+ "node": ">= 0.4"
565
+ }
566
+ },
567
+ "node_modules/glob-parent": {
568
+ "version": "5.1.2",
569
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
570
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
571
+ "dev": true,
572
+ "license": "ISC",
573
+ "dependencies": {
574
+ "is-glob": "^4.0.1"
575
+ },
576
+ "engines": {
577
+ "node": ">= 6"
578
+ }
579
+ },
580
+ "node_modules/gopd": {
581
+ "version": "1.2.0",
582
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
583
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
584
+ "license": "MIT",
585
+ "engines": {
586
+ "node": ">= 0.4"
587
+ },
588
+ "funding": {
589
+ "url": "https://github.com/sponsors/ljharb"
590
+ }
591
+ },
592
+ "node_modules/has-flag": {
593
+ "version": "3.0.0",
594
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
595
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
596
+ "dev": true,
597
+ "license": "MIT",
598
+ "engines": {
599
+ "node": ">=4"
600
+ }
601
+ },
602
+ "node_modules/has-symbols": {
603
+ "version": "1.1.0",
604
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
605
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
606
+ "license": "MIT",
607
+ "engines": {
608
+ "node": ">= 0.4"
609
+ },
610
+ "funding": {
611
+ "url": "https://github.com/sponsors/ljharb"
612
+ }
613
+ },
614
+ "node_modules/hasown": {
615
+ "version": "2.0.2",
616
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
617
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
618
+ "license": "MIT",
619
+ "dependencies": {
620
+ "function-bind": "^1.1.2"
621
+ },
622
+ "engines": {
623
+ "node": ">= 0.4"
624
+ }
625
+ },
626
+ "node_modules/helmet": {
627
+ "version": "7.2.0",
628
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
629
+ "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
630
+ "license": "MIT",
631
+ "engines": {
632
+ "node": ">=16.0.0"
633
+ }
634
+ },
635
+ "node_modules/http-errors": {
636
+ "version": "2.0.1",
637
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
638
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
639
+ "license": "MIT",
640
+ "dependencies": {
641
+ "depd": "~2.0.0",
642
+ "inherits": "~2.0.4",
643
+ "setprototypeof": "~1.2.0",
644
+ "statuses": "~2.0.2",
645
+ "toidentifier": "~1.0.1"
646
+ },
647
+ "engines": {
648
+ "node": ">= 0.8"
649
+ },
650
+ "funding": {
651
+ "type": "opencollective",
652
+ "url": "https://opencollective.com/express"
653
+ }
654
+ },
655
+ "node_modules/iconv-lite": {
656
+ "version": "0.4.24",
657
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
658
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
659
+ "license": "MIT",
660
+ "dependencies": {
661
+ "safer-buffer": ">= 2.1.2 < 3"
662
+ },
663
+ "engines": {
664
+ "node": ">=0.10.0"
665
+ }
666
+ },
667
+ "node_modules/ignore-by-default": {
668
+ "version": "1.0.1",
669
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
670
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
671
+ "dev": true,
672
+ "license": "ISC"
673
+ },
674
+ "node_modules/inherits": {
675
+ "version": "2.0.4",
676
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
677
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
678
+ "license": "ISC"
679
+ },
680
+ "node_modules/ipaddr.js": {
681
+ "version": "1.9.1",
682
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
683
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
684
+ "license": "MIT",
685
+ "engines": {
686
+ "node": ">= 0.10"
687
+ }
688
+ },
689
+ "node_modules/is-binary-path": {
690
+ "version": "2.1.0",
691
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
692
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
693
+ "dev": true,
694
+ "license": "MIT",
695
+ "dependencies": {
696
+ "binary-extensions": "^2.0.0"
697
+ },
698
+ "engines": {
699
+ "node": ">=8"
700
+ }
701
+ },
702
+ "node_modules/is-extglob": {
703
+ "version": "2.1.1",
704
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
705
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
706
+ "dev": true,
707
+ "license": "MIT",
708
+ "engines": {
709
+ "node": ">=0.10.0"
710
+ }
711
+ },
712
+ "node_modules/is-glob": {
713
+ "version": "4.0.3",
714
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
715
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
716
+ "dev": true,
717
+ "license": "MIT",
718
+ "dependencies": {
719
+ "is-extglob": "^2.1.1"
720
+ },
721
+ "engines": {
722
+ "node": ">=0.10.0"
723
+ }
724
+ },
725
+ "node_modules/is-number": {
726
+ "version": "7.0.0",
727
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
728
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
729
+ "dev": true,
730
+ "license": "MIT",
731
+ "engines": {
732
+ "node": ">=0.12.0"
733
+ }
734
+ },
735
+ "node_modules/jsonwebtoken": {
736
+ "version": "9.0.3",
737
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
738
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
739
+ "license": "MIT",
740
+ "dependencies": {
741
+ "jws": "^4.0.1",
742
+ "lodash.includes": "^4.3.0",
743
+ "lodash.isboolean": "^3.0.3",
744
+ "lodash.isinteger": "^4.0.4",
745
+ "lodash.isnumber": "^3.0.3",
746
+ "lodash.isplainobject": "^4.0.6",
747
+ "lodash.isstring": "^4.0.1",
748
+ "lodash.once": "^4.0.0",
749
+ "ms": "^2.1.1",
750
+ "semver": "^7.5.4"
751
+ },
752
+ "engines": {
753
+ "node": ">=12",
754
+ "npm": ">=6"
755
+ }
756
+ },
757
+ "node_modules/jsonwebtoken/node_modules/ms": {
758
+ "version": "2.1.3",
759
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
760
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
761
+ "license": "MIT"
762
+ },
763
+ "node_modules/jwa": {
764
+ "version": "2.0.1",
765
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
766
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
767
+ "license": "MIT",
768
+ "dependencies": {
769
+ "buffer-equal-constant-time": "^1.0.1",
770
+ "ecdsa-sig-formatter": "1.0.11",
771
+ "safe-buffer": "^5.0.1"
772
+ }
773
+ },
774
+ "node_modules/jws": {
775
+ "version": "4.0.1",
776
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
777
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
778
+ "license": "MIT",
779
+ "dependencies": {
780
+ "jwa": "^2.0.1",
781
+ "safe-buffer": "^5.0.1"
782
+ }
783
+ },
784
+ "node_modules/lodash": {
785
+ "version": "4.18.1",
786
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
787
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
788
+ "license": "MIT"
789
+ },
790
+ "node_modules/lodash.includes": {
791
+ "version": "4.3.0",
792
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
793
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
794
+ "license": "MIT"
795
+ },
796
+ "node_modules/lodash.isboolean": {
797
+ "version": "3.0.3",
798
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
799
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
800
+ "license": "MIT"
801
+ },
802
+ "node_modules/lodash.isinteger": {
803
+ "version": "4.0.4",
804
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
805
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
806
+ "license": "MIT"
807
+ },
808
+ "node_modules/lodash.isnumber": {
809
+ "version": "3.0.3",
810
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
811
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
812
+ "license": "MIT"
813
+ },
814
+ "node_modules/lodash.isplainobject": {
815
+ "version": "4.0.6",
816
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
817
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
818
+ "license": "MIT"
819
+ },
820
+ "node_modules/lodash.isstring": {
821
+ "version": "4.0.1",
822
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
823
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
824
+ "license": "MIT"
825
+ },
826
+ "node_modules/lodash.once": {
827
+ "version": "4.1.1",
828
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
829
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
830
+ "license": "MIT"
831
+ },
832
+ "node_modules/math-intrinsics": {
833
+ "version": "1.1.0",
834
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
835
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
836
+ "license": "MIT",
837
+ "engines": {
838
+ "node": ">= 0.4"
839
+ }
840
+ },
841
+ "node_modules/media-typer": {
842
+ "version": "0.3.0",
843
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
844
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
845
+ "license": "MIT",
846
+ "engines": {
847
+ "node": ">= 0.6"
848
+ }
849
+ },
850
+ "node_modules/merge-descriptors": {
851
+ "version": "1.0.3",
852
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
853
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
854
+ "license": "MIT",
855
+ "funding": {
856
+ "url": "https://github.com/sponsors/sindresorhus"
857
+ }
858
+ },
859
+ "node_modules/methods": {
860
+ "version": "1.1.2",
861
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
862
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
863
+ "license": "MIT",
864
+ "engines": {
865
+ "node": ">= 0.6"
866
+ }
867
+ },
868
+ "node_modules/mime": {
869
+ "version": "1.6.0",
870
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
871
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
872
+ "license": "MIT",
873
+ "bin": {
874
+ "mime": "cli.js"
875
+ },
876
+ "engines": {
877
+ "node": ">=4"
878
+ }
879
+ },
880
+ "node_modules/mime-db": {
881
+ "version": "1.52.0",
882
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
883
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
884
+ "license": "MIT",
885
+ "engines": {
886
+ "node": ">= 0.6"
887
+ }
888
+ },
889
+ "node_modules/mime-types": {
890
+ "version": "2.1.35",
891
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
892
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
893
+ "license": "MIT",
894
+ "dependencies": {
895
+ "mime-db": "1.52.0"
896
+ },
897
+ "engines": {
898
+ "node": ">= 0.6"
899
+ }
900
+ },
901
+ "node_modules/minimatch": {
902
+ "version": "10.2.5",
903
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
904
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
905
+ "dev": true,
906
+ "license": "BlueOak-1.0.0",
907
+ "dependencies": {
908
+ "brace-expansion": "^5.0.5"
909
+ },
910
+ "engines": {
911
+ "node": "18 || 20 || >=22"
912
+ },
913
+ "funding": {
914
+ "url": "https://github.com/sponsors/isaacs"
915
+ }
916
+ },
917
+ "node_modules/ms": {
918
+ "version": "2.0.0",
919
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
920
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
921
+ "license": "MIT"
922
+ },
923
+ "node_modules/negotiator": {
924
+ "version": "0.6.3",
925
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
926
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
927
+ "license": "MIT",
928
+ "engines": {
929
+ "node": ">= 0.6"
930
+ }
931
+ },
932
+ "node_modules/nodemon": {
933
+ "version": "3.1.14",
934
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
935
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
936
+ "dev": true,
937
+ "license": "MIT",
938
+ "dependencies": {
939
+ "chokidar": "^3.5.2",
940
+ "debug": "^4",
941
+ "ignore-by-default": "^1.0.1",
942
+ "minimatch": "^10.2.1",
943
+ "pstree.remy": "^1.1.8",
944
+ "semver": "^7.5.3",
945
+ "simple-update-notifier": "^2.0.0",
946
+ "supports-color": "^5.5.0",
947
+ "touch": "^3.1.0",
948
+ "undefsafe": "^2.0.5"
949
+ },
950
+ "bin": {
951
+ "nodemon": "bin/nodemon.js"
952
+ },
953
+ "engines": {
954
+ "node": ">=10"
955
+ },
956
+ "funding": {
957
+ "type": "opencollective",
958
+ "url": "https://opencollective.com/nodemon"
959
+ }
960
+ },
961
+ "node_modules/nodemon/node_modules/debug": {
962
+ "version": "4.4.3",
963
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
964
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
965
+ "dev": true,
966
+ "license": "MIT",
967
+ "dependencies": {
968
+ "ms": "^2.1.3"
969
+ },
970
+ "engines": {
971
+ "node": ">=6.0"
972
+ },
973
+ "peerDependenciesMeta": {
974
+ "supports-color": {
975
+ "optional": true
976
+ }
977
+ }
978
+ },
979
+ "node_modules/nodemon/node_modules/ms": {
980
+ "version": "2.1.3",
981
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
982
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
983
+ "dev": true,
984
+ "license": "MIT"
985
+ },
986
+ "node_modules/normalize-path": {
987
+ "version": "3.0.0",
988
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
989
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
990
+ "dev": true,
991
+ "license": "MIT",
992
+ "engines": {
993
+ "node": ">=0.10.0"
994
+ }
995
+ },
996
+ "node_modules/object-assign": {
997
+ "version": "4.1.1",
998
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
999
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1000
+ "license": "MIT",
1001
+ "engines": {
1002
+ "node": ">=0.10.0"
1003
+ }
1004
+ },
1005
+ "node_modules/object-inspect": {
1006
+ "version": "1.13.4",
1007
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
1008
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
1009
+ "license": "MIT",
1010
+ "engines": {
1011
+ "node": ">= 0.4"
1012
+ },
1013
+ "funding": {
1014
+ "url": "https://github.com/sponsors/ljharb"
1015
+ }
1016
+ },
1017
+ "node_modules/on-finished": {
1018
+ "version": "2.4.1",
1019
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1020
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1021
+ "license": "MIT",
1022
+ "dependencies": {
1023
+ "ee-first": "1.1.1"
1024
+ },
1025
+ "engines": {
1026
+ "node": ">= 0.8"
1027
+ }
1028
+ },
1029
+ "node_modules/parseurl": {
1030
+ "version": "1.3.3",
1031
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1032
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1033
+ "license": "MIT",
1034
+ "engines": {
1035
+ "node": ">= 0.8"
1036
+ }
1037
+ },
1038
+ "node_modules/path-to-regexp": {
1039
+ "version": "0.1.13",
1040
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
1041
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
1042
+ "license": "MIT"
1043
+ },
1044
+ "node_modules/pg": {
1045
+ "version": "8.20.0",
1046
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
1047
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
1048
+ "license": "MIT",
1049
+ "dependencies": {
1050
+ "pg-connection-string": "^2.12.0",
1051
+ "pg-pool": "^3.13.0",
1052
+ "pg-protocol": "^1.13.0",
1053
+ "pg-types": "2.2.0",
1054
+ "pgpass": "1.0.5"
1055
+ },
1056
+ "engines": {
1057
+ "node": ">= 16.0.0"
1058
+ },
1059
+ "optionalDependencies": {
1060
+ "pg-cloudflare": "^1.3.0"
1061
+ },
1062
+ "peerDependencies": {
1063
+ "pg-native": ">=3.0.1"
1064
+ },
1065
+ "peerDependenciesMeta": {
1066
+ "pg-native": {
1067
+ "optional": true
1068
+ }
1069
+ }
1070
+ },
1071
+ "node_modules/pg-cloudflare": {
1072
+ "version": "1.3.0",
1073
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
1074
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
1075
+ "license": "MIT",
1076
+ "optional": true
1077
+ },
1078
+ "node_modules/pg-connection-string": {
1079
+ "version": "2.12.0",
1080
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
1081
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
1082
+ "license": "MIT"
1083
+ },
1084
+ "node_modules/pg-int8": {
1085
+ "version": "1.0.1",
1086
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
1087
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
1088
+ "license": "ISC",
1089
+ "engines": {
1090
+ "node": ">=4.0.0"
1091
+ }
1092
+ },
1093
+ "node_modules/pg-pool": {
1094
+ "version": "3.13.0",
1095
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
1096
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
1097
+ "license": "MIT",
1098
+ "peerDependencies": {
1099
+ "pg": ">=8.0"
1100
+ }
1101
+ },
1102
+ "node_modules/pg-protocol": {
1103
+ "version": "1.13.0",
1104
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
1105
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
1106
+ "license": "MIT"
1107
+ },
1108
+ "node_modules/pg-types": {
1109
+ "version": "2.2.0",
1110
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
1111
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
1112
+ "license": "MIT",
1113
+ "dependencies": {
1114
+ "pg-int8": "1.0.1",
1115
+ "postgres-array": "~2.0.0",
1116
+ "postgres-bytea": "~1.0.0",
1117
+ "postgres-date": "~1.0.4",
1118
+ "postgres-interval": "^1.1.0"
1119
+ },
1120
+ "engines": {
1121
+ "node": ">=4"
1122
+ }
1123
+ },
1124
+ "node_modules/pgpass": {
1125
+ "version": "1.0.5",
1126
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
1127
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
1128
+ "license": "MIT",
1129
+ "dependencies": {
1130
+ "split2": "^4.1.0"
1131
+ }
1132
+ },
1133
+ "node_modules/picomatch": {
1134
+ "version": "2.3.2",
1135
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
1136
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
1137
+ "dev": true,
1138
+ "license": "MIT",
1139
+ "engines": {
1140
+ "node": ">=8.6"
1141
+ },
1142
+ "funding": {
1143
+ "url": "https://github.com/sponsors/jonschlinkert"
1144
+ }
1145
+ },
1146
+ "node_modules/postgres-array": {
1147
+ "version": "2.0.0",
1148
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
1149
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
1150
+ "license": "MIT",
1151
+ "engines": {
1152
+ "node": ">=4"
1153
+ }
1154
+ },
1155
+ "node_modules/postgres-bytea": {
1156
+ "version": "1.0.1",
1157
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
1158
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
1159
+ "license": "MIT",
1160
+ "engines": {
1161
+ "node": ">=0.10.0"
1162
+ }
1163
+ },
1164
+ "node_modules/postgres-date": {
1165
+ "version": "1.0.7",
1166
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
1167
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
1168
+ "license": "MIT",
1169
+ "engines": {
1170
+ "node": ">=0.10.0"
1171
+ }
1172
+ },
1173
+ "node_modules/postgres-interval": {
1174
+ "version": "1.2.0",
1175
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
1176
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
1177
+ "license": "MIT",
1178
+ "dependencies": {
1179
+ "xtend": "^4.0.0"
1180
+ },
1181
+ "engines": {
1182
+ "node": ">=0.10.0"
1183
+ }
1184
+ },
1185
+ "node_modules/proxy-addr": {
1186
+ "version": "2.0.7",
1187
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
1188
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
1189
+ "license": "MIT",
1190
+ "dependencies": {
1191
+ "forwarded": "0.2.0",
1192
+ "ipaddr.js": "1.9.1"
1193
+ },
1194
+ "engines": {
1195
+ "node": ">= 0.10"
1196
+ }
1197
+ },
1198
+ "node_modules/pstree.remy": {
1199
+ "version": "1.1.8",
1200
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
1201
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
1202
+ "dev": true,
1203
+ "license": "MIT"
1204
+ },
1205
+ "node_modules/qs": {
1206
+ "version": "6.14.2",
1207
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
1208
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
1209
+ "license": "BSD-3-Clause",
1210
+ "dependencies": {
1211
+ "side-channel": "^1.1.0"
1212
+ },
1213
+ "engines": {
1214
+ "node": ">=0.6"
1215
+ },
1216
+ "funding": {
1217
+ "url": "https://github.com/sponsors/ljharb"
1218
+ }
1219
+ },
1220
+ "node_modules/range-parser": {
1221
+ "version": "1.2.1",
1222
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
1223
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
1224
+ "license": "MIT",
1225
+ "engines": {
1226
+ "node": ">= 0.6"
1227
+ }
1228
+ },
1229
+ "node_modules/raw-body": {
1230
+ "version": "2.5.3",
1231
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
1232
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
1233
+ "license": "MIT",
1234
+ "dependencies": {
1235
+ "bytes": "~3.1.2",
1236
+ "http-errors": "~2.0.1",
1237
+ "iconv-lite": "~0.4.24",
1238
+ "unpipe": "~1.0.0"
1239
+ },
1240
+ "engines": {
1241
+ "node": ">= 0.8"
1242
+ }
1243
+ },
1244
+ "node_modules/readdirp": {
1245
+ "version": "3.6.0",
1246
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
1247
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1248
+ "dev": true,
1249
+ "license": "MIT",
1250
+ "dependencies": {
1251
+ "picomatch": "^2.2.1"
1252
+ },
1253
+ "engines": {
1254
+ "node": ">=8.10.0"
1255
+ }
1256
+ },
1257
+ "node_modules/safe-buffer": {
1258
+ "version": "5.2.1",
1259
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
1260
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
1261
+ "funding": [
1262
+ {
1263
+ "type": "github",
1264
+ "url": "https://github.com/sponsors/feross"
1265
+ },
1266
+ {
1267
+ "type": "patreon",
1268
+ "url": "https://www.patreon.com/feross"
1269
+ },
1270
+ {
1271
+ "type": "consulting",
1272
+ "url": "https://feross.org/support"
1273
+ }
1274
+ ],
1275
+ "license": "MIT"
1276
+ },
1277
+ "node_modules/safer-buffer": {
1278
+ "version": "2.1.2",
1279
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1280
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
1281
+ "license": "MIT"
1282
+ },
1283
+ "node_modules/semver": {
1284
+ "version": "7.7.4",
1285
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
1286
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
1287
+ "license": "ISC",
1288
+ "bin": {
1289
+ "semver": "bin/semver.js"
1290
+ },
1291
+ "engines": {
1292
+ "node": ">=10"
1293
+ }
1294
+ },
1295
+ "node_modules/send": {
1296
+ "version": "0.19.2",
1297
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
1298
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
1299
+ "license": "MIT",
1300
+ "dependencies": {
1301
+ "debug": "2.6.9",
1302
+ "depd": "2.0.0",
1303
+ "destroy": "1.2.0",
1304
+ "encodeurl": "~2.0.0",
1305
+ "escape-html": "~1.0.3",
1306
+ "etag": "~1.8.1",
1307
+ "fresh": "~0.5.2",
1308
+ "http-errors": "~2.0.1",
1309
+ "mime": "1.6.0",
1310
+ "ms": "2.1.3",
1311
+ "on-finished": "~2.4.1",
1312
+ "range-parser": "~1.2.1",
1313
+ "statuses": "~2.0.2"
1314
+ },
1315
+ "engines": {
1316
+ "node": ">= 0.8.0"
1317
+ }
1318
+ },
1319
+ "node_modules/send/node_modules/ms": {
1320
+ "version": "2.1.3",
1321
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1322
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1323
+ "license": "MIT"
1324
+ },
1325
+ "node_modules/serve-static": {
1326
+ "version": "1.16.3",
1327
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
1328
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
1329
+ "license": "MIT",
1330
+ "dependencies": {
1331
+ "encodeurl": "~2.0.0",
1332
+ "escape-html": "~1.0.3",
1333
+ "parseurl": "~1.3.3",
1334
+ "send": "~0.19.1"
1335
+ },
1336
+ "engines": {
1337
+ "node": ">= 0.8.0"
1338
+ }
1339
+ },
1340
+ "node_modules/setprototypeof": {
1341
+ "version": "1.2.0",
1342
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1343
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1344
+ "license": "ISC"
1345
+ },
1346
+ "node_modules/side-channel": {
1347
+ "version": "1.1.0",
1348
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
1349
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
1350
+ "license": "MIT",
1351
+ "dependencies": {
1352
+ "es-errors": "^1.3.0",
1353
+ "object-inspect": "^1.13.3",
1354
+ "side-channel-list": "^1.0.0",
1355
+ "side-channel-map": "^1.0.1",
1356
+ "side-channel-weakmap": "^1.0.2"
1357
+ },
1358
+ "engines": {
1359
+ "node": ">= 0.4"
1360
+ },
1361
+ "funding": {
1362
+ "url": "https://github.com/sponsors/ljharb"
1363
+ }
1364
+ },
1365
+ "node_modules/side-channel-list": {
1366
+ "version": "1.0.1",
1367
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
1368
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
1369
+ "license": "MIT",
1370
+ "dependencies": {
1371
+ "es-errors": "^1.3.0",
1372
+ "object-inspect": "^1.13.4"
1373
+ },
1374
+ "engines": {
1375
+ "node": ">= 0.4"
1376
+ },
1377
+ "funding": {
1378
+ "url": "https://github.com/sponsors/ljharb"
1379
+ }
1380
+ },
1381
+ "node_modules/side-channel-map": {
1382
+ "version": "1.0.1",
1383
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1384
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1385
+ "license": "MIT",
1386
+ "dependencies": {
1387
+ "call-bound": "^1.0.2",
1388
+ "es-errors": "^1.3.0",
1389
+ "get-intrinsic": "^1.2.5",
1390
+ "object-inspect": "^1.13.3"
1391
+ },
1392
+ "engines": {
1393
+ "node": ">= 0.4"
1394
+ },
1395
+ "funding": {
1396
+ "url": "https://github.com/sponsors/ljharb"
1397
+ }
1398
+ },
1399
+ "node_modules/side-channel-weakmap": {
1400
+ "version": "1.0.2",
1401
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1402
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1403
+ "license": "MIT",
1404
+ "dependencies": {
1405
+ "call-bound": "^1.0.2",
1406
+ "es-errors": "^1.3.0",
1407
+ "get-intrinsic": "^1.2.5",
1408
+ "object-inspect": "^1.13.3",
1409
+ "side-channel-map": "^1.0.1"
1410
+ },
1411
+ "engines": {
1412
+ "node": ">= 0.4"
1413
+ },
1414
+ "funding": {
1415
+ "url": "https://github.com/sponsors/ljharb"
1416
+ }
1417
+ },
1418
+ "node_modules/simple-update-notifier": {
1419
+ "version": "2.0.0",
1420
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
1421
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
1422
+ "dev": true,
1423
+ "license": "MIT",
1424
+ "dependencies": {
1425
+ "semver": "^7.5.3"
1426
+ },
1427
+ "engines": {
1428
+ "node": ">=10"
1429
+ }
1430
+ },
1431
+ "node_modules/split2": {
1432
+ "version": "4.2.0",
1433
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
1434
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
1435
+ "license": "ISC",
1436
+ "engines": {
1437
+ "node": ">= 10.x"
1438
+ }
1439
+ },
1440
+ "node_modules/statuses": {
1441
+ "version": "2.0.2",
1442
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
1443
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
1444
+ "license": "MIT",
1445
+ "engines": {
1446
+ "node": ">= 0.8"
1447
+ }
1448
+ },
1449
+ "node_modules/supports-color": {
1450
+ "version": "5.5.0",
1451
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
1452
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
1453
+ "dev": true,
1454
+ "license": "MIT",
1455
+ "dependencies": {
1456
+ "has-flag": "^3.0.0"
1457
+ },
1458
+ "engines": {
1459
+ "node": ">=4"
1460
+ }
1461
+ },
1462
+ "node_modules/to-regex-range": {
1463
+ "version": "5.0.1",
1464
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
1465
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
1466
+ "dev": true,
1467
+ "license": "MIT",
1468
+ "dependencies": {
1469
+ "is-number": "^7.0.0"
1470
+ },
1471
+ "engines": {
1472
+ "node": ">=8.0"
1473
+ }
1474
+ },
1475
+ "node_modules/toidentifier": {
1476
+ "version": "1.0.1",
1477
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1478
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1479
+ "license": "MIT",
1480
+ "engines": {
1481
+ "node": ">=0.6"
1482
+ }
1483
+ },
1484
+ "node_modules/touch": {
1485
+ "version": "3.1.1",
1486
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
1487
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
1488
+ "dev": true,
1489
+ "license": "ISC",
1490
+ "bin": {
1491
+ "nodetouch": "bin/nodetouch.js"
1492
+ }
1493
+ },
1494
+ "node_modules/type-is": {
1495
+ "version": "1.6.18",
1496
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
1497
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
1498
+ "license": "MIT",
1499
+ "dependencies": {
1500
+ "media-typer": "0.3.0",
1501
+ "mime-types": "~2.1.24"
1502
+ },
1503
+ "engines": {
1504
+ "node": ">= 0.6"
1505
+ }
1506
+ },
1507
+ "node_modules/undefsafe": {
1508
+ "version": "2.0.5",
1509
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
1510
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
1511
+ "dev": true,
1512
+ "license": "MIT"
1513
+ },
1514
+ "node_modules/unpipe": {
1515
+ "version": "1.0.0",
1516
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1517
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1518
+ "license": "MIT",
1519
+ "engines": {
1520
+ "node": ">= 0.8"
1521
+ }
1522
+ },
1523
+ "node_modules/utils-merge": {
1524
+ "version": "1.0.1",
1525
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
1526
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
1527
+ "license": "MIT",
1528
+ "engines": {
1529
+ "node": ">= 0.4.0"
1530
+ }
1531
+ },
1532
+ "node_modules/uuid": {
1533
+ "version": "9.0.1",
1534
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
1535
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
1536
+ "funding": [
1537
+ "https://github.com/sponsors/broofa",
1538
+ "https://github.com/sponsors/ctavan"
1539
+ ],
1540
+ "license": "MIT",
1541
+ "bin": {
1542
+ "uuid": "dist/bin/uuid"
1543
+ }
1544
+ },
1545
+ "node_modules/validator": {
1546
+ "version": "13.15.35",
1547
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
1548
+ "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
1549
+ "license": "MIT",
1550
+ "engines": {
1551
+ "node": ">= 0.10"
1552
+ }
1553
+ },
1554
+ "node_modules/vary": {
1555
+ "version": "1.1.2",
1556
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1557
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1558
+ "license": "MIT",
1559
+ "engines": {
1560
+ "node": ">= 0.8"
1561
+ }
1562
+ },
1563
+ "node_modules/xtend": {
1564
+ "version": "4.0.2",
1565
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
1566
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
1567
+ "license": "MIT",
1568
+ "engines": {
1569
+ "node": ">=0.4"
1570
+ }
1571
+ }
1572
+ }
1573
+ }
backend/package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "taskflow-backend",
3
+ "version": "1.0.0",
4
+ "description": "Multi-tenant task management API",
5
+ "main": "src/server.js",
6
+ "scripts": {
7
+ "start": "node src/server.js",
8
+ "dev": "nodemon src/server.js",
9
+ "migrate": "node src/utils/migrate.js",
10
+ "seed": "node src/utils/seed.js"
11
+ },
12
+ "dependencies": {
13
+ "bcryptjs": "^2.4.3",
14
+ "cors": "^2.8.5",
15
+ "dotenv": "^16.3.1",
16
+ "express": "^4.18.2",
17
+ "express-rate-limit": "^7.1.5",
18
+ "express-validator": "^7.0.1",
19
+ "helmet": "^7.1.0",
20
+ "jsonwebtoken": "^9.0.2",
21
+ "pg": "^8.11.3",
22
+ "uuid": "^9.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "nodemon": "^3.0.2"
26
+ }
27
+ }
backend/src/controllers/adminController.js ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { query } = require('../utils/db');
2
+ const { auditLog } = require('../utils/auditLogger');
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const crypto = require('crypto');
5
+
6
+ /**
7
+ * GET /api/admin/users
8
+ * List all users in the admin's organization
9
+ */
10
+ const listUsers = async (req, res) => {
11
+ try {
12
+ const { organization_id: orgId } = req.user;
13
+
14
+ const { rows } = await query(
15
+ `SELECT id, name, email, role, is_active, created_at, updated_at
16
+ FROM users
17
+ WHERE organization_id = $1
18
+ ORDER BY created_at ASC`,
19
+ [orgId]
20
+ );
21
+
22
+ return res.json(rows);
23
+ } catch (err) {
24
+ console.error('List users error:', err);
25
+ return res.status(500).json({ error: 'Failed to retrieve users.' });
26
+ }
27
+ };
28
+
29
+ /**
30
+ * PATCH /api/admin/users/:id/role
31
+ * Change a user's role
32
+ */
33
+ const updateUserRole = async (req, res) => {
34
+ try {
35
+ const { organization_id: orgId, id: actorId, name: actorName, email: actorEmail } = req.user;
36
+ const { id } = req.params;
37
+ const { role } = req.body;
38
+
39
+ if (!['admin', 'member'].includes(role)) {
40
+ return res.status(400).json({ error: 'Role must be admin or member.' });
41
+ }
42
+
43
+ // Cannot change own role
44
+ if (id === actorId) {
45
+ return res.status(400).json({ error: 'You cannot change your own role.' });
46
+ }
47
+
48
+ const { rows } = await query(
49
+ `UPDATE users SET role = $1, updated_at = NOW()
50
+ WHERE id = $2 AND organization_id = $3
51
+ RETURNING id, name, email, role, is_active`,
52
+ [role, id, orgId]
53
+ );
54
+
55
+ if (!rows.length) {
56
+ return res.status(404).json({ error: 'User not found in your organization.' });
57
+ }
58
+
59
+ await auditLog({
60
+ organizationId: orgId,
61
+ actorId,
62
+ actorName,
63
+ actorEmail,
64
+ action: 'USER_ROLE_CHANGED',
65
+ entityType: 'user',
66
+ newValues: { userId: id, newRole: role },
67
+ });
68
+
69
+ return res.json(rows[0]);
70
+ } catch (err) {
71
+ console.error('Update role error:', err);
72
+ return res.status(500).json({ error: 'Failed to update role.' });
73
+ }
74
+ };
75
+
76
+ /**
77
+ * PATCH /api/admin/users/:id/deactivate
78
+ */
79
+ const deactivateUser = async (req, res) => {
80
+ try {
81
+ const { organization_id: orgId, id: actorId, name: actorName, email: actorEmail } = req.user;
82
+ const { id } = req.params;
83
+
84
+ if (id === actorId) {
85
+ return res.status(400).json({ error: 'You cannot deactivate your own account.' });
86
+ }
87
+
88
+ const { rows } = await query(
89
+ `UPDATE users SET is_active = false, updated_at = NOW()
90
+ WHERE id = $1 AND organization_id = $2
91
+ RETURNING id, name, email, role, is_active`,
92
+ [id, orgId]
93
+ );
94
+
95
+ if (!rows.length) {
96
+ return res.status(404).json({ error: 'User not found in your organization.' });
97
+ }
98
+
99
+ await auditLog({
100
+ organizationId: orgId,
101
+ actorId,
102
+ actorName,
103
+ actorEmail,
104
+ action: 'USER_DEACTIVATED',
105
+ entityType: 'user',
106
+ newValues: { userId: id },
107
+ });
108
+
109
+ return res.json(rows[0]);
110
+ } catch (err) {
111
+ console.error('Deactivate user error:', err);
112
+ return res.status(500).json({ error: 'Failed to deactivate user.' });
113
+ }
114
+ };
115
+
116
+ /**
117
+ * POST /api/admin/invites
118
+ * Send an invite to join the organization
119
+ */
120
+ const createInvite = async (req, res) => {
121
+ try {
122
+ const { organization_id: orgId, id: actorId, name: actorName, email: actorEmail } = req.user;
123
+ const { email, role = 'member' } = req.body;
124
+
125
+ if (!['admin', 'member'].includes(role)) {
126
+ return res.status(400).json({ error: 'Role must be admin or member.' });
127
+ }
128
+
129
+ // Check if email already a member
130
+ const existing = await query(`SELECT id FROM users WHERE email = $1`, [email.toLowerCase()]);
131
+ if (existing.rows.length) {
132
+ return res.status(409).json({ error: 'A user with this email already exists.' });
133
+ }
134
+
135
+ const token = crypto.randomBytes(32).toString('hex');
136
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
137
+
138
+ const { rows } = await query(
139
+ `INSERT INTO invites (id, organization_id, email, role, token, invited_by, expires_at)
140
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
141
+ RETURNING id, email, role, token, expires_at`,
142
+ [uuidv4(), orgId, email.toLowerCase(), role, token, actorId, expiresAt]
143
+ );
144
+
145
+ await auditLog({
146
+ organizationId: orgId,
147
+ actorId,
148
+ actorName,
149
+ actorEmail,
150
+ action: 'USER_INVITED',
151
+ entityType: 'invite',
152
+ newValues: { invitedEmail: email, role },
153
+ });
154
+
155
+ return res.status(201).json({
156
+ ...rows[0],
157
+ inviteUrl: `${process.env.FRONTEND_URL}/accept-invite?token=${token}`,
158
+ });
159
+ } catch (err) {
160
+ console.error('Create invite error:', err);
161
+ return res.status(500).json({ error: 'Failed to create invite.' });
162
+ }
163
+ };
164
+
165
+ /**
166
+ * GET /api/admin/invites
167
+ */
168
+ const listInvites = async (req, res) => {
169
+ try {
170
+ const { organization_id: orgId } = req.user;
171
+
172
+ const { rows } = await query(
173
+ `SELECT i.id, i.email, i.role, i.expires_at, i.used_at, i.created_at,
174
+ u.name AS invited_by_name
175
+ FROM invites i
176
+ LEFT JOIN users u ON u.id = i.invited_by
177
+ WHERE i.organization_id = $1
178
+ ORDER BY i.created_at DESC`,
179
+ [orgId]
180
+ );
181
+
182
+ return res.json(rows);
183
+ } catch (err) {
184
+ console.error('List invites error:', err);
185
+ return res.status(500).json({ error: 'Failed to retrieve invites.' });
186
+ }
187
+ };
188
+
189
+ /**
190
+ * GET /api/admin/audit-logs
191
+ */
192
+ const getAuditLogs = async (req, res) => {
193
+ try {
194
+ const { organization_id: orgId } = req.user;
195
+ const { page = 1, limit = 50, action, task_id } = req.query;
196
+
197
+ const pageNum = Math.max(1, parseInt(page));
198
+ const limitNum = Math.min(200, Math.max(1, parseInt(limit)));
199
+ const offset = (pageNum - 1) * limitNum;
200
+
201
+ const conditions = [`organization_id = $1`];
202
+ const params = [orgId];
203
+ let idx = 2;
204
+
205
+ if (action) {
206
+ conditions.push(`action = $${idx++}`);
207
+ params.push(action);
208
+ }
209
+ if (task_id) {
210
+ conditions.push(`task_id = $${idx++}`);
211
+ params.push(task_id);
212
+ }
213
+
214
+ const where = `WHERE ${conditions.join(' AND ')}`;
215
+
216
+ const countResult = await query(`SELECT COUNT(*) FROM audit_logs ${where}`, params);
217
+ const total = parseInt(countResult.rows[0].count);
218
+
219
+ const { rows } = await query(
220
+ `SELECT id, task_id, actor_id, actor_name, actor_email, action, entity_type,
221
+ old_values, new_values, metadata, created_at
222
+ FROM audit_logs
223
+ ${where}
224
+ ORDER BY created_at DESC
225
+ LIMIT $${idx} OFFSET $${idx + 1}`,
226
+ [...params, limitNum, offset]
227
+ );
228
+
229
+ return res.json({
230
+ logs: rows,
231
+ pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum) },
232
+ });
233
+ } catch (err) {
234
+ console.error('Audit logs error:', err);
235
+ return res.status(500).json({ error: 'Failed to retrieve audit logs.' });
236
+ }
237
+ };
238
+
239
+ /**
240
+ * GET /api/admin/org
241
+ * Get organization info
242
+ */
243
+ const getOrg = async (req, res) => {
244
+ try {
245
+ const { organization_id: orgId } = req.user;
246
+ const { rows } = await query(`SELECT id, name, slug, created_at FROM organizations WHERE id = $1`, [orgId]);
247
+ return res.json(rows[0]);
248
+ } catch (err) {
249
+ return res.status(500).json({ error: 'Failed to retrieve organization.' });
250
+ }
251
+ };
252
+
253
+ /**
254
+ * GET /api/users - org-scoped user list for dropdowns (any authenticated user)
255
+ */
256
+ const listOrgUsers = async (req, res) => {
257
+ try {
258
+ const { organization_id: orgId } = req.user;
259
+ const { rows } = await query(
260
+ `SELECT id, name, email, role FROM users WHERE organization_id = $1 AND is_active = true ORDER BY name`,
261
+ [orgId]
262
+ );
263
+ return res.json(rows);
264
+ } catch (err) {
265
+ return res.status(500).json({ error: 'Failed to retrieve users.' });
266
+ }
267
+ };
268
+
269
+ module.exports = { listUsers, updateUserRole, deactivateUser, createInvite, listInvites, getAuditLogs, getOrg, listOrgUsers };
backend/src/controllers/authController.js ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const bcrypt = require('bcryptjs');
2
+ const { v4: uuidv4 } = require('uuid');
3
+ const { query } = require('../utils/db');
4
+ const { generateToken, buildTokenPayload } = require('../utils/jwt');
5
+ const { auditLog } = require('../utils/auditLogger');
6
+
7
+ /**
8
+ * POST /api/auth/register
9
+ * Register a new organization + admin user
10
+ */
11
+ const register = async (req, res) => {
12
+ try {
13
+ const { orgName, name, email, password } = req.body;
14
+
15
+ // Check email uniqueness across all orgs (global unique email)
16
+ const existing = await query(`SELECT id FROM users WHERE email = $1`, [email.toLowerCase()]);
17
+ if (existing.rows.length) {
18
+ return res.status(409).json({ error: 'An account with this email already exists.' });
19
+ }
20
+
21
+ // Generate org slug
22
+ const baseSlug = orgName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
23
+ let slug = baseSlug;
24
+ let suffix = 1;
25
+ while (true) {
26
+ const check = await query(`SELECT id FROM organizations WHERE slug = $1`, [slug]);
27
+ if (!check.rows.length) break;
28
+ slug = `${baseSlug}-${suffix++}`;
29
+ }
30
+
31
+ const orgId = uuidv4();
32
+ const userId = uuidv4();
33
+ const passwordHash = await bcrypt.hash(password, 12);
34
+
35
+ await query(`INSERT INTO organizations (id, name, slug) VALUES ($1, $2, $3)`, [orgId, orgName, slug]);
36
+ await query(
37
+ `INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES ($1, $2, $3, $4, $5, 'admin')`,
38
+ [userId, orgId, name, email.toLowerCase(), passwordHash]
39
+ );
40
+
41
+ const user = { id: userId, name, email: email.toLowerCase(), role: 'admin', organization_id: orgId, org_slug: slug };
42
+ const token = generateToken(buildTokenPayload(user));
43
+
44
+ await auditLog({
45
+ organizationId: orgId,
46
+ actorId: userId,
47
+ actorName: name,
48
+ actorEmail: email.toLowerCase(),
49
+ action: 'USER_REGISTERED',
50
+ entityType: 'user',
51
+ newValues: { userId, orgId, role: 'admin' },
52
+ });
53
+
54
+ return res.status(201).json({
55
+ token,
56
+ user: { id: userId, name, email: email.toLowerCase(), role: 'admin', organizationId: orgId, orgSlug: slug, orgName },
57
+ });
58
+ } catch (err) {
59
+ console.error('Register error:', err);
60
+ return res.status(500).json({ error: 'Registration failed.' });
61
+ }
62
+ };
63
+
64
+ /**
65
+ * POST /api/auth/login
66
+ */
67
+ const login = async (req, res) => {
68
+ try {
69
+ const { email, password } = req.body;
70
+
71
+ const { rows } = await query(
72
+ `SELECT u.id, u.name, u.email, u.password_hash, u.role, u.organization_id, u.is_active,
73
+ o.slug as org_slug, o.name as org_name
74
+ FROM users u
75
+ JOIN organizations o ON o.id = u.organization_id
76
+ WHERE u.email = $1`,
77
+ [email.toLowerCase()]
78
+ );
79
+
80
+ if (!rows.length) {
81
+ return res.status(401).json({ error: 'Invalid email or password.' });
82
+ }
83
+
84
+ const user = rows[0];
85
+ if (!user.is_active) {
86
+ return res.status(403).json({ error: 'Your account has been deactivated.' });
87
+ }
88
+
89
+ if (!user.password_hash) {
90
+ return res.status(401).json({ error: 'This account uses OAuth login. Please sign in with your provider.' });
91
+ }
92
+
93
+ const valid = await bcrypt.compare(password, user.password_hash);
94
+ if (!valid) {
95
+ return res.status(401).json({ error: 'Invalid email or password.' });
96
+ }
97
+
98
+ const token = generateToken(buildTokenPayload(user));
99
+
100
+ return res.json({
101
+ token,
102
+ user: {
103
+ id: user.id,
104
+ name: user.name,
105
+ email: user.email,
106
+ role: user.role,
107
+ organizationId: user.organization_id,
108
+ orgSlug: user.org_slug,
109
+ orgName: user.org_name,
110
+ },
111
+ });
112
+ } catch (err) {
113
+ console.error('Login error:', err);
114
+ return res.status(500).json({ error: 'Login failed.' });
115
+ }
116
+ };
117
+
118
+ /**
119
+ * GET /api/auth/me
120
+ */
121
+ const getMe = async (req, res) => {
122
+ const u = req.user;
123
+ return res.json({
124
+ id: u.id,
125
+ name: u.name,
126
+ email: u.email,
127
+ role: u.role,
128
+ organizationId: u.organization_id,
129
+ orgSlug: u.org_slug,
130
+ orgName: u.org_name,
131
+ });
132
+ };
133
+
134
+ /**
135
+ * POST /api/auth/accept-invite
136
+ */
137
+ const acceptInvite = async (req, res) => {
138
+ try {
139
+ const { token, name, password } = req.body;
140
+
141
+ const { rows: invites } = await query(
142
+ `SELECT * FROM invites WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
143
+ [token]
144
+ );
145
+
146
+ if (!invites.length) {
147
+ return res.status(400).json({ error: 'Invite link is invalid or has expired.' });
148
+ }
149
+
150
+ const invite = invites[0];
151
+
152
+ // Check email not already registered in this org
153
+ const dup = await query(`SELECT id FROM users WHERE email = $1`, [invite.email.toLowerCase()]);
154
+ if (dup.rows.length) {
155
+ return res.status(409).json({ error: 'An account with this email already exists.' });
156
+ }
157
+
158
+ const userId = uuidv4();
159
+ const passwordHash = await bcrypt.hash(password, 12);
160
+
161
+ await query(
162
+ `INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES ($1, $2, $3, $4, $5, $6)`,
163
+ [userId, invite.organization_id, name, invite.email.toLowerCase(), passwordHash, invite.role]
164
+ );
165
+ await query(`UPDATE invites SET used_at = NOW() WHERE id = $1`, [invite.id]);
166
+
167
+ const { rows: orgs } = await query(`SELECT slug, name FROM organizations WHERE id = $1`, [invite.organization_id]);
168
+ const org = orgs[0];
169
+
170
+ const user = { id: userId, name, email: invite.email, role: invite.role, organization_id: invite.organization_id, org_slug: org.slug };
171
+ const authToken = generateToken(buildTokenPayload(user));
172
+
173
+ await auditLog({
174
+ organizationId: invite.organization_id,
175
+ actorId: userId,
176
+ actorName: name,
177
+ actorEmail: invite.email.toLowerCase(),
178
+ action: 'USER_JOINED_VIA_INVITE',
179
+ entityType: 'user',
180
+ newValues: { userId, role: invite.role },
181
+ });
182
+
183
+ return res.status(201).json({
184
+ token: authToken,
185
+ user: { id: userId, name, email: invite.email, role: invite.role, organizationId: invite.organization_id, orgSlug: org.slug, orgName: org.name },
186
+ });
187
+ } catch (err) {
188
+ console.error('Accept invite error:', err);
189
+ return res.status(500).json({ error: 'Failed to accept invite.' });
190
+ }
191
+ };
192
+
193
+ module.exports = { register, login, getMe, acceptInvite };
backend/src/controllers/commentController.js ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { query } = require('../utils/db');
2
+ const { auditLog } = require('../utils/auditLogger');
3
+ const { v4: uuidv4 } = require('uuid');
4
+
5
+ /**
6
+ * GET /api/tasks/:id/comments
7
+ */
8
+ const listComments = async (req, res) => {
9
+ try {
10
+ const { organization_id: orgId, id: userId, role } = req.user;
11
+ const { id: taskId } = req.params;
12
+
13
+ // Verify task belongs to org
14
+ const taskCheck = await query(
15
+ `SELECT id, creator_id, assignee_id FROM tasks WHERE id = $1 AND organization_id = $2`,
16
+ [taskId, orgId]
17
+ );
18
+ if (!taskCheck.rows.length) return res.status(404).json({ error: 'Task not found.' });
19
+
20
+ const task = taskCheck.rows[0];
21
+ if (role === 'member' && task.creator_id !== userId && task.assignee_id !== userId) {
22
+ return res.status(403).json({ error: 'Access denied.' });
23
+ }
24
+
25
+ const { rows } = await query(
26
+ `SELECT c.id, c.body, c.created_at, c.updated_at,
27
+ c.author_id, u.name AS author_name, u.email AS author_email
28
+ FROM task_comments c
29
+ JOIN users u ON u.id = c.author_id
30
+ WHERE c.task_id = $1 AND c.organization_id = $2
31
+ ORDER BY c.created_at ASC`,
32
+ [taskId, orgId]
33
+ );
34
+ return res.json(rows);
35
+ } catch (err) {
36
+ console.error('List comments error:', err);
37
+ return res.status(500).json({ error: 'Failed to retrieve comments.' });
38
+ }
39
+ };
40
+
41
+ /**
42
+ * POST /api/tasks/:id/comments
43
+ */
44
+ const addComment = async (req, res) => {
45
+ try {
46
+ const { organization_id: orgId, id: userId, name: userName, email: userEmail, role } = req.user;
47
+ const { id: taskId } = req.params;
48
+ const { body } = req.body;
49
+
50
+ if (!body || !body.trim()) {
51
+ return res.status(422).json({ error: 'Comment cannot be empty.' });
52
+ }
53
+
54
+ const taskCheck = await query(
55
+ `SELECT id, creator_id, assignee_id FROM tasks WHERE id = $1 AND organization_id = $2`,
56
+ [taskId, orgId]
57
+ );
58
+ if (!taskCheck.rows.length) return res.status(404).json({ error: 'Task not found.' });
59
+
60
+ const task = taskCheck.rows[0];
61
+ if (role === 'member' && task.creator_id !== userId && task.assignee_id !== userId) {
62
+ return res.status(403).json({ error: 'Access denied.' });
63
+ }
64
+
65
+ const { rows } = await query(
66
+ `INSERT INTO task_comments (id, task_id, organization_id, author_id, body)
67
+ VALUES ($1, $2, $3, $4, $5)
68
+ RETURNING id, body, created_at, author_id`,
69
+ [uuidv4(), taskId, orgId, userId, body.trim()]
70
+ );
71
+
72
+ // Touch task updated_at so it surfaces in recent activity
73
+ await query(`UPDATE tasks SET updated_at = NOW() WHERE id = $1`, [taskId]);
74
+
75
+ await auditLog({
76
+ organizationId: orgId,
77
+ taskId,
78
+ actorId: userId,
79
+ actorName: userName,
80
+ actorEmail: userEmail,
81
+ action: 'COMMENT_ADDED',
82
+ entityType: 'comment',
83
+ newValues: { body: body.trim().slice(0, 120) },
84
+ });
85
+
86
+ return res.status(201).json({
87
+ ...rows[0],
88
+ author_name: userName,
89
+ author_email: userEmail,
90
+ });
91
+ } catch (err) {
92
+ console.error('Add comment error:', err);
93
+ return res.status(500).json({ error: 'Failed to add comment.' });
94
+ }
95
+ };
96
+
97
+ /**
98
+ * DELETE /api/tasks/:id/comments/:commentId
99
+ */
100
+ const deleteComment = async (req, res) => {
101
+ try {
102
+ const { organization_id: orgId, id: userId, role } = req.user;
103
+ const { id: taskId, commentId } = req.params;
104
+
105
+ const { rows } = await query(
106
+ `SELECT c.id, c.author_id FROM task_comments c
107
+ JOIN tasks t ON t.id = c.task_id
108
+ WHERE c.id = $1 AND c.task_id = $2 AND c.organization_id = $3`,
109
+ [commentId, taskId, orgId]
110
+ );
111
+
112
+ if (!rows.length) return res.status(404).json({ error: 'Comment not found.' });
113
+
114
+ const comment = rows[0];
115
+ if (role !== 'admin' && comment.author_id !== userId) {
116
+ return res.status(403).json({ error: 'You can only delete your own comments.' });
117
+ }
118
+
119
+ await query(`DELETE FROM task_comments WHERE id = $1`, [commentId]);
120
+ return res.status(204).send();
121
+ } catch (err) {
122
+ console.error('Delete comment error:', err);
123
+ return res.status(500).json({ error: 'Failed to delete comment.' });
124
+ }
125
+ };
126
+
127
+ module.exports = { listComments, addComment, deleteComment };
backend/src/controllers/taskController.js ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { query } = require('../utils/db');
2
+ const { auditLog } = require('../utils/auditLogger');
3
+ const { v4: uuidv4 } = require('uuid');
4
+
5
+ const VALID_STATUSES = ['todo', 'in_progress', 'in_review', 'done', 'cancelled'];
6
+ const VALID_PRIORITIES = ['low', 'medium', 'high', 'critical'];
7
+
8
+ /**
9
+ * GET /api/tasks
10
+ * List tasks scoped to the authenticated user's organization.
11
+ * Members only see their assigned or created tasks.
12
+ * Admins see all tasks in the org.
13
+ */
14
+ const listTasks = async (req, res) => {
15
+ try {
16
+ const { organization_id: orgId, id: userId, role } = req.user;
17
+ const {
18
+ status, priority, assignee_id, search,
19
+ sort_by = 'created_at', sort_dir = 'desc',
20
+ page = 1, limit = 20,
21
+ } = req.query;
22
+
23
+ const pageNum = Math.max(1, parseInt(page));
24
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
25
+ const offset = (pageNum - 1) * limitNum;
26
+
27
+ const conditions = [`t.organization_id = $1`];
28
+ const params = [orgId];
29
+ let idx = 2;
30
+
31
+ // Members only see tasks they created or are assigned to
32
+ if (role === 'member') {
33
+ conditions.push(`(t.creator_id = $${idx} OR t.assignee_id = $${idx})`);
34
+ params.push(userId);
35
+ idx++;
36
+ }
37
+
38
+ if (status && VALID_STATUSES.includes(status)) {
39
+ conditions.push(`t.status = $${idx++}`);
40
+ params.push(status);
41
+ }
42
+ if (priority && VALID_PRIORITIES.includes(priority)) {
43
+ conditions.push(`t.priority = $${idx++}`);
44
+ params.push(priority);
45
+ }
46
+ if (assignee_id) {
47
+ conditions.push(`t.assignee_id = $${idx++}`);
48
+ params.push(assignee_id);
49
+ }
50
+ if (search) {
51
+ conditions.push(`(t.title ILIKE $${idx} OR t.description ILIKE $${idx})`);
52
+ params.push(`%${search}%`);
53
+ idx++;
54
+ }
55
+
56
+ const allowed_sort = ['created_at', 'updated_at', 'due_date', 'priority', 'status', 'title'];
57
+ const sortCol = allowed_sort.includes(sort_by) ? sort_by : 'created_at';
58
+ const sortDir = sort_dir === 'asc' ? 'ASC' : 'DESC';
59
+
60
+ const whereClause = `WHERE ${conditions.join(' AND ')}`;
61
+
62
+ const countResult = await query(
63
+ `SELECT COUNT(*) FROM tasks t ${whereClause}`,
64
+ params
65
+ );
66
+ const total = parseInt(countResult.rows[0].count);
67
+
68
+ const { rows: tasks } = await query(
69
+ `SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date,
70
+ t.created_at, t.updated_at,
71
+ t.creator_id,
72
+ creator.name AS creator_name, creator.email AS creator_email,
73
+ t.assignee_id,
74
+ assignee.name AS assignee_name, assignee.email AS assignee_email
75
+ FROM tasks t
76
+ LEFT JOIN users creator ON creator.id = t.creator_id
77
+ LEFT JOIN users assignee ON assignee.id = t.assignee_id
78
+ ${whereClause}
79
+ ORDER BY t.${sortCol} ${sortDir}
80
+ LIMIT $${idx} OFFSET $${idx + 1}`,
81
+ [...params, limitNum, offset]
82
+ );
83
+
84
+ return res.json({
85
+ tasks,
86
+ pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum) },
87
+ });
88
+ } catch (err) {
89
+ console.error('List tasks error:', err);
90
+ return res.status(500).json({ error: 'Failed to retrieve tasks.' });
91
+ }
92
+ };
93
+
94
+ /**
95
+ * GET /api/tasks/:id
96
+ */
97
+ const getTask = async (req, res) => {
98
+ try {
99
+ const { organization_id: orgId, id: userId, role } = req.user;
100
+ const { id } = req.params;
101
+
102
+ const { rows } = await query(
103
+ `SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date,
104
+ t.created_at, t.updated_at, t.organization_id,
105
+ t.creator_id, creator.name AS creator_name, creator.email AS creator_email,
106
+ t.assignee_id, assignee.name AS assignee_name, assignee.email AS assignee_email
107
+ FROM tasks t
108
+ LEFT JOIN users creator ON creator.id = t.creator_id
109
+ LEFT JOIN users assignee ON assignee.id = t.assignee_id
110
+ WHERE t.id = $1 AND t.organization_id = $2`,
111
+ [id, orgId]
112
+ );
113
+
114
+ if (!rows.length) {
115
+ return res.status(404).json({ error: 'Task not found.' });
116
+ }
117
+
118
+ const task = rows[0];
119
+
120
+ // Members can only view tasks they created or are assigned to
121
+ if (role === 'member' && task.creator_id !== userId && task.assignee_id !== userId) {
122
+ return res.status(403).json({ error: 'You do not have permission to view this task.' });
123
+ }
124
+
125
+ return res.json(task);
126
+ } catch (err) {
127
+ console.error('Get task error:', err);
128
+ return res.status(500).json({ error: 'Failed to retrieve task.' });
129
+ }
130
+ };
131
+
132
+ /**
133
+ * POST /api/tasks
134
+ */
135
+ const createTask = async (req, res) => {
136
+ try {
137
+ const { organization_id: orgId, id: userId, name: userName, email: userEmail } = req.user;
138
+ const { title, description, status = 'todo', priority = 'medium', assignee_id, due_date } = req.body;
139
+
140
+ // Validate assignee belongs to same org
141
+ if (assignee_id) {
142
+ const check = await query(`SELECT id FROM users WHERE id = $1 AND organization_id = $2`, [assignee_id, orgId]);
143
+ if (!check.rows.length) {
144
+ return res.status(400).json({ error: 'Assignee must belong to your organization.' });
145
+ }
146
+ }
147
+
148
+ const taskId = uuidv4();
149
+ const { rows } = await query(
150
+ `INSERT INTO tasks (id, organization_id, title, description, status, priority, creator_id, assignee_id, due_date)
151
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
152
+ RETURNING *`,
153
+ [taskId, orgId, title, description || null, status, priority, userId, assignee_id || null, due_date || null]
154
+ );
155
+
156
+ const task = rows[0];
157
+
158
+ await auditLog({
159
+ organizationId: orgId,
160
+ taskId: task.id,
161
+ actorId: userId,
162
+ actorName: userName,
163
+ actorEmail: userEmail,
164
+ action: 'TASK_CREATED',
165
+ newValues: { title, status, priority, assignee_id: assignee_id || null },
166
+ });
167
+
168
+ return res.status(201).json(task);
169
+ } catch (err) {
170
+ console.error('Create task error:', err);
171
+ return res.status(500).json({ error: 'Failed to create task.' });
172
+ }
173
+ };
174
+
175
+ /**
176
+ * PATCH /api/tasks/:id
177
+ */
178
+ const updateTask = async (req, res) => {
179
+ try {
180
+ const { organization_id: orgId, id: userId, name: userName, email: userEmail, role } = req.user;
181
+ const { id } = req.params;
182
+
183
+ const { rows: existing } = await query(
184
+ `SELECT * FROM tasks WHERE id = $1 AND organization_id = $2`,
185
+ [id, orgId]
186
+ );
187
+ if (!existing.length) {
188
+ return res.status(404).json({ error: 'Task not found.' });
189
+ }
190
+
191
+ const task = existing[0];
192
+
193
+ // Members can only update tasks they created or are assigned to
194
+ if (role === 'member' && task.creator_id !== userId && task.assignee_id !== userId) {
195
+ return res.status(403).json({ error: 'You do not have permission to update this task.' });
196
+ }
197
+
198
+ const { title, description, status, priority, assignee_id, due_date } = req.body;
199
+ const updates = {};
200
+
201
+ if (title !== undefined) updates.title = title;
202
+ if (description !== undefined) updates.description = description;
203
+ if (status !== undefined && VALID_STATUSES.includes(status)) updates.status = status;
204
+ if (priority !== undefined && VALID_PRIORITIES.includes(priority)) updates.priority = priority;
205
+ if (due_date !== undefined) updates.due_date = due_date || null;
206
+
207
+ if (assignee_id !== undefined) {
208
+ if (assignee_id === null) {
209
+ updates.assignee_id = null;
210
+ } else {
211
+ const check = await query(`SELECT id FROM users WHERE id = $1 AND organization_id = $2`, [assignee_id, orgId]);
212
+ if (!check.rows.length) {
213
+ return res.status(400).json({ error: 'Assignee must belong to your organization.' });
214
+ }
215
+ updates.assignee_id = assignee_id;
216
+ }
217
+ }
218
+
219
+ if (!Object.keys(updates).length) {
220
+ return res.status(400).json({ error: 'No valid fields provided for update.' });
221
+ }
222
+
223
+ const setClauses = Object.keys(updates).map((k, i) => `${k} = $${i + 3}`);
224
+ const setValues = Object.values(updates);
225
+
226
+ const { rows: updated } = await query(
227
+ `UPDATE tasks SET ${setClauses.join(', ')}, updated_at = NOW()
228
+ WHERE id = $1 AND organization_id = $2
229
+ RETURNING *`,
230
+ [id, orgId, ...setValues]
231
+ );
232
+
233
+ await auditLog({
234
+ organizationId: orgId,
235
+ taskId: id,
236
+ actorId: userId,
237
+ actorName: userName,
238
+ actorEmail: userEmail,
239
+ action: 'TASK_UPDATED',
240
+ oldValues: { title: task.title, status: task.status, priority: task.priority, assignee_id: task.assignee_id },
241
+ newValues: updates,
242
+ });
243
+
244
+ return res.json(updated[0]);
245
+ } catch (err) {
246
+ console.error('Update task error:', err);
247
+ return res.status(500).json({ error: 'Failed to update task.' });
248
+ }
249
+ };
250
+
251
+ /**
252
+ * DELETE /api/tasks/:id
253
+ */
254
+ const deleteTask = async (req, res) => {
255
+ try {
256
+ const { organization_id: orgId, id: userId, name: userName, email: userEmail, role } = req.user;
257
+ const { id } = req.params;
258
+
259
+ const { rows: existing } = await query(
260
+ `SELECT * FROM tasks WHERE id = $1 AND organization_id = $2`,
261
+ [id, orgId]
262
+ );
263
+ if (!existing.length) {
264
+ return res.status(404).json({ error: 'Task not found.' });
265
+ }
266
+
267
+ const task = existing[0];
268
+
269
+ // Members can only delete tasks they created
270
+ if (role === 'member' && task.creator_id !== userId) {
271
+ return res.status(403).json({ error: 'You can only delete tasks you created.' });
272
+ }
273
+
274
+ await query(`DELETE FROM tasks WHERE id = $1 AND organization_id = $2`, [id, orgId]);
275
+
276
+ await auditLog({
277
+ organizationId: orgId,
278
+ taskId: id,
279
+ actorId: userId,
280
+ actorName: userName,
281
+ actorEmail: userEmail,
282
+ action: 'TASK_DELETED',
283
+ oldValues: { title: task.title, status: task.status, priority: task.priority },
284
+ });
285
+
286
+ return res.status(204).send();
287
+ } catch (err) {
288
+ console.error('Delete task error:', err);
289
+ return res.status(500).json({ error: 'Failed to delete task.' });
290
+ }
291
+ };
292
+
293
+ /**
294
+ * GET /api/tasks/stats
295
+ * Dashboard stats for the org
296
+ */
297
+ const getStats = async (req, res) => {
298
+ try {
299
+ const { organization_id: orgId, id: userId, role } = req.user;
300
+
301
+ let scopeClause = `organization_id = $1`;
302
+ const params = [orgId];
303
+
304
+ if (role === 'member') {
305
+ scopeClause += ` AND (creator_id = $2 OR assignee_id = $2)`;
306
+ params.push(userId);
307
+ }
308
+
309
+ const { rows: statusCounts } = await query(
310
+ `SELECT status, COUNT(*) as count FROM tasks WHERE ${scopeClause} GROUP BY status`,
311
+ params
312
+ );
313
+
314
+ const { rows: priorityCounts } = await query(
315
+ `SELECT priority, COUNT(*) as count FROM tasks WHERE ${scopeClause} GROUP BY priority`,
316
+ params
317
+ );
318
+
319
+ const { rows: overdue } = await query(
320
+ `SELECT COUNT(*) as count FROM tasks
321
+ WHERE ${scopeClause} AND due_date < NOW() AND status NOT IN ('done', 'cancelled')`,
322
+ params
323
+ );
324
+
325
+ const { rows: recent } = await query(
326
+ `SELECT t.id, t.title, t.status, t.priority, t.updated_at,
327
+ assignee.name AS assignee_name
328
+ FROM tasks t
329
+ LEFT JOIN users assignee ON assignee.id = t.assignee_id
330
+ WHERE t.${scopeClause}
331
+ ORDER BY t.updated_at DESC LIMIT 5`,
332
+ params
333
+ );
334
+
335
+ return res.json({
336
+ statusCounts: Object.fromEntries(statusCounts.map(r => [r.status, parseInt(r.count)])),
337
+ priorityCounts: Object.fromEntries(priorityCounts.map(r => [r.priority, parseInt(r.count)])),
338
+ overdueCount: parseInt(overdue[0].count),
339
+ recentTasks: recent,
340
+ });
341
+ } catch (err) {
342
+ console.error('Stats error:', err);
343
+ return res.status(500).json({ error: 'Failed to retrieve stats.' });
344
+ }
345
+ };
346
+
347
+ module.exports = { listTasks, getTask, createTask, updateTask, deleteTask, getStats };
backend/src/middleware/auth.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { verifyToken } = require('../utils/jwt');
2
+ const { query } = require('../utils/db');
3
+
4
+ /**
5
+ * Middleware: Validate JWT and attach user to req.user
6
+ */
7
+ const authenticate = async (req, res, next) => {
8
+ try {
9
+ const authHeader = req.headers.authorization;
10
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
11
+ return res.status(401).json({ error: 'Authentication required.' });
12
+ }
13
+
14
+ const token = authHeader.split(' ')[1];
15
+ let decoded;
16
+ try {
17
+ decoded = verifyToken(token);
18
+ } catch (err) {
19
+ if (err.name === 'TokenExpiredError') {
20
+ return res.status(401).json({ error: 'Token has expired. Please log in again.' });
21
+ }
22
+ return res.status(401).json({ error: 'Invalid token.' });
23
+ }
24
+
25
+ // Verify user still exists and is active in the database
26
+ const { rows } = await query(
27
+ `SELECT u.id, u.name, u.email, u.role, u.organization_id, u.is_active, o.slug as org_slug, o.name as org_name
28
+ FROM users u
29
+ JOIN organizations o ON o.id = u.organization_id
30
+ WHERE u.id = $1 AND u.organization_id = $2`,
31
+ [decoded.sub, decoded.org]
32
+ );
33
+
34
+ if (!rows.length || !rows[0].is_active) {
35
+ return res.status(401).json({ error: 'User account not found or deactivated.' });
36
+ }
37
+
38
+ req.user = rows[0];
39
+ next();
40
+ } catch (err) {
41
+ console.error('Auth middleware error:', err);
42
+ return res.status(500).json({ error: 'Internal server error.' });
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Middleware factory: Require specific roles
48
+ */
49
+ const requireRole = (...roles) => {
50
+ return (req, res, next) => {
51
+ if (!req.user) {
52
+ return res.status(401).json({ error: 'Authentication required.' });
53
+ }
54
+ if (!roles.includes(req.user.role)) {
55
+ return res.status(403).json({ error: 'You do not have permission to perform this action.' });
56
+ }
57
+ next();
58
+ };
59
+ };
60
+
61
+ module.exports = { authenticate, requireRole };
backend/src/middleware/validate.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { validationResult } = require('express-validator');
2
+
3
+ const validate = (req, res, next) => {
4
+ const errors = validationResult(req);
5
+ if (!errors.isEmpty()) {
6
+ return res.status(422).json({
7
+ error: 'Validation failed.',
8
+ details: errors.array().map(e => ({ field: e.path, message: e.msg })),
9
+ });
10
+ }
11
+ next();
12
+ };
13
+
14
+ module.exports = { validate };
backend/src/routes/admin.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { body } = require('express-validator');
4
+ const { validate } = require('../middleware/validate');
5
+ const { authenticate, requireRole } = require('../middleware/auth');
6
+ const {
7
+ listUsers, updateUserRole, deactivateUser,
8
+ createInvite, listInvites, getAuditLogs, getOrg, listOrgUsers
9
+ } = require('../controllers/adminController');
10
+
11
+ // All admin routes require authentication
12
+ router.use(authenticate);
13
+
14
+ // Org-scoped user list (available to all authenticated users for assignee dropdowns)
15
+ router.get('/users/org', listOrgUsers);
16
+
17
+ // Admin-only below
18
+ router.use(requireRole('admin'));
19
+
20
+ router.get('/org', getOrg);
21
+
22
+ router.get('/users', listUsers);
23
+
24
+ router.patch('/users/:id/role', [
25
+ body('role').isIn(['admin', 'member']).withMessage('Role must be admin or member.'),
26
+ validate,
27
+ ], updateUserRole);
28
+
29
+ router.patch('/users/:id/deactivate', deactivateUser);
30
+
31
+ router.get('/invites', listInvites);
32
+
33
+ router.post('/invites', [
34
+ body('email').isEmail().normalizeEmail().withMessage('Valid email is required.'),
35
+ body('role').optional().isIn(['admin', 'member']).withMessage('Role must be admin or member.'),
36
+ validate,
37
+ ], createInvite);
38
+
39
+ router.get('/audit-logs', getAuditLogs);
40
+
41
+ module.exports = router;
backend/src/routes/auth.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { body } = require('express-validator');
4
+ const { validate } = require('../middleware/validate');
5
+ const { authenticate } = require('../middleware/auth');
6
+ const { register, login, getMe, acceptInvite } = require('../controllers/authController');
7
+
8
+ router.post('/register', [
9
+ body('orgName').trim().isLength({ min: 2, max: 100 }).withMessage('Organization name must be 2-100 characters.'),
10
+ body('name').trim().isLength({ min: 2, max: 100 }).withMessage('Name must be 2-100 characters.'),
11
+ body('email').isEmail().normalizeEmail().withMessage('Valid email is required.'),
12
+ body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters.')
13
+ .matches(/[A-Z]/).withMessage('Password must contain at least one uppercase letter.')
14
+ .matches(/[0-9]/).withMessage('Password must contain at least one number.'),
15
+ validate,
16
+ ], register);
17
+
18
+ router.post('/login', [
19
+ body('email').isEmail().normalizeEmail().withMessage('Valid email is required.'),
20
+ body('password').notEmpty().withMessage('Password is required.'),
21
+ validate,
22
+ ], login);
23
+
24
+ router.get('/me', authenticate, getMe);
25
+
26
+ router.post('/accept-invite', [
27
+ body('token').notEmpty().withMessage('Invite token is required.'),
28
+ body('name').trim().isLength({ min: 2, max: 100 }).withMessage('Name is required.'),
29
+ body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters.'),
30
+ validate,
31
+ ], acceptInvite);
32
+
33
+ module.exports = router;
backend/src/routes/comments.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const router = express.Router({ mergeParams: true }); // inherit :id from parent
3
+ const { authenticate } = require('../middleware/auth');
4
+ const { listComments, addComment, deleteComment } = require('../controllers/commentController');
5
+
6
+ router.use(authenticate);
7
+
8
+ router.get('/', listComments);
9
+ router.post('/', addComment);
10
+ router.delete('/:commentId', deleteComment);
11
+
12
+ module.exports = router;
backend/src/routes/tasks.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { body } = require('express-validator');
4
+ const { validate } = require('../middleware/validate');
5
+ const { authenticate } = require('../middleware/auth');
6
+ const { listTasks, getTask, createTask, updateTask, deleteTask, getStats } = require('../controllers/taskController');
7
+
8
+ router.use(authenticate);
9
+
10
+ router.get('/stats', getStats);
11
+
12
+ router.get('/', listTasks);
13
+
14
+ router.get('/:id', getTask);
15
+
16
+ router.post('/', [
17
+ body('title').trim().isLength({ min: 1, max: 500 }).withMessage('Title is required (max 500 chars).'),
18
+ body('description').optional().isLength({ max: 5000 }).withMessage('Description too long.'),
19
+ body('status').optional().isIn(['todo', 'in_progress', 'in_review', 'done', 'cancelled']).withMessage('Invalid status.'),
20
+ body('priority').optional().isIn(['low', 'medium', 'high', 'critical']).withMessage('Invalid priority.'),
21
+ body('due_date').optional({ nullable: true }).isISO8601().withMessage('Due date must be a valid date.'),
22
+ validate,
23
+ ], createTask);
24
+
25
+ router.patch('/:id', [
26
+ body('title').optional().trim().isLength({ min: 1, max: 500 }).withMessage('Title must be 1-500 chars.'),
27
+ body('description').optional().isLength({ max: 5000 }).withMessage('Description too long.'),
28
+ body('status').optional().isIn(['todo', 'in_progress', 'in_review', 'done', 'cancelled']).withMessage('Invalid status.'),
29
+ body('priority').optional().isIn(['low', 'medium', 'high', 'critical']).withMessage('Invalid priority.'),
30
+ body('due_date').optional({ nullable: true }).isISO8601().withMessage('Due date must be a valid date.'),
31
+ validate,
32
+ ], updateTask);
33
+
34
+ router.delete('/:id', deleteTask);
35
+
36
+ module.exports = router;
backend/src/server.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require('dotenv').config();
2
+ const express = require('express');
3
+ const cors = require('cors');
4
+ const helmet = require('helmet');
5
+ const rateLimit = require('express-rate-limit');
6
+
7
+ const authRoutes = require('./routes/auth');
8
+ const taskRoutes = require('./routes/tasks');
9
+ const adminRoutes = require('./routes/admin');
10
+ const commentRoutes = require('./routes/comments');
11
+
12
+ const app = express();
13
+
14
+ // On HuggingFace, nginx proxies /api/* to this process on 127.0.0.1:5000
15
+ // so requests arrive without an Origin header — CORS is only needed for local dev
16
+ app.use(helmet({ contentSecurityPolicy: false }));
17
+
18
+ const allowedOrigins = [
19
+ process.env.FRONTEND_URL || 'http://localhost:3000',
20
+ 'http://localhost:3000',
21
+ 'http://127.0.0.1:3000',
22
+ ];
23
+
24
+ app.use(cors({
25
+ origin: (origin, cb) => {
26
+ // Allow requests with no origin (nginx proxy, curl, etc.)
27
+ if (!origin || allowedOrigins.includes(origin)) return cb(null, true);
28
+ cb(new Error('CORS blocked'));
29
+ },
30
+ credentials: true,
31
+ methods: ['GET','POST','PATCH','PUT','DELETE','OPTIONS'],
32
+ allowedHeaders: ['Content-Type','Authorization'],
33
+ }));
34
+
35
+ const generalLimiter = rateLimit({ windowMs: 15*60*1000, max: 500, standardHeaders: true, legacyHeaders: false });
36
+ const authLimiter = rateLimit({ windowMs: 15*60*1000, max: 20, message: { error: 'Too many attempts. Try again later.' } });
37
+
38
+ app.use(generalLimiter);
39
+ app.use(express.json({ limit: '2mb' }));
40
+ app.use(express.urlencoded({ extended: true }));
41
+
42
+ app.get('/health', (req, res) =>
43
+ res.json({ status: 'ok', service: 'taskflow-api', ts: new Date().toISOString() })
44
+ );
45
+
46
+ app.use('/api/auth', authLimiter, authRoutes);
47
+ app.use('/api/tasks', taskRoutes);
48
+ app.use('/api/tasks/:id/comments', commentRoutes);
49
+ app.use('/api/admin', adminRoutes);
50
+
51
+ app.use((req, res) => res.status(404).json({ error: 'Endpoint not found.' }));
52
+ app.use((err, req, res, next) => {
53
+ console.error('Unhandled error:', err.message);
54
+ res.status(500).json({ error: 'An unexpected error occurred.' });
55
+ });
56
+
57
+ const PORT = process.env.PORT || 5000;
58
+ app.listen(PORT, '127.0.0.1', () =>
59
+ console.log(`Taskflow API on port ${PORT} [${process.env.NODE_ENV || 'development'}]`)
60
+ );
61
+
62
+ module.exports = app;
backend/src/utils/auditLogger.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { query } = require('./db');
2
+
3
+ /**
4
+ * Write an audit log entry (append-only, never updated or deleted).
5
+ */
6
+ const auditLog = async ({
7
+ organizationId,
8
+ taskId = null,
9
+ actorId,
10
+ actorName,
11
+ actorEmail,
12
+ action,
13
+ entityType = 'task',
14
+ oldValues = null,
15
+ newValues = null,
16
+ metadata = null,
17
+ }) => {
18
+ try {
19
+ await query(
20
+ `INSERT INTO audit_logs
21
+ (organization_id, task_id, actor_id, actor_name, actor_email, action, entity_type, old_values, new_values, metadata)
22
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
23
+ [
24
+ organizationId,
25
+ taskId,
26
+ actorId,
27
+ actorName,
28
+ actorEmail,
29
+ action,
30
+ entityType,
31
+ oldValues ? JSON.stringify(oldValues) : null,
32
+ newValues ? JSON.stringify(newValues) : null,
33
+ metadata ? JSON.stringify(metadata) : null,
34
+ ]
35
+ );
36
+ } catch (err) {
37
+ // Audit log failures must never crash the main request
38
+ console.error('[AUDIT LOG ERROR]', err.message);
39
+ }
40
+ };
41
+
42
+ module.exports = { auditLog };
backend/src/utils/db.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { Pool } = require('pg');
2
+
3
+ const pool = new Pool({
4
+ host: process.env.DB_HOST || 'localhost',
5
+ port: parseInt(process.env.DB_PORT) || 5432,
6
+ database: process.env.DB_NAME || 'taskflow',
7
+ user: process.env.DB_USER || 'taskflow_user',
8
+ password: process.env.DB_PASSWORD || 'taskflow_secret',
9
+ max: 20,
10
+ idleTimeoutMillis: 30000,
11
+ connectionTimeoutMillis: 2000,
12
+ });
13
+
14
+ pool.on('error', (err) => {
15
+ console.error('Unexpected PostgreSQL error:', err);
16
+ process.exit(-1);
17
+ });
18
+
19
+ const query = (text, params) => pool.query(text, params);
20
+
21
+ const getClient = () => pool.connect();
22
+
23
+ module.exports = { query, getClient, pool };
backend/src/utils/jwt.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const jwt = require('jsonwebtoken');
2
+
3
+ const JWT_SECRET = process.env.JWT_SECRET || 'fallback_dev_secret_change_in_prod';
4
+ const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
5
+
6
+ const generateToken = (payload) => {
7
+ return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
8
+ };
9
+
10
+ const verifyToken = (token) => {
11
+ return jwt.verify(token, JWT_SECRET);
12
+ };
13
+
14
+ const buildTokenPayload = (user) => ({
15
+ sub: user.id,
16
+ email: user.email,
17
+ name: user.name,
18
+ role: user.role,
19
+ org: user.organization_id,
20
+ orgSlug: user.org_slug || null,
21
+ });
22
+
23
+ module.exports = { generateToken, verifyToken, buildTokenPayload };
backend/src/utils/migrate.js ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require('dotenv').config();
2
+ const { query } = require('./db');
3
+
4
+ const migrate = async () => {
5
+ console.log('Running database migrations...');
6
+
7
+ await query(`
8
+ CREATE TABLE IF NOT EXISTS organizations (
9
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
10
+ name VARCHAR(255) NOT NULL,
11
+ slug VARCHAR(100) UNIQUE NOT NULL,
12
+ created_at TIMESTAMPTZ DEFAULT NOW(),
13
+ updated_at TIMESTAMPTZ DEFAULT NOW()
14
+ );
15
+ `);
16
+
17
+ await query(`
18
+ CREATE TABLE IF NOT EXISTS users (
19
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
20
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
21
+ name VARCHAR(255) NOT NULL,
22
+ email VARCHAR(255) NOT NULL,
23
+ password_hash VARCHAR(255),
24
+ role VARCHAR(50) NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
25
+ oauth_provider VARCHAR(50),
26
+ oauth_provider_id VARCHAR(255),
27
+ is_active BOOLEAN DEFAULT TRUE,
28
+ created_at TIMESTAMPTZ DEFAULT NOW(),
29
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
30
+ UNIQUE(email, organization_id)
31
+ );
32
+ `);
33
+
34
+ await query(`CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);`);
35
+ await query(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);`);
36
+
37
+ await query(`
38
+ CREATE TABLE IF NOT EXISTS tasks (
39
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
40
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
41
+ title VARCHAR(500) NOT NULL,
42
+ description TEXT,
43
+ status VARCHAR(50) NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'in_review', 'done', 'cancelled')),
44
+ priority VARCHAR(50) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical')),
45
+ creator_id UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
46
+ assignee_id UUID REFERENCES users(id) ON DELETE SET NULL,
47
+ due_date TIMESTAMPTZ,
48
+ created_at TIMESTAMPTZ DEFAULT NOW(),
49
+ updated_at TIMESTAMPTZ DEFAULT NOW()
50
+ );
51
+ `);
52
+
53
+ await query(`CREATE INDEX IF NOT EXISTS idx_tasks_org ON tasks(organization_id);`);
54
+ await query(`CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(organization_id, status);`);
55
+ await query(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_id);`);
56
+ await query(`CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(organization_id, priority);`);
57
+ await query(`CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(organization_id, due_date);`);
58
+
59
+ await query(`
60
+ CREATE TABLE IF NOT EXISTS task_comments (
61
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
62
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
63
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
64
+ author_id UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
65
+ body TEXT NOT NULL,
66
+ created_at TIMESTAMPTZ DEFAULT NOW(),
67
+ updated_at TIMESTAMPTZ DEFAULT NOW()
68
+ );
69
+ `);
70
+ await query(`CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id);`);
71
+
72
+ await query(`
73
+ CREATE TABLE IF NOT EXISTS audit_logs (
74
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
75
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
76
+ task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
77
+ actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
78
+ actor_name VARCHAR(255) NOT NULL,
79
+ actor_email VARCHAR(255) NOT NULL,
80
+ action VARCHAR(100) NOT NULL,
81
+ entity_type VARCHAR(50) NOT NULL DEFAULT 'task',
82
+ old_values JSONB,
83
+ new_values JSONB,
84
+ metadata JSONB,
85
+ created_at TIMESTAMPTZ DEFAULT NOW()
86
+ );
87
+ `);
88
+ await query(`CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_logs(organization_id);`);
89
+ await query(`CREATE INDEX IF NOT EXISTS idx_audit_task ON audit_logs(task_id);`);
90
+ await query(`CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(organization_id, created_at DESC);`);
91
+
92
+ await query(`
93
+ CREATE TABLE IF NOT EXISTS invites (
94
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
95
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
96
+ email VARCHAR(255) NOT NULL,
97
+ role VARCHAR(50) NOT NULL DEFAULT 'member',
98
+ token VARCHAR(255) UNIQUE NOT NULL,
99
+ invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
100
+ expires_at TIMESTAMPTZ NOT NULL,
101
+ used_at TIMESTAMPTZ,
102
+ created_at TIMESTAMPTZ DEFAULT NOW()
103
+ );
104
+ `);
105
+ await query(`CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);`);
106
+ await query(`CREATE INDEX IF NOT EXISTS idx_invites_org ON invites(organization_id);`);
107
+
108
+ console.log('✅ Migrations complete.');
109
+ process.exit(0);
110
+ };
111
+
112
+ migrate().catch(err => {
113
+ console.error('Migration failed:', err);
114
+ process.exit(1);
115
+ });
backend/src/utils/seed.js ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require('dotenv').config();
2
+ const bcrypt = require('bcryptjs');
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const { query } = require('./db');
5
+
6
+ const seed = async () => {
7
+ console.log('Seeding database...');
8
+
9
+ // Create two demo organizations
10
+ const org1Id = uuidv4();
11
+ const org2Id = uuidv4();
12
+
13
+ await query(`
14
+ INSERT INTO organizations (id, name, slug) VALUES
15
+ ($1, 'Acme Corp', 'acme-corp'),
16
+ ($2, 'Globex Inc', 'globex-inc')
17
+ ON CONFLICT (slug) DO NOTHING;
18
+ `, [org1Id, org2Id]);
19
+
20
+ // Fetch actual org IDs in case they already existed
21
+ const { rows: orgs } = await query(`SELECT id, slug FROM organizations WHERE slug IN ('acme-corp', 'globex-inc')`);
22
+ const acme = orgs.find(o => o.slug === 'acme-corp');
23
+ const globex = orgs.find(o => o.slug === 'globex-inc');
24
+
25
+ const hash = await bcrypt.hash('Password123!', 12);
26
+
27
+ // Acme users
28
+ const admin1Id = uuidv4();
29
+ const member1Id = uuidv4();
30
+ await query(`
31
+ INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
32
+ ($1, $2, 'Alice Admin', 'alice@acme.com', $3, 'admin'),
33
+ ($4, $2, 'Bob Member', 'bob@acme.com', $3, 'member')
34
+ ON CONFLICT DO NOTHING;
35
+ `, [admin1Id, acme.id, hash, member1Id]);
36
+
37
+ // Globex users
38
+ const admin2Id = uuidv4();
39
+ await query(`
40
+ INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
41
+ ($1, $2, 'Carol Admin', 'carol@globex.com', $3, 'admin')
42
+ ON CONFLICT DO NOTHING;
43
+ `, [admin2Id, globex.id, hash]);
44
+
45
+ // Fetch real user IDs
46
+ const { rows: users } = await query(`SELECT id, email FROM users WHERE email IN ('alice@acme.com', 'bob@acme.com', 'carol@globex.com')`);
47
+ const alice = users.find(u => u.email === 'alice@acme.com');
48
+ const bob = users.find(u => u.email === 'bob@acme.com');
49
+
50
+ // Seed tasks for Acme
51
+ const taskStatuses = ['todo', 'in_progress', 'in_review', 'done'];
52
+ const taskPriorities = ['low', 'medium', 'high', 'critical'];
53
+ const sampleTasks = [
54
+ { title: 'Set up CI/CD pipeline', description: 'Configure GitHub Actions for automated testing and deployment', status: 'done', priority: 'high' },
55
+ { title: 'Design database schema', description: 'Create ERD and finalize relationships for all entities', status: 'done', priority: 'critical' },
56
+ { title: 'Build authentication module', description: 'Implement JWT-based auth with refresh token support', status: 'in_progress', priority: 'critical' },
57
+ { title: 'Create task CRUD endpoints', description: 'RESTful API endpoints for task management', status: 'in_progress', priority: 'high' },
58
+ { title: 'Write unit tests', description: 'Cover all service functions with Jest unit tests', status: 'todo', priority: 'medium' },
59
+ { title: 'Implement RBAC middleware', description: 'Role-based access control for all protected routes', status: 'in_review', priority: 'high' },
60
+ { title: 'Set up Docker Compose', description: 'Containerize all services with proper networking', status: 'done', priority: 'medium' },
61
+ { title: 'Add audit logging', description: 'Track all create, update, delete operations with metadata', status: 'todo', priority: 'medium' },
62
+ ];
63
+
64
+ for (const task of sampleTasks) {
65
+ const taskId = uuidv4();
66
+ const assignee = Math.random() > 0.5 ? alice.id : bob.id;
67
+ await query(`
68
+ INSERT INTO tasks (id, organization_id, title, description, status, priority, creator_id, assignee_id, due_date)
69
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
70
+ ON CONFLICT DO NOTHING;
71
+ `, [taskId, acme.id, task.title, task.description, task.status, task.priority, alice.id, assignee,
72
+ new Date(Date.now() + Math.random() * 14 * 24 * 60 * 60 * 1000)]);
73
+ }
74
+
75
+ console.log('✅ Seed complete.');
76
+ console.log('');
77
+ console.log('Demo accounts (password: Password123!):');
78
+ console.log(' alice@acme.com — Admin @ Acme Corp');
79
+ console.log(' bob@acme.com — Member @ Acme Corp');
80
+ console.log(' carol@globex.com — Admin @ Globex Inc');
81
+ process.exit(0);
82
+ };
83
+
84
+ seed().catch(err => {
85
+ console.error('Seed failed:', err);
86
+ process.exit(1);
87
+ });
docker-compose.dev.yml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.9'
2
+
3
+ # Development override - mounts source code for hot reloading
4
+ # Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
5
+
6
+ services:
7
+ postgres:
8
+ ports:
9
+ - "5432:5432" # expose DB port for local tools (TablePlus, DBeaver, etc.)
10
+
11
+ backend:
12
+ build:
13
+ context: ./backend
14
+ dockerfile: Dockerfile
15
+ command: ["sh", "-c", "node src/utils/migrate.js && npm run dev"]
16
+ environment:
17
+ NODE_ENV: development
18
+ PORT: 5000
19
+ DB_HOST: postgres
20
+ DB_PORT: 5432
21
+ DB_NAME: taskflow
22
+ DB_USER: taskflow_user
23
+ DB_PASSWORD: taskflow_secret
24
+ JWT_SECRET: dev_secret_change_in_production_min_32_chars_long
25
+ JWT_EXPIRES_IN: 7d
26
+ FRONTEND_URL: http://localhost:3000
27
+ volumes:
28
+ - ./backend:/app
29
+ - /app/node_modules
30
+ ports:
31
+ - "5000:5000"
32
+
33
+ frontend:
34
+ build:
35
+ context: ./frontend
36
+ dockerfile: Dockerfile.dev
37
+ environment:
38
+ REACT_APP_API_URL: http://localhost:5000/api
39
+ CHOKIDAR_USEPOLLING: "true"
40
+ volumes:
41
+ - ./frontend/src:/app/src
42
+ - ./frontend/public:/app/public
43
+ ports:
44
+ - "3000:3000"
docker-compose.yml ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.9'
2
+
3
+ services:
4
+ postgres:
5
+ image: postgres:16-alpine
6
+ container_name: taskflow_postgres
7
+ restart: unless-stopped
8
+ environment:
9
+ POSTGRES_DB: taskflow
10
+ POSTGRES_USER: taskflow_user
11
+ POSTGRES_PASSWORD: taskflow_secret
12
+ volumes:
13
+ - postgres_data:/var/lib/postgresql/data
14
+ healthcheck:
15
+ test: ["CMD-SHELL", "pg_isready -U taskflow_user -d taskflow"]
16
+ interval: 5s
17
+ timeout: 5s
18
+ retries: 10
19
+ networks:
20
+ - taskflow_net
21
+
22
+ backend:
23
+ build:
24
+ context: ./backend
25
+ dockerfile: Dockerfile
26
+ container_name: taskflow_backend
27
+ restart: unless-stopped
28
+ depends_on:
29
+ postgres:
30
+ condition: service_healthy
31
+ environment:
32
+ NODE_ENV: production
33
+ PORT: 5000
34
+ DB_HOST: postgres
35
+ DB_PORT: 5432
36
+ DB_NAME: taskflow
37
+ DB_USER: taskflow_user
38
+ DB_PASSWORD: taskflow_secret
39
+ JWT_SECRET: change_this_to_a_long_random_secret_min_32_chars_in_production
40
+ JWT_EXPIRES_IN: 7d
41
+ FRONTEND_URL: http://localhost:3000
42
+ networks:
43
+ - taskflow_net
44
+ healthcheck:
45
+ test: ["CMD-SHELL", "wget -qO- http://localhost:5000/health || exit 1"]
46
+ interval: 10s
47
+ timeout: 5s
48
+ retries: 5
49
+
50
+ seed:
51
+ build:
52
+ context: ./backend
53
+ dockerfile: Dockerfile
54
+ container_name: taskflow_seed
55
+ depends_on:
56
+ backend:
57
+ condition: service_healthy
58
+ environment:
59
+ NODE_ENV: development
60
+ DB_HOST: postgres
61
+ DB_PORT: 5432
62
+ DB_NAME: taskflow
63
+ DB_USER: taskflow_user
64
+ DB_PASSWORD: taskflow_secret
65
+ command: ["node", "src/utils/seed.js"]
66
+ networks:
67
+ - taskflow_net
68
+ profiles:
69
+ - seed
70
+
71
+ frontend:
72
+ build:
73
+ context: ./frontend
74
+ dockerfile: Dockerfile
75
+ container_name: taskflow_frontend
76
+ restart: unless-stopped
77
+ depends_on:
78
+ backend:
79
+ condition: service_healthy
80
+ ports:
81
+ - "3000:80"
82
+ networks:
83
+ - taskflow_net
84
+
85
+ volumes:
86
+ postgres_data:
87
+
88
+ networks:
89
+ taskflow_net:
90
+ driver: bridge
frontend/.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ REACT_APP_API_URL=http://localhost:5000/api
frontend/Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage
2
+ FROM node:20-alpine AS builder
3
+ WORKDIR /app
4
+ COPY package*.json ./
5
+ RUN npm ci
6
+ COPY . .
7
+ RUN npm run build
8
+
9
+ # Production stage - serve with nginx
10
+ FROM nginx:alpine
11
+ COPY --from=builder /app/build /usr/share/nginx/html
12
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
13
+ EXPOSE 80
14
+ CMD ["nginx", "-g", "daemon off;"]
frontend/Dockerfile.dev ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ EXPOSE 3000
7
+ CMD ["npm", "start"]
frontend/nginx.conf ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name _;
4
+ root /usr/share/nginx/html;
5
+ index index.html;
6
+
7
+ # Gzip compression
8
+ gzip on;
9
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
10
+
11
+ # Proxy API requests to backend
12
+ location /api/ {
13
+ proxy_pass http://backend:5000;
14
+ proxy_http_version 1.1;
15
+ proxy_set_header Upgrade $http_upgrade;
16
+ proxy_set_header Connection 'upgrade';
17
+ proxy_set_header Host $host;
18
+ proxy_set_header X-Real-IP $remote_addr;
19
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
20
+ proxy_cache_bypass $http_upgrade;
21
+ }
22
+
23
+ # React SPA - all routes serve index.html
24
+ location / {
25
+ try_files $uri $uri/ /index.html;
26
+ }
27
+
28
+ # Cache static assets
29
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
30
+ expires 1y;
31
+ add_header Cache-Control "public, immutable";
32
+ }
33
+ }
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "taskflow-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@tanstack/react-query": "^5.17.0",
7
+ "axios": "^1.6.5",
8
+ "date-fns": "^3.3.1",
9
+ "lucide-react": "^0.309.0",
10
+ "react": "^18.2.0",
11
+ "react-dom": "^18.2.0",
12
+ "react-router-dom": "^6.21.3",
13
+ "react-scripts": "5.0.1",
14
+ "react-hot-toast": "^2.4.1"
15
+ },
16
+ "scripts": {
17
+ "start": "react-scripts start",
18
+ "build": "react-scripts build"
19
+ },
20
+ "eslintConfig": {
21
+ "extends": ["react-app"]
22
+ },
23
+ "browserslist": {
24
+ "production": [">0.2%", "not dead", "not op_mini all"],
25
+ "development": ["last 1 chrome version", "last 1 firefox version"]
26
+ },
27
+ "proxy": "http://backend:5000"
28
+ }
frontend/public/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#0d0f12" />
7
+ <meta name="description" content="TaskFlow - Multi-tenant task management" />
8
+ <title>TaskFlow</title>
9
+ </head>
10
+ <body>
11
+ <noscript>You need to enable JavaScript to run this app.</noscript>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
frontend/src/App.jsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { Toaster } from 'react-hot-toast';
5
+ import { AuthProvider, useAuth } from './context/AuthContext';
6
+ import AppLayout from './components/layout/AppLayout';
7
+
8
+ import LoginPage from './pages/LoginPage';
9
+ import RegisterPage from './pages/RegisterPage';
10
+ import AcceptInvitePage from './pages/AcceptInvitePage';
11
+ import DashboardPage from './pages/DashboardPage';
12
+ import TasksPage from './pages/TasksPage';
13
+ import BoardPage from './pages/BoardPage';
14
+ import TaskDetailPage from './pages/TaskDetailPage';
15
+ import AdminPage from './pages/AdminPage';
16
+ import NotFoundPage from './pages/NotFoundPage';
17
+
18
+ const queryClient = new QueryClient({
19
+ defaultOptions: { queries: { retry: 1, staleTime: 30000, refetchOnWindowFocus: false } },
20
+ });
21
+
22
+ const PrivateRoute = ({ children, adminOnly = false }) => {
23
+ const { user, isAdmin } = useAuth();
24
+ if (!user) return <Navigate to="/login" replace />;
25
+ if (adminOnly && !isAdmin) return <Navigate to="/dashboard" replace />;
26
+ return children;
27
+ };
28
+
29
+ const PublicRoute = ({ children }) => {
30
+ const { user } = useAuth();
31
+ return user ? <Navigate to="/dashboard" replace /> : children;
32
+ };
33
+
34
+ const AppRoutes = () => (
35
+ <Routes>
36
+ <Route path="/" element={<Navigate to="/dashboard" replace />} />
37
+ <Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
38
+ <Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
39
+ <Route path="/accept-invite" element={<AcceptInvitePage />} />
40
+
41
+ <Route element={<PrivateRoute><AppLayout /></PrivateRoute>}>
42
+ <Route path="/dashboard" element={<DashboardPage />} />
43
+ <Route path="/tasks" element={<TasksPage />} />
44
+ <Route path="/tasks/:id" element={<TaskDetailPage />} />
45
+ <Route path="/board" element={<BoardPage />} />
46
+ <Route path="/admin" element={<PrivateRoute adminOnly><AdminPage /></PrivateRoute>} />
47
+ </Route>
48
+
49
+ <Route path="*" element={<NotFoundPage />} />
50
+ </Routes>
51
+ );
52
+
53
+ export default function App() {
54
+ return (
55
+ <QueryClientProvider client={queryClient}>
56
+ <AuthProvider>
57
+ <BrowserRouter>
58
+ <AppRoutes />
59
+ <Toaster
60
+ position="top-right"
61
+ toastOptions={{
62
+ style: {
63
+ background: '#faf7f2', color: '#1c1814',
64
+ border: '1.5px solid #ddd6c8',
65
+ borderRadius: '8px', fontSize: '13.5px',
66
+ boxShadow: '0 4px 12px rgba(28,24,20,.1)',
67
+ },
68
+ success: { iconTheme: { primary: '#2d5a3d', secondary: '#faf7f2' } },
69
+ error: { iconTheme: { primary: '#9b3a3a', secondary: '#faf7f2' } },
70
+ }}
71
+ />
72
+ </BrowserRouter>
73
+ </AuthProvider>
74
+ </QueryClientProvider>
75
+ );
76
+ }
frontend/src/components/layout/AppLayout.jsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Outlet, NavLink, useNavigate } from 'react-router-dom';
3
+ import { LayoutDashboard, CheckSquare, ShieldCheck, LogOut, Menu, X, Building2, Kanban } from 'lucide-react';
4
+ import { useAuth } from '../../context/AuthContext';
5
+ import styles from './AppLayout.module.css';
6
+
7
+ export default function AppLayout() {
8
+ const { user, logout, isAdmin } = useAuth();
9
+ const [sidebarOpen, setSidebarOpen] = useState(false);
10
+ const navigate = useNavigate();
11
+
12
+ const handleLogout = () => { logout(); navigate('/login'); };
13
+
14
+ return (
15
+ <div className={styles.shell}>
16
+ {sidebarOpen && <div className={styles.overlay} onClick={() => setSidebarOpen(false)} />}
17
+
18
+ <aside className={`${styles.sidebar} ${sidebarOpen ? styles.sidebarOpen : ''}`}>
19
+ <div className={styles.sidebarHeader}>
20
+ <div className={styles.logo}>
21
+ <div className={styles.logoMark}>T</div>
22
+ <span className={styles.logoText}>Taskflow</span>
23
+ </div>
24
+ <button className={styles.closeMobile} onClick={() => setSidebarOpen(false)}><X size={18} /></button>
25
+ </div>
26
+
27
+ <div className={styles.orgBadge}>
28
+ <Building2 size={12} />
29
+ <span>{user?.orgName || 'Organization'}</span>
30
+ </div>
31
+
32
+ <nav className={styles.nav}>
33
+ <span className={styles.navSection}>Workspace</span>
34
+ {[
35
+ { to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
36
+ { to: '/tasks', icon: CheckSquare, label: 'Task List' },
37
+ { to: '/board', icon: Kanban, label: 'Board' },
38
+ ...(isAdmin ? [{ to: '/admin', icon: ShieldCheck, label: 'Admin' }] : []),
39
+ ].map(({ to, icon: Icon, label }) => (
40
+ <NavLink key={to} to={to}
41
+ className={({ isActive }) => `${styles.navItem} ${isActive ? styles.navItemActive : ''}`}
42
+ onClick={() => setSidebarOpen(false)}
43
+ >
44
+ <Icon size={16} /><span>{label}</span>
45
+ </NavLink>
46
+ ))}
47
+ </nav>
48
+
49
+ <div className={styles.userSection}>
50
+ <div className={styles.userInfo}>
51
+ <div className={styles.avatar}>{user?.name?.[0]?.toUpperCase()}</div>
52
+ <div className={styles.userDetails}>
53
+ <span className={styles.userName}>{user?.name}</span>
54
+ <span className={styles.userRole}>{user?.role}</span>
55
+ </div>
56
+ </div>
57
+ <button className={styles.logoutBtn} onClick={handleLogout} title="Logout"><LogOut size={15} /></button>
58
+ </div>
59
+ </aside>
60
+
61
+ <div className={styles.main}>
62
+ <header className={styles.header}>
63
+ <button className={styles.menuBtn} onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
64
+ <div className={styles.headerRight}>
65
+ <span className={styles.headerOrg}>{user?.orgName}</span>
66
+ <div className={styles.headerAvatar}>{user?.name?.[0]?.toUpperCase()}</div>
67
+ </div>
68
+ </header>
69
+ <main className={styles.content}><Outlet /></main>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
frontend/src/components/layout/AppLayout.module.css ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .shell { display:flex; height:100vh; overflow:hidden; }
2
+ .overlay { display:none; position:fixed; inset:0; background:rgba(28,24,20,.45); z-index:40; }
3
+
4
+ .sidebar {
5
+ width:var(--sidebar-w); background:var(--bg-surface);
6
+ border-right:1.5px solid var(--border); display:flex;
7
+ flex-direction:column; flex-shrink:0; z-index:50;
8
+ transition:transform .25s ease;
9
+ }
10
+
11
+ .sidebarHeader { display:flex;align-items:center;justify-content:space-between;padding:18px 18px 14px;border-bottom:1px solid var(--border); }
12
+
13
+ .logo { display:flex;align-items:center;gap:9px; }
14
+ .logoMark { width:30px;height:30px;background:var(--accent);border-radius:7px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:14px;font-family:'Lora',serif;flex-shrink:0; }
15
+ .logoText { font-family:'Lora',serif;font-weight:700;font-size:17px;color:var(--text-primary);letter-spacing:-.2px; }
16
+
17
+ .closeMobile { display:none; background:none; border:none; color:var(--text-muted); padding:4px; }
18
+
19
+ .orgBadge {
20
+ display:flex;align-items:center;gap:7px;margin:10px 14px;
21
+ padding:7px 11px;background:var(--bg-base);border-radius:var(--radius);
22
+ font-size:11.5px;font-weight:600;color:var(--text-muted);border:1px solid var(--border);
23
+ overflow:hidden;white-space:nowrap;
24
+ }
25
+ .orgBadge span { overflow:hidden;text-overflow:ellipsis; }
26
+
27
+ .nav { flex:1;padding:6px 10px;display:flex;flex-direction:column;gap:1px; }
28
+
29
+ .navItem {
30
+ display:flex;align-items:center;gap:11px;padding:9px 12px;
31
+ border-radius:var(--radius);color:var(--text-secondary);
32
+ font-size:13.5px;font-weight:500;transition:all .13s ease;text-decoration:none;
33
+ border:1px solid transparent;
34
+ }
35
+ .navItem:hover { background:var(--bg-hover);color:var(--text-primary);text-decoration:none; }
36
+ .navItemActive { background:var(--accent-light);color:var(--accent);border-color:var(--accent-border);font-weight:600; }
37
+ .navItemActive:hover { background:var(--accent-light);color:var(--accent);text-decoration:none; }
38
+
39
+ .navSection { font-size:10.5px;font-weight:700;letter-spacing:.08em;color:var(--text-faint);text-transform:uppercase;padding:10px 12px 4px; }
40
+
41
+ .userSection { padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px; }
42
+ .userInfo { flex:1;display:flex;align-items:center;gap:10px;min-width:0; }
43
+ .avatar {
44
+ width:32px;height:32px;border-radius:50%;background:var(--accent);
45
+ color:#fff;font-weight:700;font-size:12.5px;display:flex;align-items:center;
46
+ justify-content:center;flex-shrink:0;font-family:'Lora',serif;
47
+ }
48
+ .userDetails { display:flex;flex-direction:column;min-width:0; }
49
+ .userName { font-size:13px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
50
+ .userRole { font-size:11px;color:var(--text-muted);text-transform:capitalize; }
51
+
52
+ .logoutBtn { background:none;border:none;color:var(--text-faint);padding:5px;border-radius:var(--radius);display:flex;align-items:center;transition:all .15s; }
53
+ .logoutBtn:hover { background:var(--rose-light);color:var(--rose); }
54
+
55
+ .main { flex:1;display:flex;flex-direction:column;overflow:hidden; }
56
+ .header { height:var(--header-h);background:var(--bg-surface);border-bottom:1.5px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 24px;flex-shrink:0; }
57
+ .menuBtn { display:none;background:none;border:none;color:var(--text-muted);padding:4px;cursor:pointer; }
58
+ .headerRight { display:flex;align-items:center;gap:10px;margin-left:auto; }
59
+ .headerOrg { font-size:12.5px;color:var(--text-muted);font-weight:500; }
60
+ .headerAvatar { width:30px;height:30px;border-radius:50%;background:var(--accent);color:#fff;font-weight:700;font-size:11.5px;display:flex;align-items:center;justify-content:center;font-family:'Lora',serif; }
61
+ .content { flex:1;overflow-y:auto;padding:26px 30px; }
62
+
63
+ @media(max-width:768px){
64
+ .sidebar{position:fixed;top:0;left:0;height:100%;transform:translateX(-100%);}
65
+ .sidebarOpen{transform:translateX(0);}
66
+ .overlay{display:block;}
67
+ .closeMobile{display:flex;}
68
+ .menuBtn{display:flex;}
69
+ .headerOrg{display:none;}
70
+ .content{padding:18px 16px;}
71
+ }
frontend/src/components/tasks/TaskModal.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useMutation, useQuery } from '@tanstack/react-query';
3
+ import { X } from 'lucide-react';
4
+ import api from '../../utils/api';
5
+ import toast from 'react-hot-toast';
6
+ import '../../components/ui/components.css';
7
+ import styles from './TaskModal.module.css';
8
+
9
+ export default function TaskModal({ task, defaultStatus = 'todo', onClose, onSuccess }) {
10
+ const isEdit = !!task;
11
+
12
+ const [form, setForm] = useState({
13
+ title: task?.title || '',
14
+ description: task?.description || '',
15
+ status: task?.status || defaultStatus,
16
+ priority: task?.priority || 'medium',
17
+ assignee_id: task?.assignee_id || '',
18
+ due_date: task?.due_date ? task.due_date.slice(0, 10) : '',
19
+ });
20
+ const [errors, setErrors] = useState({});
21
+
22
+ const { data: orgUsers = [] } = useQuery({
23
+ queryKey: ['org-users'],
24
+ queryFn: () => api.get('/admin/users/org').then(r => r.data),
25
+ });
26
+
27
+ const set = k => e => setForm(f => ({ ...f, [k]: e.target.value }));
28
+
29
+ const validate = () => {
30
+ const e = {};
31
+ if (!form.title.trim()) e.title = 'Title is required.';
32
+ setErrors(e);
33
+ return !Object.keys(e).length;
34
+ };
35
+
36
+ const mutation = useMutation({
37
+ mutationFn: payload =>
38
+ isEdit
39
+ ? api.patch(`/tasks/${task.id}`, payload).then(r => r.data)
40
+ : api.post('/tasks', payload).then(r => r.data),
41
+ onSuccess: () => { toast.success(isEdit ? 'Task updated.' : 'Task created.'); onSuccess(); },
42
+ onError: err => {
43
+ const d = err.response?.data;
44
+ if (d?.details) {
45
+ const fe = {};
46
+ d.details.forEach(({ field, message }) => { fe[field] = message; });
47
+ setErrors(fe);
48
+ } else toast.error(d?.error || 'Something went wrong.');
49
+ },
50
+ });
51
+
52
+ const handleSubmit = ev => {
53
+ ev.preventDefault();
54
+ if (!validate()) return;
55
+ mutation.mutate({ ...form, assignee_id: form.assignee_id || null, due_date: form.due_date || null });
56
+ };
57
+
58
+ useEffect(() => {
59
+ const h = e => { if (e.key === 'Escape') onClose(); };
60
+ window.addEventListener('keydown', h);
61
+ return () => window.removeEventListener('keydown', h);
62
+ }, [onClose]);
63
+
64
+ return (
65
+ <div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
66
+ <div className={styles.modal} role="dialog" aria-modal="true">
67
+ <div className={styles.header}>
68
+ <h2 className={styles.title}>{isEdit ? 'Edit Task' : 'New Task'}</h2>
69
+ <button className={styles.closeBtn} onClick={onClose}><X size={17}/></button>
70
+ </div>
71
+
72
+ <form onSubmit={handleSubmit} className={styles.body} noValidate>
73
+ <div className="field">
74
+ <label className="field-label">Title <span style={{color:'var(--rose)'}}>*</span></label>
75
+ <input
76
+ className={`field-input ${errors.title ? 'field-input-error' : ''}`}
77
+ type="text" placeholder="What needs to be done?"
78
+ value={form.title} onChange={set('title')} autoFocus
79
+ />
80
+ {errors.title && <span className="field-error">{errors.title}</span>}
81
+ </div>
82
+
83
+ <div className="field">
84
+ <label className="field-label">Description</label>
85
+ <textarea
86
+ className="field-input" placeholder="Add more detail, context, or acceptance criteria…"
87
+ value={form.description} onChange={set('description')} rows={3}
88
+ />
89
+ </div>
90
+
91
+ <div className={styles.row}>
92
+ <div className="field">
93
+ <label className="field-label">Status</label>
94
+ <select className="field-input" value={form.status} onChange={set('status')}>
95
+ <option value="todo">To Do</option>
96
+ <option value="in_progress">In Progress</option>
97
+ <option value="in_review">In Review</option>
98
+ <option value="done">Done</option>
99
+ <option value="cancelled">Cancelled</option>
100
+ </select>
101
+ </div>
102
+ <div className="field">
103
+ <label className="field-label">Priority</label>
104
+ <select className="field-input" value={form.priority} onChange={set('priority')}>
105
+ <option value="low">Low</option>
106
+ <option value="medium">Medium</option>
107
+ <option value="high">High</option>
108
+ <option value="critical">Critical</option>
109
+ </select>
110
+ </div>
111
+ </div>
112
+
113
+ <div className={styles.row}>
114
+ <div className="field">
115
+ <label className="field-label">Assignee</label>
116
+ <select className="field-input" value={form.assignee_id} onChange={set('assignee_id')}>
117
+ <option value="">Unassigned</option>
118
+ {orgUsers.map(u => (
119
+ <option key={u.id} value={u.id}>{u.name}</option>
120
+ ))}
121
+ </select>
122
+ </div>
123
+ <div className="field">
124
+ <label className="field-label">Due Date</label>
125
+ <input className="field-input" type="date" value={form.due_date} onChange={set('due_date')}/>
126
+ </div>
127
+ </div>
128
+
129
+ <div className={styles.footer}>
130
+ <button type="button" className="btn btn-secondary btn-md" onClick={onClose}>Cancel</button>
131
+ <button type="submit" className="btn btn-primary btn-md" disabled={mutation.isPending}>
132
+ {mutation.isPending ? <span className="spinner"/> : null}
133
+ {mutation.isPending ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Task'}
134
+ </button>
135
+ </div>
136
+ </form>
137
+ </div>
138
+ </div>
139
+ );
140
+ }
frontend/src/components/tasks/TaskModal.module.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .overlay {
2
+ position: fixed; inset: 0; background: rgba(28,24,20,.5);
3
+ display: flex; align-items: center; justify-content: center;
4
+ z-index: 100; padding: 16px;
5
+ backdrop-filter: blur(3px);
6
+ animation: fadeIn .15s ease;
7
+ }
8
+ @keyframes fadeIn { from{opacity:0} to{opacity:1} }
9
+
10
+ .modal {
11
+ background: var(--bg-elevated); border: 1.5px solid var(--border-strong);
12
+ border-radius: var(--radius-xl); width: 100%; max-width: 530px;
13
+ box-shadow: var(--shadow-lg);
14
+ animation: slideUp .2s ease;
15
+ max-height: 92vh; overflow-y: auto;
16
+ }
17
+ @keyframes slideUp { from{transform:translateY(18px);opacity:0} to{transform:translateY(0);opacity:1} }
18
+
19
+ .header {
20
+ display: flex; align-items: center; justify-content: space-between;
21
+ padding: 20px 22px 0;
22
+ }
23
+
24
+ .title { font-family: 'Lora', serif; font-size: 17px; font-weight: 700; color: var(--text-primary); }
25
+
26
+ .closeBtn {
27
+ background: none; border: none; color: var(--text-muted); cursor: pointer;
28
+ padding: 5px; border-radius: var(--radius); display: flex; align-items: center;
29
+ transition: all .13s;
30
+ }
31
+ .closeBtn:hover { background: var(--bg-hover); color: var(--text-primary); }
32
+
33
+ .body { padding: 18px 22px; display: flex; flex-direction: column; gap: 14px; }
34
+
35
+ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
36
+
37
+ .footer {
38
+ display: flex; justify-content: flex-end; gap: 9px;
39
+ padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px;
40
+ }
41
+
42
+ @media(max-width:480px){ .row{grid-template-columns:1fr;} }
frontend/src/components/ui/components.css ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Buttons ── */
2
+ .btn {
3
+ display:inline-flex; align-items:center; justify-content:center; gap:7px;
4
+ font-weight:500; border:1px solid transparent; border-radius:var(--radius);
5
+ cursor:pointer; transition:all .15s ease; white-space:nowrap;
6
+ font-family:'Inter',sans-serif; letter-spacing:0.01em;
7
+ }
8
+ .btn:disabled { opacity:.5; cursor:not-allowed; }
9
+
10
+ .btn-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
11
+ .btn-primary:hover:not(:disabled) { background:var(--accent-hover); border-color:var(--accent-hover); }
12
+
13
+ .btn-secondary { background:var(--bg-elevated); color:var(--text-secondary); border-color:var(--border); }
14
+ .btn-secondary:hover:not(:disabled) { background:var(--bg-hover); border-color:var(--border-strong); }
15
+
16
+ .btn-danger { background:var(--rose-light); color:var(--rose); border-color:var(--rose-border); }
17
+ .btn-danger:hover:not(:disabled) { background:#f7e0e0; }
18
+
19
+ .btn-ghost { background:transparent; color:var(--text-muted); border-color:transparent; }
20
+ .btn-ghost:hover:not(:disabled) { background:var(--bg-hover); color:var(--text-secondary); }
21
+
22
+ .btn-sm { padding:5px 11px; font-size:12.5px; }
23
+ .btn-md { padding:8px 16px; font-size:13.5px; }
24
+ .btn-lg { padding:11px 22px; font-size:14.5px; }
25
+
26
+ /* ── Badges ── */
27
+ .badge {
28
+ display:inline-flex; align-items:center; padding:2px 9px;
29
+ border-radius:100px; font-size:11.5px; font-weight:600; white-space:nowrap;
30
+ border:1px solid transparent; font-family:'Inter',sans-serif;
31
+ }
32
+ .badge-todo { background:var(--s-todo-bg); color:var(--s-todo); border-color:#ddd6c8; }
33
+ .badge-inprogress { background:var(--s-progress-bg); color:var(--s-progress); border-color:var(--blue-border); }
34
+ .badge-inreview { background:var(--s-review-bg); color:var(--s-review); border-color:var(--violet-border); }
35
+ .badge-done { background:var(--s-done-bg); color:var(--s-done); border-color:var(--accent-border); }
36
+ .badge-cancelled { background:var(--s-cancelled-bg);color:var(--s-cancelled); border-color:var(--rose-border); }
37
+
38
+ .badge-low { background:var(--accent-light); color:var(--p-low); border-color:var(--accent-border); }
39
+ .badge-medium { background:var(--amber-light); color:var(--p-medium); border-color:var(--amber-border); }
40
+ .badge-high { background:#fef3ea; color:var(--p-high); border-color:#e8b880; }
41
+ .badge-critical { background:var(--rose-light); color:var(--p-critical); border-color:var(--rose-border); }
42
+
43
+ /* ── Fields ── */
44
+ .field { display:flex; flex-direction:column; gap:5px; }
45
+ .field-label { font-size:12.5px; font-weight:600; color:var(--text-secondary); letter-spacing:.02em; }
46
+ .field-input {
47
+ background:var(--bg-elevated); border:1.5px solid var(--border);
48
+ border-radius:var(--radius); color:var(--text-primary);
49
+ font-size:13.5px; padding:9px 13px; transition:border-color .15s;
50
+ width:100%;
51
+ }
52
+ .field-input:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(45,90,61,0.1); }
53
+ .field-input-error { border-color:var(--rose) !important; }
54
+ .field-error { font-size:11.5px; color:var(--rose); }
55
+ select.field-input { appearance:none; cursor:pointer; }
56
+ textarea.field-input { resize:vertical; min-height:90px; line-height:1.6; }
57
+
58
+ /* ── Card ── */
59
+ .card {
60
+ background:var(--bg-elevated); border:1px solid var(--border);
61
+ border-radius:var(--radius-lg); padding:22px;
62
+ box-shadow:var(--shadow-sm);
63
+ }
64
+
65
+ /* ── Empty State ── */
66
+ .empty-state { display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:56px 24px;gap:10px; }
67
+ .empty-icon { width:52px;height:52px;border-radius:50%;background:var(--bg-base);border:1.5px solid var(--border);display:flex;align-items:center;justify-content:center;color:var(--text-faint);margin-bottom:4px; }
68
+ .empty-title { font-family:'Lora',serif;font-size:16px;font-weight:600;color:var(--text-secondary); }
69
+ .empty-desc { font-size:13px;color:var(--text-muted);max-width:300px;line-height:1.5; }
70
+ .empty-action { margin-top:10px; }
71
+
72
+ /* ── Spinner ── */
73
+ @keyframes spin { to { transform:rotate(360deg); } }
74
+ .spinner {
75
+ width:15px; height:15px;
76
+ border:2px solid rgba(255,255,255,.35);
77
+ border-top-color:white; border-radius:50%;
78
+ animation:spin .7s linear infinite;
79
+ display:inline-block; flex-shrink:0;
80
+ }
81
+ .spinner-dark {
82
+ width:18px; height:18px;
83
+ border:2px solid var(--border); border-top-color:var(--accent);
84
+ border-radius:50%; animation:spin .7s linear infinite; display:inline-block;
85
+ }
86
+
87
+ /* ── Page Header ── */
88
+ .page-header { display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:24px;flex-wrap:wrap; }
89
+ .page-title { font-family:'Lora',serif;font-size:22px;font-weight:700;color:var(--text-primary);letter-spacing:-.2px; }
90
+ .page-subtitle { font-size:13px;color:var(--text-muted);margin-top:3px; }
91
+ .page-action { flex-shrink:0; }
92
+
93
+ /* ── Divider ── */
94
+ .divider { height:1px; background:var(--border); margin:16px 0; }
frontend/src/components/ui/index.jsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export const Button = ({ children, variant='primary', size='md', loading=false, disabled=false, className='', ...props }) => (
4
+ <button className={`btn btn-${variant} btn-${size} ${className}`} disabled={disabled||loading} {...props}>
5
+ {loading && <span className="spinner"/>}{children}
6
+ </button>
7
+ );
8
+
9
+ export const StatusBadge = ({ status }) => {
10
+ const map = {
11
+ todo: { label:'To Do', cls:'badge-todo' },
12
+ in_progress: { label:'In Progress', cls:'badge-inprogress' },
13
+ in_review: { label:'In Review', cls:'badge-inreview' },
14
+ done: { label:'Done', cls:'badge-done' },
15
+ cancelled: { label:'Cancelled', cls:'badge-cancelled' },
16
+ };
17
+ const { label, cls } = map[status] || { label: status, cls: '' };
18
+ return <span className={`badge ${cls}`}>{label}</span>;
19
+ };
20
+
21
+ export const PriorityBadge = ({ priority }) => {
22
+ const map = {
23
+ low: { label:'Low', cls:'badge-low' },
24
+ medium: { label:'Medium', cls:'badge-medium' },
25
+ high: { label:'High', cls:'badge-high' },
26
+ critical: { label:'Critical', cls:'badge-critical' },
27
+ };
28
+ const { label, cls } = map[priority] || { label: priority, cls: '' };
29
+ return <span className={`badge ${cls}`}>{label}</span>;
30
+ };
31
+
32
+ export const Input = React.forwardRef(({ label, error, ...props }, ref) => (
33
+ <div className="field">
34
+ {label && <label className="field-label">{label}</label>}
35
+ <input ref={ref} className={`field-input ${error ? 'field-input-error' : ''}`} {...props}/>
36
+ {error && <span className="field-error">{error}</span>}
37
+ </div>
38
+ ));
39
+
40
+ export const Select = React.forwardRef(({ label, error, children, ...props }, ref) => (
41
+ <div className="field">
42
+ {label && <label className="field-label">{label}</label>}
43
+ <select ref={ref} className={`field-input ${error ? 'field-input-error' : ''}`} {...props}>{children}</select>
44
+ {error && <span className="field-error">{error}</span>}
45
+ </div>
46
+ ));
47
+
48
+ export const Textarea = React.forwardRef(({ label, error, ...props }, ref) => (
49
+ <div className="field">
50
+ {label && <label className="field-label">{label}</label>}
51
+ <textarea ref={ref} className={`field-input ${error ? 'field-input-error' : ''}`} {...props}/>
52
+ {error && <span className="field-error">{error}</span>}
53
+ </div>
54
+ ));
55
+
56
+ export const Card = ({ children, className='', ...props }) => (
57
+ <div className={`card ${className}`} {...props}>{children}</div>
58
+ );
59
+
60
+ export const EmptyState = ({ icon, title, description, action }) => (
61
+ <div className="empty-state">
62
+ {icon && <div className="empty-icon">{icon}</div>}
63
+ <h3 className="empty-title">{title}</h3>
64
+ {description && <p className="empty-desc">{description}</p>}
65
+ {action && <div className="empty-action">{action}</div>}
66
+ </div>
67
+ );
68
+
69
+ export const Spinner = ({ size=24 }) => (
70
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
71
+ style={{animation:'spin .8s linear infinite',flexShrink:0}}>
72
+ <style>{'@keyframes spin{to{transform:rotate(360deg)}}'}</style>
73
+ <circle cx="12" cy="12" r="10" stroke="var(--border-strong)" strokeWidth="3"/>
74
+ <path d="M12 2a10 10 0 0 1 10 10" stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"/>
75
+ </svg>
76
+ );
77
+
78
+ export const PageHeader = ({ title, subtitle, action }) => (
79
+ <div className="page-header">
80
+ <div>
81
+ <h1 className="page-title">{title}</h1>
82
+ {subtitle && <p className="page-subtitle">{subtitle}</p>}
83
+ </div>
84
+ {action && <div className="page-action">{action}</div>}
85
+ </div>
86
+ );
frontend/src/context/AuthContext.jsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useCallback } from 'react';
2
+ import api from '../utils/api';
3
+
4
+ const AuthContext = createContext(null);
5
+
6
+ export const AuthProvider = ({ children }) => {
7
+ const [user, setUser] = useState(() => {
8
+ try {
9
+ const stored = localStorage.getItem('tf_user');
10
+ return stored ? JSON.parse(stored) : null;
11
+ } catch { return null; }
12
+ });
13
+
14
+ const login = useCallback(async (email, password) => {
15
+ const { data } = await api.post('/auth/login', { email, password });
16
+ localStorage.setItem('tf_token', data.token);
17
+ localStorage.setItem('tf_user', JSON.stringify(data.user));
18
+ setUser(data.user);
19
+ return data.user;
20
+ }, []);
21
+
22
+ const register = useCallback(async (payload) => {
23
+ const { data } = await api.post('/auth/register', payload);
24
+ localStorage.setItem('tf_token', data.token);
25
+ localStorage.setItem('tf_user', JSON.stringify(data.user));
26
+ setUser(data.user);
27
+ return data.user;
28
+ }, []);
29
+
30
+ const logout = useCallback(() => {
31
+ localStorage.removeItem('tf_token');
32
+ localStorage.removeItem('tf_user');
33
+ setUser(null);
34
+ }, []);
35
+
36
+ const isAdmin = user?.role === 'admin';
37
+
38
+ return (
39
+ <AuthContext.Provider value={{ user, login, register, logout, isAdmin }}>
40
+ {children}
41
+ </AuthContext.Provider>
42
+ );
43
+ };
44
+
45
+ export const useAuth = () => {
46
+ const ctx = useContext(AuthContext);
47
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider');
48
+ return ctx;
49
+ };
frontend/src/index.css ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
2
+
3
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4
+
5
+ :root {
6
+ /* Cream / warm parchment palette */
7
+ --bg-base: #f5f0e8;
8
+ --bg-surface: #faf7f2;
9
+ --bg-elevated: #ffffff;
10
+ --bg-hover: #ede8de;
11
+ --bg-active: #e4ddd0;
12
+
13
+ --border: #ddd6c8;
14
+ --border-strong: #c9c0b0;
15
+
16
+ --text-primary: #1c1814;
17
+ --text-secondary:#4a4540;
18
+ --text-muted: #8c8278;
19
+ --text-faint: #b8b0a4;
20
+
21
+ --accent: #2d5a3d;
22
+ --accent-hover: #234a31;
23
+ --accent-light: #eef5f0;
24
+ --accent-border: #a8c8b4;
25
+
26
+ --amber: #c17d11;
27
+ --amber-light: #fdf3e0;
28
+ --amber-border: #e8c070;
29
+
30
+ --rose: #9b3a3a;
31
+ --rose-light: #fdf0f0;
32
+ --rose-border: #dba8a8;
33
+
34
+ --blue: #2a5080;
35
+ --blue-light: #eef3fa;
36
+ --blue-border: #a0bcd8;
37
+
38
+ --violet: #5a3a7a;
39
+ --violet-light: #f3eefa;
40
+ --violet-border: #c0a0d8;
41
+
42
+ /* Status */
43
+ --s-todo: #6b6258;
44
+ --s-todo-bg: #f0ece5;
45
+ --s-progress: var(--blue);
46
+ --s-progress-bg: var(--blue-light);
47
+ --s-review: var(--violet);
48
+ --s-review-bg: var(--violet-light);
49
+ --s-done: var(--accent);
50
+ --s-done-bg: var(--accent-light);
51
+ --s-cancelled: var(--rose);
52
+ --s-cancelled-bg:var(--rose-light);
53
+
54
+ /* Priority */
55
+ --p-low: var(--accent);
56
+ --p-medium: var(--amber);
57
+ --p-high: #c15a11;
58
+ --p-critical: var(--rose);
59
+
60
+ --sidebar-w: 252px;
61
+ --header-h: 56px;
62
+ --radius: 6px;
63
+ --radius-lg: 10px;
64
+ --radius-xl: 14px;
65
+ --shadow-sm: 0 1px 3px rgba(28,24,20,0.08);
66
+ --shadow-md: 0 4px 12px rgba(28,24,20,0.1);
67
+ --shadow-lg: 0 8px 32px rgba(28,24,20,0.14);
68
+ }
69
+
70
+ html, body, #root { height: 100%; }
71
+ body {
72
+ font-family: 'Inter', system-ui, sans-serif;
73
+ background: var(--bg-base);
74
+ color: var(--text-primary);
75
+ line-height: 1.6;
76
+ -webkit-font-smoothing: antialiased;
77
+ }
78
+
79
+ h1,h2,h3,h4 { font-family: 'Lora', serif; line-height: 1.25; }
80
+ a { color: var(--accent); text-decoration: none; }
81
+ a:hover { color: var(--accent-hover); text-decoration: underline; }
82
+ input, textarea, select, button { font-family: inherit; }
83
+ button { cursor: pointer; }
84
+ code, pre { font-family: 'JetBrains Mono', monospace; }
85
+
86
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
87
+ ::-webkit-scrollbar-track { background: var(--bg-base); }
88
+ ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
89
+
90
+ .sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
frontend/src/index.jsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import './index.css';
4
+ import App from './App';
5
+
6
+ const root = ReactDOM.createRoot(document.getElementById('root'));
7
+ root.render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>
11
+ );
frontend/src/pages/AcceptInvitePage.jsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useNavigate, useSearchParams, Link } from 'react-router-dom';
3
+ import { Eye, EyeOff } from 'lucide-react';
4
+ import api from '../utils/api';
5
+ import toast from 'react-hot-toast';
6
+ import '../components/ui/components.css';
7
+ import styles from './AuthPage.module.css';
8
+
9
+ export default function AcceptInvitePage() {
10
+ const [params] = useSearchParams();
11
+ const token = params.get('token');
12
+ const [form, setForm] = useState({ name: '', password: '' });
13
+ const [showPw, setShowPw] = useState(false);
14
+ const [loading, setLoading] = useState(false);
15
+ const [errors, setErrors] = useState({});
16
+
17
+ if (!token) return (
18
+ <div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center',background:'var(--bg-base)'}}>
19
+ <p style={{color:'var(--rose)'}}>Invalid invite link. <Link to="/login">Go to login</Link></p>
20
+ </div>
21
+ );
22
+
23
+ const handleSubmit = async ev => {
24
+ ev.preventDefault();
25
+ const e = {};
26
+ if (!form.name.trim()) e.name = 'Name is required.';
27
+ if (form.password.length < 8) e.password = 'Password must be at least 8 characters.';
28
+ setErrors(e);
29
+ if (Object.keys(e).length) return;
30
+ setLoading(true);
31
+ try {
32
+ const { data } = await api.post('/auth/accept-invite', { token, ...form });
33
+ localStorage.setItem('tf_token', data.token);
34
+ localStorage.setItem('tf_user', JSON.stringify(data.user));
35
+ window.location.href = '/dashboard';
36
+ } catch (err) {
37
+ toast.error(err.response?.data?.error || 'Failed to accept invite.');
38
+ } finally { setLoading(false); }
39
+ };
40
+
41
+ return (
42
+ <div className={styles.page}>
43
+ <div className={styles.card}>
44
+ <div className={styles.logoArea}>
45
+ <div className={styles.logoMark}>T</div>
46
+ <span className={styles.logoName}>Taskflow</span>
47
+ </div>
48
+ <h2 className={styles.heading}>You've been invited</h2>
49
+ <p className={styles.sub}>Set your name and password to join the workspace</p>
50
+ <form onSubmit={handleSubmit} className={styles.form} noValidate>
51
+ <div className="field">
52
+ <label className="field-label">Your full name</label>
53
+ <input className={`field-input ${errors.name?'field-input-error':''}`} type="text"
54
+ placeholder="Jane Smith" value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))} autoFocus/>
55
+ {errors.name && <span className="field-error">{errors.name}</span>}
56
+ </div>
57
+ <div className="field">
58
+ <label className="field-label">Choose a password</label>
59
+ <div className={styles.pwWrap}>
60
+ <input className={`field-input ${errors.password?'field-input-error':''}`}
61
+ type={showPw?'text':'password'} placeholder="Min 8 characters"
62
+ value={form.password} onChange={e=>setForm(f=>({...f,password:e.target.value}))}/>
63
+ <button type="button" className={styles.pwToggle} onClick={()=>setShowPw(v=>!v)}>
64
+ {showPw?<Eye size={15}/>:<Eye size={15}/>}
65
+ </button>
66
+ </div>
67
+ {errors.password && <span className="field-error">{errors.password}</span>}
68
+ </div>
69
+ <button type="submit" className="btn btn-primary btn-md" style={{width:'100%'}} disabled={loading}>
70
+ {loading?<span className="spinner"/>:null}{loading?'Joining…':'Join Workspace'}
71
+ </button>
72
+ </form>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
frontend/src/pages/AdminPage.jsx ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { Users, ClipboardList, Mail, Plus, X } from 'lucide-react';
4
+ import api from '../utils/api';
5
+ import { Spinner, PageHeader } from '../components/ui';
6
+ import toast from 'react-hot-toast';
7
+ import { format, formatDistanceToNow } from 'date-fns';
8
+ import '../components/ui/components.css';
9
+ import styles from './AdminPage.module.css';
10
+
11
+ const TABS = [
12
+ { id:'users', label:'Team', icon:Users },
13
+ { id:'invites',label:'Invites', icon:Mail },
14
+ { id:'audit', label:'Audit Log', icon:ClipboardList },
15
+ ];
16
+
17
+ export default function AdminPage() {
18
+ const [tab, setTab] = useState('users');
19
+ return (
20
+ <div className={styles.page}>
21
+ <PageHeader title="Admin" subtitle="Manage your organization"/>
22
+ <div className={styles.tabs}>
23
+ {TABS.map(({id,label,icon:Icon})=>(
24
+ <button key={id} className={`${styles.tab} ${tab===id?styles.tabActive:''}`} onClick={()=>setTab(id)}>
25
+ <Icon size={14}/>{label}
26
+ </button>
27
+ ))}
28
+ </div>
29
+ {tab==='users' && <UsersTab/>}
30
+ {tab==='invites'&& <InvitesTab/>}
31
+ {tab==='audit' && <AuditTab/>}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ function UsersTab() {
37
+ const qc = useQueryClient();
38
+ const [showInvite, setShowInvite] = useState(false);
39
+ const [inviteForm, setInviteForm] = useState({email:'',role:'member'});
40
+ const [inviteLoading, setInviteLoading] = useState(false);
41
+
42
+ const { data:users=[], isLoading } = useQuery({
43
+ queryKey:['admin-users'],
44
+ queryFn:()=>api.get('/admin/users').then(r=>r.data),
45
+ });
46
+
47
+ const roleMutation = useMutation({
48
+ mutationFn:({id,role})=>api.patch(`/admin/users/${id}/role`,{role}),
49
+ onSuccess:()=>{ toast.success('Role updated.'); qc.invalidateQueries(['admin-users']); },
50
+ onError:err=>toast.error(err.response?.data?.error||'Failed.'),
51
+ });
52
+
53
+ const deactivateMutation = useMutation({
54
+ mutationFn:id=>api.patch(`/admin/users/${id}/deactivate`),
55
+ onSuccess:()=>{ toast.success('User deactivated.'); qc.invalidateQueries(['admin-users']); },
56
+ onError:err=>toast.error(err.response?.data?.error||'Failed.'),
57
+ });
58
+
59
+ const handleInvite = async e => {
60
+ e.preventDefault();
61
+ if (!inviteForm.email) return;
62
+ setInviteLoading(true);
63
+ try {
64
+ const { data } = await api.post('/admin/invites', inviteForm);
65
+ toast.success(`Invite link: ${data.inviteUrl}`, { duration: 8000 });
66
+ setShowInvite(false);
67
+ setInviteForm({email:'',role:'member'});
68
+ qc.invalidateQueries(['admin-invites']);
69
+ } catch(err) { toast.error(err.response?.data?.error||'Failed.'); }
70
+ finally { setInviteLoading(false); }
71
+ };
72
+
73
+ if (isLoading) return <div className={styles.loadArea}><span className="spinner-dark"/></div>;
74
+
75
+ return (
76
+ <div>
77
+ <div className={styles.sectionHead}>
78
+ <span className={styles.sectionCount}>{users.length} member{users.length!==1?'s':''}</span>
79
+ <button className="btn btn-primary btn-sm" onClick={()=>setShowInvite(v=>!v)}>
80
+ <Plus size={13}/> Invite
81
+ </button>
82
+ </div>
83
+
84
+ {showInvite && (
85
+ <div className={styles.inviteBox}>
86
+ <form onSubmit={handleInvite} className={styles.inviteRow}>
87
+ <input className="field-input" style={{flex:1}} type="email"
88
+ placeholder="colleague@company.com" value={inviteForm.email}
89
+ onChange={e=>setInviteForm(f=>({...f,email:e.target.value}))} required/>
90
+ <select className="field-input" style={{width:'auto'}}
91
+ value={inviteForm.role} onChange={e=>setInviteForm(f=>({...f,role:e.target.value}))}>
92
+ <option value="member">Member</option>
93
+ <option value="admin">Admin</option>
94
+ </select>
95
+ <button type="submit" className="btn btn-primary btn-sm" disabled={inviteLoading}>
96
+ {inviteLoading?<span className="spinner"/>:'Send'}
97
+ </button>
98
+ <button type="button" className="btn btn-ghost btn-sm" onClick={()=>setShowInvite(false)}><X size={13}/></button>
99
+ </form>
100
+ </div>
101
+ )}
102
+
103
+ <div className={styles.tableWrap}>
104
+ <table className={styles.table}>
105
+ <thead><tr><th>Member</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead>
106
+ <tbody>
107
+ {users.map(u=>(
108
+ <tr key={u.id}>
109
+ <td>
110
+ <div className={styles.userCell}>
111
+ <div className={styles.userAvatar}>{u.name?.[0]?.toUpperCase()}</div>
112
+ <div>
113
+ <p className={styles.userName}>{u.name}</p>
114
+ <p className={styles.userEmail}>{u.email}</p>
115
+ </div>
116
+ </div>
117
+ </td>
118
+ <td>
119
+ <span className={`badge ${u.role==='admin'?'badge-inreview':'badge-todo'}`}>{u.role}</span>
120
+ </td>
121
+ <td>
122
+ <span className={`badge ${u.is_active?'badge-done':'badge-cancelled'}`}>
123
+ {u.is_active?'Active':'Inactive'}
124
+ </span>
125
+ </td>
126
+ <td className={styles.dateCell}>{formatDistanceToNow(new Date(u.created_at),{addSuffix:true})}</td>
127
+ <td>
128
+ <div className={styles.rowActions}>
129
+ <select className={styles.roleSelect} value={u.role}
130
+ onChange={e=>{ if(window.confirm(`Change ${u.name} to ${e.target.value}?`)) roleMutation.mutate({id:u.id,role:e.target.value}); }}>
131
+ <option value="member">Member</option>
132
+ <option value="admin">Admin</option>
133
+ </select>
134
+ {u.is_active && (
135
+ <button className="btn btn-danger btn-sm"
136
+ onClick={()=>{ if(window.confirm(`Deactivate ${u.name}?`)) deactivateMutation.mutate(u.id); }}>
137
+ Deactivate
138
+ </button>
139
+ )}
140
+ </div>
141
+ </td>
142
+ </tr>
143
+ ))}
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ function InvitesTab() {
152
+ const { data:invites=[], isLoading } = useQuery({
153
+ queryKey:['admin-invites'],
154
+ queryFn:()=>api.get('/admin/invites').then(r=>r.data),
155
+ });
156
+ if (isLoading) return <div className={styles.loadArea}><span className="spinner-dark"/></div>;
157
+ return (
158
+ <div>
159
+ <div className={styles.sectionHead}>
160
+ <span className={styles.sectionCount}>{invites.length} invite{invites.length!==1?'s':''}</span>
161
+ </div>
162
+ {invites.length===0
163
+ ? <p style={{color:'var(--text-muted)',fontSize:13,padding:'16px 0',fontStyle:'italic'}}>No invites sent yet.</p>
164
+ : (
165
+ <div className={styles.tableWrap}>
166
+ <table className={styles.table}>
167
+ <thead><tr><th>Email</th><th>Role</th><th>Invited By</th><th>Status</th><th>Expires</th></tr></thead>
168
+ <tbody>
169
+ {invites.map(inv=>(
170
+ <tr key={inv.id}>
171
+ <td style={{fontWeight:500}}>{inv.email}</td>
172
+ <td><span className={`badge ${inv.role==='admin'?'badge-inreview':'badge-todo'}`}>{inv.role}</span></td>
173
+ <td className={styles.dateCell}>{inv.invited_by_name||'—'}</td>
174
+ <td>
175
+ <span className={`badge ${inv.used_at?'badge-done':new Date(inv.expires_at)<new Date()?'badge-cancelled':'badge-inprogress'}`}>
176
+ {inv.used_at?'Accepted':new Date(inv.expires_at)<new Date()?'Expired':'Pending'}
177
+ </span>
178
+ </td>
179
+ <td className={styles.dateCell}>{format(new Date(inv.expires_at),'MMM d, yyyy')}</td>
180
+ </tr>
181
+ ))}
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+ )
186
+ }
187
+ </div>
188
+ );
189
+ }
190
+
191
+ function AuditTab() {
192
+ const [page, setPage] = useState(1);
193
+ const { data, isLoading } = useQuery({
194
+ queryKey:['audit-logs',page],
195
+ queryFn:()=>api.get('/admin/audit-logs',{params:{page,limit:25}}).then(r=>r.data),
196
+ keepPreviousData:true,
197
+ });
198
+
199
+ const actionColor = a => {
200
+ if (a.includes('CREATED')||a.includes('JOINED')) return 'badge-done';
201
+ if (a.includes('DELETED')||a.includes('DEACTIVATED')) return 'badge-cancelled';
202
+ if (a.includes('UPDATED')||a.includes('CHANGED')) return 'badge-inprogress';
203
+ if (a.includes('INVITED')) return 'badge-inreview';
204
+ return 'badge-todo';
205
+ };
206
+
207
+ if (isLoading) return <div className={styles.loadArea}><span className="spinner-dark"/></div>;
208
+
209
+ return (
210
+ <div>
211
+ <div className={styles.sectionHead}>
212
+ <span className={styles.sectionCount}>{data?.pagination?.total||0} entries</span>
213
+ </div>
214
+ <div className={styles.tableWrap}>
215
+ <table className={styles.table}>
216
+ <thead><tr><th>Actor</th><th>Action</th><th>Details</th><th>When</th></tr></thead>
217
+ <tbody>
218
+ {data?.logs?.map(log=>(
219
+ <tr key={log.id}>
220
+ <td>
221
+ <p style={{fontSize:13,fontWeight:500}}>{log.actor_name}</p>
222
+ <p style={{fontSize:11.5,color:'var(--text-muted)'}}>{log.actor_email}</p>
223
+ </td>
224
+ <td>
225
+ <span className={`badge ${actionColor(log.action)}`} style={{fontSize:11}}>
226
+ {log.action.replace(/_/g,' ').toLowerCase().replace(/\b\w/g,c=>c.toUpperCase())}
227
+ </span>
228
+ </td>
229
+ <td>
230
+ {log.new_values && (
231
+ <div style={{fontSize:12,color:'var(--text-muted)'}}>
232
+ {Object.entries(log.new_values).slice(0,2).map(([k,v])=>(
233
+ <span key={k} style={{marginRight:8}}>
234
+ <b style={{color:'var(--text-secondary)'}}>{k.replace(/_/g,' ')}:</b> {String(v??'—').slice(0,40)}
235
+ </span>
236
+ ))}
237
+ </div>
238
+ )}
239
+ </td>
240
+ <td className={styles.dateCell} title={format(new Date(log.created_at),'PPpp')}>
241
+ {formatDistanceToNow(new Date(log.created_at),{addSuffix:true})}
242
+ </td>
243
+ </tr>
244
+ ))}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+ {data?.pagination?.totalPages>1 && (
249
+ <div className={styles.pagination}>
250
+ <button className="btn btn-secondary btn-sm" disabled={page<=1} onClick={()=>setPage(p=>p-1)}>← Prev</button>
251
+ <span style={{fontSize:13,color:'var(--text-muted)'}}>Page {page} of {data.pagination.totalPages}</span>
252
+ <button className="btn btn-secondary btn-sm" disabled={page>=data.pagination.totalPages} onClick={()=>setPage(p=>p+1)}>Next →</button>
253
+ </div>
254
+ )}
255
+ </div>
256
+ );
257
+ }
frontend/src/pages/AdminPage.module.css ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .page { max-width: 1100px; }
2
+
3
+ .tabs { display:flex;gap:3px;border-bottom:1.5px solid var(--border);margin-bottom:22px; }
4
+ .tab {
5
+ display:inline-flex;align-items:center;gap:7px;padding:9px 16px;
6
+ font-size:13.5px;font-weight:500;color:var(--text-muted);
7
+ background:none;border:none;border-bottom:2px solid transparent;
8
+ cursor:pointer;transition:all .13s;margin-bottom:-1.5px;
9
+ }
10
+ .tab:hover { color:var(--text-primary); }
11
+ .tabActive { color:var(--accent);border-bottom-color:var(--accent); }
12
+
13
+ .sectionHead { display:flex;align-items:center;justify-content:space-between;margin-bottom:14px; }
14
+ .sectionCount { font-size:13px;font-weight:500;color:var(--text-muted); }
15
+
16
+ .inviteBox {
17
+ background:var(--bg-base);border:1.5px solid var(--border);
18
+ border-radius:var(--radius-lg);padding:14px;margin-bottom:14px;
19
+ }
20
+ .inviteRow { display:flex;gap:8px;align-items:center;flex-wrap:wrap; }
21
+
22
+ .tableWrap {
23
+ background:var(--bg-elevated);border:1.5px solid var(--border);
24
+ border-radius:var(--radius-lg);overflow:hidden;
25
+ }
26
+ .table { width:100%;border-collapse:collapse;font-size:13.5px; }
27
+ .table thead tr { border-bottom:1.5px solid var(--border);background:var(--bg-surface); }
28
+ .table th { padding:11px 14px;text-align:left;font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em; }
29
+ .table td { padding:13px 14px;border-bottom:1px solid var(--border);vertical-align:middle; }
30
+ .table tbody tr:last-child td { border-bottom:none; }
31
+ .table tbody tr:hover td { background:var(--bg-base); }
32
+
33
+ .userCell { display:flex;align-items:center;gap:10px; }
34
+ .userAvatar {
35
+ width:34px;height:34px;border-radius:50%;background:var(--accent);color:#fff;
36
+ font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;
37
+ flex-shrink:0;font-family:'Lora',serif;
38
+ }
39
+ .userName { font-size:13.5px;font-weight:500;color:var(--text-primary); }
40
+ .userEmail { font-size:12px;color:var(--text-muted);margin-top:1px; }
41
+
42
+ .dateCell { font-size:12px;color:var(--text-muted);white-space:nowrap; }
43
+ .rowActions { display:flex;align-items:center;gap:7px; }
44
+ .roleSelect {
45
+ background:var(--bg-base);border:1.5px solid var(--border);
46
+ border-radius:var(--radius);color:var(--text-secondary);
47
+ font-size:12.5px;padding:5px 9px;cursor:pointer;appearance:none;
48
+ }
49
+
50
+ .loadArea { display:flex;align-items:center;justify-content:center;padding:56px; }
51
+
52
+ .pagination { display:flex;align-items:center;justify-content:center;gap:14px;margin-top:18px; }
53
+
54
+ @media(max-width:640px){
55
+ .table th:nth-child(3),.table td:nth-child(3),
56
+ .table th:nth-child(4),.table td:nth-child(4){ display:none; }
57
+ }
frontend/src/pages/AuthPage.module.css ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .page {
2
+ min-height:100vh; display:flex; align-items:center; justify-content:center;
3
+ background:var(--bg-base); padding:24px; position:relative; overflow:hidden;
4
+ }
5
+ /* Subtle paper texture illusion with radial bg */
6
+ .page::before {
7
+ content:''; position:absolute; inset:0;
8
+ background:
9
+ radial-gradient(ellipse 60% 40% at 20% 20%, rgba(45,90,61,.05) 0%, transparent 60%),
10
+ radial-gradient(ellipse 50% 50% at 80% 80%, rgba(193,125,17,.04) 0%, transparent 60%);
11
+ pointer-events:none;
12
+ }
13
+
14
+ .card {
15
+ background:var(--bg-elevated); border:1.5px solid var(--border);
16
+ border-radius:var(--radius-xl); padding:38px 36px;
17
+ width:100%; max-width:430px; position:relative; z-index:1;
18
+ box-shadow:var(--shadow-lg);
19
+ }
20
+
21
+ .logoArea { display:flex;align-items:center;gap:10px;margin-bottom:26px; }
22
+ .logoMark { width:34px;height:34px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:16px;font-family:'Lora',serif; }
23
+ .logoName { font-family:'Lora',serif;font-size:20px;font-weight:700;color:var(--text-primary);letter-spacing:-.2px; }
24
+
25
+ .heading { font-family:'Lora',serif;font-size:19px;font-weight:700;margin-bottom:4px;color:var(--text-primary); }
26
+ .sub { font-size:13px;color:var(--text-muted);margin-bottom:26px; }
27
+ .form { display:flex;flex-direction:column;gap:14px; }
28
+
29
+ .pwWrap { position:relative; }
30
+ .pwWrap .field-input { padding-right:40px; }
31
+ .pwToggle { position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;display:flex;align-items:center;transition:color .15s; }
32
+ .pwToggle:hover { color:var(--text-secondary); }
33
+
34
+ .demos { margin-top:18px;padding-top:18px;border-top:1px solid var(--border); }
35
+ .demosLabel { font-size:12px;color:var(--text-muted);margin-bottom:9px;font-style:italic; }
36
+ .demoBtns { display:flex;gap:8px;flex-wrap:wrap; }
37
+
38
+ .switchLink { margin-top:18px;font-size:12.5px;color:var(--text-muted);text-align:center; }
39
+ .row { display:grid;grid-template-columns:1fr 1fr;gap:14px; }
40
+
41
+ @media(max-width:480px){
42
+ .card{padding:26px 18px;}
43
+ .row{grid-template-columns:1fr;}
44
+ }
frontend/src/pages/BoardPage.jsx ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef } from 'react';
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { Link } from 'react-router-dom';
4
+ import { Plus, MoreHorizontal, Calendar, User } from 'lucide-react';
5
+ import api from '../utils/api';
6
+ import { PriorityBadge, Spinner, PageHeader } from '../components/ui';
7
+ import TaskModal from '../components/tasks/TaskModal';
8
+ import toast from 'react-hot-toast';
9
+ import { format, isPast } from 'date-fns';
10
+ import '../components/ui/components.css';
11
+ import styles from './BoardPage.module.css';
12
+
13
+ const COLUMNS = [
14
+ { id: 'todo', label: 'To Do', color: 'var(--s-todo)', bg: 'var(--s-todo-bg)' },
15
+ { id: 'in_progress', label: 'In Progress', color: 'var(--s-progress)', bg: 'var(--s-progress-bg)' },
16
+ { id: 'in_review', label: 'In Review', color: 'var(--s-review)', bg: 'var(--s-review-bg)' },
17
+ { id: 'done', label: 'Done', color: 'var(--s-done)', bg: 'var(--s-done-bg)' },
18
+ ];
19
+
20
+ export default function BoardPage() {
21
+ const qc = useQueryClient();
22
+ const [showModal, setShowModal] = useState(false);
23
+ const [defaultStatus, setDefaultStatus] = useState('todo');
24
+ const [dragging, setDragging] = useState(null); // { taskId, fromStatus }
25
+ const [dragOver, setDragOver] = useState(null); // status column id
26
+ const dragItem = useRef(null);
27
+
28
+ const { data, isLoading } = useQuery({
29
+ queryKey: ['tasks-board'],
30
+ queryFn: () => api.get('/tasks', { params: { limit: 200 } }).then(r => r.data),
31
+ });
32
+
33
+ const moveMutation = useMutation({
34
+ mutationFn: ({ id, status }) => api.patch(`/tasks/${id}`, { status }),
35
+ onMutate: async ({ id, status }) => {
36
+ // Optimistic update
37
+ await qc.cancelQueries(['tasks-board']);
38
+ const prev = qc.getQueryData(['tasks-board']);
39
+ qc.setQueryData(['tasks-board'], old => ({
40
+ ...old,
41
+ tasks: old.tasks.map(t => t.id === id ? { ...t, status } : t),
42
+ }));
43
+ return { prev };
44
+ },
45
+ onError: (err, _vars, ctx) => {
46
+ qc.setQueryData(['tasks-board'], ctx.prev);
47
+ toast.error('Failed to move task.');
48
+ },
49
+ onSettled: () => {
50
+ qc.invalidateQueries(['tasks-board']);
51
+ qc.invalidateQueries(['task-stats']);
52
+ },
53
+ });
54
+
55
+ const tasksByStatus = (status) =>
56
+ (data?.tasks || []).filter(t => t.status === status);
57
+
58
+ /* ─── Drag handlers ─── */
59
+ const onDragStart = (e, task) => {
60
+ dragItem.current = task;
61
+ setDragging({ taskId: task.id, fromStatus: task.status });
62
+ e.dataTransfer.effectAllowed = 'move';
63
+ };
64
+
65
+ const onDragOver = (e, colId) => {
66
+ e.preventDefault();
67
+ e.dataTransfer.dropEffect = 'move';
68
+ setDragOver(colId);
69
+ };
70
+
71
+ const onDrop = (e, colId) => {
72
+ e.preventDefault();
73
+ const task = dragItem.current;
74
+ if (task && task.status !== colId) {
75
+ moveMutation.mutate({ id: task.id, status: colId });
76
+ }
77
+ setDragging(null);
78
+ setDragOver(null);
79
+ dragItem.current = null;
80
+ };
81
+
82
+ const onDragEnd = () => {
83
+ setDragging(null);
84
+ setDragOver(null);
85
+ };
86
+
87
+ const openCreate = (status) => {
88
+ setDefaultStatus(status);
89
+ setShowModal(true);
90
+ };
91
+
92
+ if (isLoading) return (
93
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 300 }}>
94
+ <span className="spinner-dark" />
95
+ </div>
96
+ );
97
+
98
+ return (
99
+ <div className={styles.page}>
100
+ <PageHeader
101
+ title="Board"
102
+ subtitle="Drag tasks between columns to update their status"
103
+ action={
104
+ <button className="btn btn-primary btn-md" onClick={() => openCreate('todo')}>
105
+ <Plus size={15} /> New Task
106
+ </button>
107
+ }
108
+ />
109
+
110
+ <div className={styles.board}>
111
+ {COLUMNS.map(col => {
112
+ const tasks = tasksByStatus(col.id);
113
+ const isOver = dragOver === col.id;
114
+
115
+ return (
116
+ <div
117
+ key={col.id}
118
+ className={`${styles.column} ${isOver ? styles.columnOver : ''}`}
119
+ onDragOver={e => onDragOver(e, col.id)}
120
+ onDrop={e => onDrop(e, col.id)}
121
+ >
122
+ {/* Column header */}
123
+ <div className={styles.colHeader}>
124
+ <div className={styles.colHeaderLeft}>
125
+ <div className={styles.colDot} style={{ background: col.color }} />
126
+ <span className={styles.colLabel}>{col.label}</span>
127
+ <span className={styles.colCount} style={{ background: col.bg, color: col.color }}>
128
+ {tasks.length}
129
+ </span>
130
+ </div>
131
+ <button
132
+ className={styles.addColBtn}
133
+ onClick={() => openCreate(col.id)}
134
+ title={`Add to ${col.label}`}
135
+ >
136
+ <Plus size={14} />
137
+ </button>
138
+ </div>
139
+
140
+ {/* Task cards */}
141
+ <div className={styles.cards}>
142
+ {tasks.length === 0 && (
143
+ <div className={`${styles.emptyCol} ${isOver ? styles.emptyColOver : ''}`}>
144
+ <span>Drop tasks here</span>
145
+ </div>
146
+ )}
147
+
148
+ {tasks.map(task => {
149
+ const overdue = task.due_date && isPast(new Date(task.due_date)) &&
150
+ !['done', 'cancelled'].includes(task.status);
151
+ const isDraggingThis = dragging?.taskId === task.id;
152
+
153
+ return (
154
+ <div
155
+ key={task.id}
156
+ className={`${styles.card} ${isDraggingThis ? styles.cardDragging : ''}`}
157
+ draggable
158
+ onDragStart={e => onDragStart(e, task)}
159
+ onDragEnd={onDragEnd}
160
+ >
161
+ <div className={styles.cardTop}>
162
+ <PriorityBadge priority={task.priority} />
163
+ <Link to={`/tasks/${task.id}`} className={styles.cardMenuBtn} title="Open task">
164
+ <MoreHorizontal size={14} />
165
+ </Link>
166
+ </div>
167
+
168
+ <Link to={`/tasks/${task.id}`} className={styles.cardTitle}>
169
+ {task.title}
170
+ </Link>
171
+
172
+ {task.description && (
173
+ <p className={styles.cardDesc}>
174
+ {task.description.slice(0, 80)}{task.description.length > 80 ? '…' : ''}
175
+ </p>
176
+ )}
177
+
178
+ <div className={styles.cardMeta}>
179
+ {task.assignee_name && (
180
+ <span className={styles.cardAssignee}>
181
+ <span className={styles.miniAvatar}>
182
+ {task.assignee_name[0].toUpperCase()}
183
+ </span>
184
+ {task.assignee_name.split(' ')[0]}
185
+ </span>
186
+ )}
187
+ {task.due_date && (
188
+ <span className={`${styles.cardDue} ${overdue ? styles.cardDueOverdue : ''}`}>
189
+ <Calendar size={11} />
190
+ {format(new Date(task.due_date), 'MMM d')}
191
+ </span>
192
+ )}
193
+ </div>
194
+ </div>
195
+ );
196
+ })}
197
+ </div>
198
+ </div>
199
+ );
200
+ })}
201
+ </div>
202
+
203
+ {showModal && (
204
+ <TaskModal
205
+ task={null}
206
+ defaultStatus={defaultStatus}
207
+ onClose={() => setShowModal(false)}
208
+ onSuccess={() => {
209
+ setShowModal(false);
210
+ qc.invalidateQueries(['tasks-board']);
211
+ qc.invalidateQueries(['task-stats']);
212
+ }}
213
+ />
214
+ )}
215
+ </div>
216
+ );
217
+ }
frontend/src/pages/BoardPage.module.css ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .page { max-width: 100%; }
2
+
3
+ .board {
4
+ display: grid;
5
+ grid-template-columns: repeat(4, minmax(230px, 1fr));
6
+ gap: 16px;
7
+ align-items: start;
8
+ overflow-x: auto;
9
+ padding-bottom: 16px;
10
+ }
11
+
12
+ .column {
13
+ background: var(--bg-surface);
14
+ border: 1.5px solid var(--border);
15
+ border-radius: var(--radius-lg);
16
+ padding: 14px;
17
+ min-height: 300px;
18
+ transition: border-color .15s, background .15s;
19
+ }
20
+
21
+ .columnOver {
22
+ border-color: var(--accent);
23
+ background: var(--accent-light);
24
+ }
25
+
26
+ .colHeader {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ margin-bottom: 12px;
31
+ }
32
+
33
+ .colHeaderLeft {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 7px;
37
+ }
38
+
39
+ .colDot {
40
+ width: 8px;
41
+ height: 8px;
42
+ border-radius: 50%;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .colLabel {
47
+ font-size: 13px;
48
+ font-weight: 600;
49
+ color: var(--text-secondary);
50
+ font-family: 'Inter', sans-serif;
51
+ }
52
+
53
+ .colCount {
54
+ font-size: 11px;
55
+ font-weight: 700;
56
+ padding: 1px 7px;
57
+ border-radius: 100px;
58
+ font-family: 'Inter', sans-serif;
59
+ }
60
+
61
+ .addColBtn {
62
+ background: none;
63
+ border: 1px solid var(--border);
64
+ border-radius: var(--radius);
65
+ color: var(--text-muted);
66
+ width: 26px;
67
+ height: 26px;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ cursor: pointer;
72
+ transition: all .15s;
73
+ }
74
+ .addColBtn:hover { background: var(--bg-hover); color: var(--text-secondary); border-color: var(--border-strong); }
75
+
76
+ .cards {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 10px;
80
+ min-height: 80px;
81
+ }
82
+
83
+ .emptyCol {
84
+ border: 1.5px dashed var(--border);
85
+ border-radius: var(--radius);
86
+ padding: 20px;
87
+ text-align: center;
88
+ font-size: 12px;
89
+ color: var(--text-faint);
90
+ transition: all .15s;
91
+ }
92
+
93
+ .emptyColOver {
94
+ border-color: var(--accent);
95
+ color: var(--accent);
96
+ background: var(--accent-light);
97
+ }
98
+
99
+ /* Task card */
100
+ .card {
101
+ background: var(--bg-elevated);
102
+ border: 1.5px solid var(--border);
103
+ border-radius: var(--radius);
104
+ padding: 12px;
105
+ cursor: grab;
106
+ transition: all .15s;
107
+ user-select: none;
108
+ }
109
+
110
+ .card:hover {
111
+ border-color: var(--border-strong);
112
+ box-shadow: var(--shadow-md);
113
+ transform: translateY(-1px);
114
+ }
115
+
116
+ .card:active { cursor: grabbing; }
117
+
118
+ .cardDragging {
119
+ opacity: 0.45;
120
+ transform: rotate(1.5deg) scale(0.97);
121
+ }
122
+
123
+ .cardTop {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ margin-bottom: 8px;
128
+ }
129
+
130
+ .cardMenuBtn {
131
+ color: var(--text-faint);
132
+ display: flex;
133
+ align-items: center;
134
+ padding: 2px;
135
+ border-radius: 4px;
136
+ transition: all .12s;
137
+ text-decoration: none;
138
+ }
139
+ .cardMenuBtn:hover { color: var(--text-secondary); background: var(--bg-hover); text-decoration: none; }
140
+
141
+ .cardTitle {
142
+ display: block;
143
+ font-size: 13.5px;
144
+ font-weight: 500;
145
+ color: var(--text-primary);
146
+ line-height: 1.4;
147
+ margin-bottom: 6px;
148
+ text-decoration: none;
149
+ transition: color .12s;
150
+ }
151
+ .cardTitle:hover { color: var(--accent); text-decoration: none; }
152
+
153
+ .cardDesc {
154
+ font-size: 12px;
155
+ color: var(--text-muted);
156
+ line-height: 1.5;
157
+ margin-bottom: 10px;
158
+ }
159
+
160
+ .cardMeta {
161
+ display: flex;
162
+ align-items: center;
163
+ justify-content: space-between;
164
+ gap: 8px;
165
+ flex-wrap: wrap;
166
+ }
167
+
168
+ .cardAssignee {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 5px;
172
+ font-size: 11.5px;
173
+ color: var(--text-muted);
174
+ }
175
+
176
+ .miniAvatar {
177
+ width: 18px;
178
+ height: 18px;
179
+ border-radius: 50%;
180
+ background: var(--accent);
181
+ color: #fff;
182
+ font-size: 9px;
183
+ font-weight: 700;
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ flex-shrink: 0;
188
+ }
189
+
190
+ .cardDue {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 4px;
194
+ font-size: 11px;
195
+ color: var(--text-muted);
196
+ background: var(--bg-base);
197
+ padding: 2px 7px;
198
+ border-radius: 4px;
199
+ border: 1px solid var(--border);
200
+ }
201
+
202
+ .cardDueOverdue {
203
+ color: var(--rose);
204
+ background: var(--rose-light);
205
+ border-color: var(--rose-border);
206
+ }
207
+
208
+ @media (max-width: 960px) {
209
+ .board { grid-template-columns: repeat(2, 1fr); }
210
+ }
211
+
212
+ @media (max-width: 560px) {
213
+ .board { grid-template-columns: 1fr; }
214
+ }
frontend/src/pages/DashboardPage.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { CheckCircle2, Clock, Zap, CircleDot, TrendingUp, Plus, AlertTriangle, BarChart2 } from 'lucide-react';
5
+ import api from '../utils/api';
6
+ import { useAuth } from '../context/AuthContext';
7
+ import { StatusBadge, PriorityBadge, Spinner, PageHeader } from '../components/ui';
8
+ import { formatDistanceToNow, format, isPast } from 'date-fns';
9
+ import '../components/ui/components.css';
10
+ import styles from './DashboardPage.module.css';
11
+
12
+ const STATUS_META = {
13
+ todo: { label:'To Do', icon:CircleDot, color:'var(--s-todo)', bg:'var(--s-todo-bg)' },
14
+ in_progress: { label:'In Progress', icon:TrendingUp, color:'var(--s-progress)', bg:'var(--s-progress-bg)' },
15
+ in_review: { label:'In Review', icon:Zap, color:'var(--s-review)', bg:'var(--s-review-bg)' },
16
+ done: { label:'Done', icon:CheckCircle2, color:'var(--s-done)', bg:'var(--s-done-bg)' },
17
+ };
18
+
19
+ export default function DashboardPage() {
20
+ const { user } = useAuth();
21
+
22
+ const { data:stats, isLoading } = useQuery({
23
+ queryKey:['task-stats'],
24
+ queryFn:()=>api.get('/tasks/stats').then(r=>r.data),
25
+ });
26
+
27
+ if (isLoading) return <div className={styles.center}><span className="spinner-dark"/></div>;
28
+
29
+ const total = Object.values(stats?.statusCounts||{}).reduce((a,b)=>a+b,0);
30
+
31
+ return (
32
+ <div className={styles.page}>
33
+ <PageHeader
34
+ title={`Hello, ${user?.name?.split(' ')[0]}`}
35
+ subtitle={`${user?.orgName} · ${user?.role === 'admin' ? 'Administrator' : 'Team Member'}`}
36
+ action={
37
+ <Link to="/tasks" className="btn btn-primary btn-md">
38
+ <Plus size={15}/> New Task
39
+ </Link>
40
+ }
41
+ />
42
+
43
+ {/* KPI cards */}
44
+ <div className={styles.kpiGrid}>
45
+ {Object.entries(STATUS_META).map(([key, m]) => {
46
+ const Icon = m.icon;
47
+ const count = stats?.statusCounts?.[key] || 0;
48
+ return (
49
+ <Link to={`/tasks?status=${key}`} key={key} className={styles.kpiCard}>
50
+ <div className={styles.kpiIconWrap} style={{background:m.bg,color:m.color}}>
51
+ <Icon size={18}/>
52
+ </div>
53
+ <div className={styles.kpiBody}>
54
+ <span className={styles.kpiCount}>{count}</span>
55
+ <span className={styles.kpiLabel}>{m.label}</span>
56
+ </div>
57
+ {key==='in_progress' && count>0 && <div className={styles.kpiPulse}/>}
58
+ </Link>
59
+ );
60
+ })}
61
+
62
+ <div className={styles.kpiCard} style={{cursor:'default'}}>
63
+ <div className={styles.kpiIconWrap} style={{background:'var(--rose-light)',color:'var(--rose)'}}>
64
+ <AlertTriangle size={18}/>
65
+ </div>
66
+ <div className={styles.kpiBody}>
67
+ <span className={styles.kpiCount} style={stats?.overdueCount>0?{color:'var(--rose)'}:{}}>
68
+ {stats?.overdueCount||0}
69
+ </span>
70
+ <span className={styles.kpiLabel}>Overdue</span>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ <div className={styles.grid2}>
76
+ {/* Recent activity */}
77
+ <div className="card">
78
+ <div className={styles.cardHead}>
79
+ <h3 className={styles.cardTitle}>Recent Activity</h3>
80
+ <Link to="/tasks" className="btn btn-ghost btn-sm">View all →</Link>
81
+ </div>
82
+
83
+ {stats?.recentTasks?.length > 0 ? (
84
+ <div className={styles.activityList}>
85
+ {stats.recentTasks.map(task => (
86
+ <Link key={task.id} to={`/tasks/${task.id}`} className={styles.activityRow}>
87
+ <div className={styles.activityLeft}>
88
+ <StatusBadge status={task.status}/>
89
+ <div className={styles.activityInfo}>
90
+ <span className={styles.activityTitle}>{task.title}</span>
91
+ {task.assignee_name && <span className={styles.activityMeta}>→ {task.assignee_name}</span>}
92
+ </div>
93
+ </div>
94
+ <span className={styles.activityTime}>
95
+ {formatDistanceToNow(new Date(task.updated_at),{addSuffix:true})}
96
+ </span>
97
+ </Link>
98
+ ))}
99
+ </div>
100
+ ) : (
101
+ <p className={styles.emptyNote}>No tasks yet. <Link to="/tasks">Create your first task →</Link></p>
102
+ )}
103
+ </div>
104
+
105
+ {/* Priority breakdown */}
106
+ <div className="card">
107
+ <div className={styles.cardHead}>
108
+ <h3 className={styles.cardTitle}>Priority Breakdown</h3>
109
+ <span className={styles.totalPill}>{total} total</span>
110
+ </div>
111
+
112
+ <div className={styles.priorityChart}>
113
+ {[
114
+ {key:'critical',label:'Critical',color:'var(--p-critical)'},
115
+ {key:'high', label:'High', color:'var(--p-high)'},
116
+ {key:'medium', label:'Medium', color:'var(--p-medium)'},
117
+ {key:'low', label:'Low', color:'var(--p-low)'},
118
+ ].map(({key,label,color})=>{
119
+ const count = stats?.priorityCounts?.[key]||0;
120
+ const pct = total>0?(count/total)*100:0;
121
+ return (
122
+ <div key={key} className={styles.pRow}>
123
+ <span className={styles.pLabel}>{label}</span>
124
+ <div className={styles.pTrack}>
125
+ <div className={styles.pFill} style={{width:`${pct}%`,background:color}}/>
126
+ </div>
127
+ <span className={styles.pCount}>{count}</span>
128
+ </div>
129
+ );
130
+ })}
131
+ </div>
132
+
133
+ <Link to="/board" className={styles.boardLink}>
134
+ <BarChart2 size={14}/> View Kanban Board →
135
+ </Link>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ );
140
+ }