feat(week8): JWT auth, SSM Parameter Store, and UI overhaul

This commit is contained in:
ikechukwu Elizabeth
2026-05-30 07:06:21 +01:00
parent 0de2d1dd42
commit dcaef8ff13
49 changed files with 8868 additions and 723 deletions
+57 -17
View File
@@ -1,36 +1,76 @@
const { Pool } = require("pg");
const { Pool } = require('pg')
// Connection pool — reuses connections instead of
// opening a new one for every request
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: {
rejectUnauthorized: false, // Required for AWS RDS
},
});
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
})
// Create the tasks table if it does not already exist.
// Runs once on server startup.
// In CI there is no real database — the error is logged
// In CI there is no real database - the error is logged
// and the server starts anyway so the health check passes.
async function initDb() {
try {
// Users table - must exist before tasks (foreign key)
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
// Tasks table
await pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
console.log("Database initialised — tasks table ready");
`)
// Add user_id to tasks if upgrading from Week 7
await pool.query(`
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
`)
// Projects table
await pool.query(`
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
// Team members table
await pool.query(`
CREATE TABLE IF NOT EXISTS team_members (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(owner_id, email)
);
`)
console.log('Database initialised - all tables ready')
} catch (err) {
console.warn("Database not available — running without persistent storage:", err.message);
console.warn(
'Database not available - running without persistent storage:',
err.message
)
}
}
module.exports = { pool, initDb };
module.exports = { pool, initDb }
+24
View File
@@ -0,0 +1,24 @@
const jwt = require("jsonwebtoken");
const JWT_SECRET = process.env.JWT_SECRET || "changeme-use-a-real-secret-in-production";
// Protects routes - attaches req.user if token is valid
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = header.split(" ")[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // { id, email, name }
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
module.exports = { requireAuth, JWT_SECRET };
+138 -12
View File
@@ -8,8 +8,10 @@
"name": "task-backend",
"version": "2.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^17.4.0",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3"
},
"devDependencies": {
@@ -2087,10 +2089,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -2101,7 +2109,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -2168,6 +2176,12 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2639,6 +2653,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3026,14 +3049,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -3052,7 +3075,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -4474,6 +4497,67 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4531,6 +4615,48 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5278,9 +5404,9 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
+3 -1
View File
@@ -10,8 +10,10 @@
"lint": "eslint . --ext .js --max-warnings=0"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^17.4.0",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3"
},
"devDependencies": {
@@ -20,4 +22,4 @@
"jest": "^30.3.0",
"supertest": "^7.2.2"
}
}
}
+112
View File
@@ -0,0 +1,112 @@
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { pool } = require("../db");
const { requireAuth, JWT_SECRET } = require("../middleware/auth");
const router = express.Router();
// Helper - sign a JWT for a user row
function signToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, name: user.name },
JWT_SECRET,
{ expiresIn: "7d" }
);
}
// POST /api/auth/register
router.post("/register", async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: "Name, email, and password are required" });
}
if (password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
}
try {
// Check if email already exists
const existing = await pool.query(
"SELECT id FROM users WHERE email = $1",
[email.toLowerCase().trim()]
);
if (existing.rowCount > 0) {
return res.status(409).json({ error: "An account with that email already exists" });
}
// Hash password
const hashed = await bcrypt.hash(password, 12);
// Insert user
const result = await pool.query(
"INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email, created_at",
[name.trim(), email.toLowerCase().trim(), hashed]
);
const user = result.rows[0];
const token = signToken(user);
res.status(201).json({ token, user: { id: user.id, name: user.name, email: user.email } });
} catch (err) {
console.error("POST /api/auth/register error:", err);
res.status(500).json({ error: "Registration failed" });
}
});
// POST /api/auth/login
router.post("/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
try {
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[email.toLowerCase().trim()]
);
if (result.rowCount === 0) {
return res.status(401).json({ error: "Invalid email or password" });
}
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password);
if (!match) {
return res.status(401).json({ error: "Invalid email or password" });
}
const token = signToken(user);
res.json({ token, user: { id: user.id, name: user.name, email: user.email } });
} catch (err) {
console.error("POST /api/auth/login error:", err);
res.status(500).json({ error: "Login failed" });
}
});
// GET /api/auth/me - returns current user from token
router.get("/me", requireAuth, async (req, res) => {
try {
const result = await pool.query(
"SELECT id, name, email, created_at FROM users WHERE id = $1",
[req.user.id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "User not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error("GET /api/auth/me error:", err);
res.status(500).json({ error: "Failed to fetch user" });
}
});
module.exports = router;
+60
View File
@@ -0,0 +1,60 @@
const express = require('express')
const { pool } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
// All project routes require a valid JWT
router.use(requireAuth)
// GET /api/projects - return all projects for the logged-in user
router.get('/', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM projects WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id]
)
res.json(result.rows)
} catch (err) {
console.error('GET /api/projects error:', err)
res.status(500).json({ error: 'Failed to fetch projects' })
}
})
// POST /api/projects - create a new project
router.post('/', async (req, res) => {
const { name, description } = req.body
if (!name) {
return res.status(400).json({ error: 'Project name is required' })
}
try {
const result = await pool.query(
'INSERT INTO projects (name, description, user_id) VALUES ($1, $2, $3) RETURNING *',
[name.trim(), description || '', req.user.id]
)
res.status(201).json(result.rows[0])
} catch (err) {
console.error('POST /api/projects error:', err)
res.status(500).json({ error: 'Failed to create project' })
}
})
// DELETE /api/projects/:id - delete a project (owner only)
router.delete('/:id', async (req, res) => {
const { id } = req.params
try {
const result = await pool.query(
'DELETE FROM projects WHERE id = $1 AND user_id = $2',
[id, req.user.id]
)
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Project not found' })
}
res.status(204).send()
} catch (err) {
console.error('DELETE /api/projects/:id error:', err)
res.status(500).json({ error: 'Failed to delete project' })
}
})
module.exports = router
+81
View File
@@ -0,0 +1,81 @@
const express = require("express");
const { pool } = require("../db");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
// All task routes require a valid JWT
router.use(requireAuth);
// GET /api/tasks - return tasks for the logged-in user, newest first
router.get("/", async (req, res) => {
try {
const result = await pool.query(
"SELECT * FROM tasks WHERE user_id = $1 ORDER BY created_at DESC",
[req.user.id]
);
res.json(result.rows);
} catch (err) {
console.error("GET /api/tasks error:", err);
res.status(500).json({ error: "Failed to fetch tasks" });
}
});
// POST /api/tasks - create a task for the logged-in user
router.post("/", async (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: "Title is required" });
}
try {
const result = await pool.query(
"INSERT INTO tasks (title, user_id) VALUES ($1, $2) RETURNING *",
[title, req.user.id]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error("POST /api/tasks error:", err);
res.status(500).json({ error: "Failed to create task" });
}
});
// PATCH /api/tasks/:id - toggle completed (only owner can update)
router.patch("/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`UPDATE tasks
SET completed = NOT completed
WHERE id = $1 AND user_id = $2
RETURNING *`,
[id, req.user.id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error("PATCH /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to update task" });
}
});
// DELETE /api/tasks/:id - delete (only owner can delete)
router.delete("/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
"DELETE FROM tasks WHERE id = $1 AND user_id = $2",
[id, req.user.id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.status(204).send();
} catch (err) {
console.error("DELETE /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to delete task" });
}
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
const express = require('express')
const { pool } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
// All team routes require a valid JWT
router.use(requireAuth)
// GET /api/team - return all team members for the logged-in user's workspace
router.get('/', async (req, res) => {
try {
const result = await pool.query(
`SELECT tm.id, tm.email, tm.role, tm.created_at,
u.name
FROM team_members tm
LEFT JOIN users u ON u.email = tm.email
WHERE tm.owner_id = $1
ORDER BY tm.created_at ASC`,
[req.user.id]
)
res.json(result.rows)
} catch (err) {
console.error('GET /api/team error:', err)
res.status(500).json({ error: 'Failed to fetch team members' })
}
})
// POST /api/team/invite - invite a team member by email
router.post('/invite', async (req, res) => {
const { email, role } = req.body
if (!email) {
return res.status(400).json({ error: 'Email is required' })
}
const validRoles = ['admin', 'member', 'viewer']
const memberRole = validRoles.includes(role) ? role : 'member'
try {
// Check if already invited
const existing = await pool.query(
'SELECT id FROM team_members WHERE owner_id = $1 AND email = $2',
[req.user.id, email.toLowerCase().trim()]
)
if (existing.rowCount > 0) {
return res.status(409).json({ error: 'This person has already been invited' })
}
const result = await pool.query(
'INSERT INTO team_members (owner_id, email, role) VALUES ($1, $2, $3) RETURNING *',
[req.user.id, email.toLowerCase().trim(), memberRole]
)
res.status(201).json(result.rows[0])
} catch (err) {
console.error('POST /api/team/invite error:', err)
res.status(500).json({ error: 'Failed to send invitation' })
}
})
// DELETE /api/team/:id - remove a team member
router.delete('/:id', async (req, res) => {
const { id } = req.params
try {
const result = await pool.query(
'DELETE FROM team_members WHERE id = $1 AND owner_id = $2',
[id, req.user.id]
)
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Team member not found' })
}
res.status(204).send()
} catch (err) {
console.error('DELETE /api/team/:id error:', err)
res.status(500).json({ error: 'Failed to remove team member' })
}
})
module.exports = router
+37 -100
View File
@@ -1,124 +1,61 @@
require("dotenv").config();
const express = require("express");
const { pool, initDb } = require("./db");
require('dotenv').config()
const express = require('express')
const { initDb } = require('./db')
const authRoutes = require('./routes/authRoutes')
const taskRoutes = require('./routes/taskRoutes')
const projectRoutes = require('./routes/projectRoutes')
const teamRoutes = require('./routes/teamRoutes')
const app = express();
const PORT = process.env.PORT || 3000;
const app = express()
const PORT = process.env.PORT || 3000
app.use(express.json());
app.use(express.json())
// ── Routes ────────────────────────────────────
// -- Health and root ----------------------------------
app.get("/api", (req, res) => {
res.json({ message: "Task API is running", version: "2.0.0" });
});
app.get('/api', (req, res) => {
res.json({ message: 'Task API is running', version: '3.0.0' })
})
app.get("/api/health", (req, res) => {
app.get('/api/health', (req, res) => {
res.status(200).json({
status: "ok",
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
})
})
// GET /api/tasks — return all tasks, newest first
app.get("/api/tasks", async (req, res) => {
try {
const result = await pool.query(
"SELECT * FROM tasks ORDER BY created_at DESC"
);
res.json(result.rows);
} catch (err) {
console.error("GET /api/tasks error:", err);
res.status(500).json({ error: "Failed to fetch tasks" });
}
});
// -- Routes -------------------------------------------
// POST /api/tasks — create a new task
app.post("/api/tasks", async (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: "Title is required" });
}
try {
const result = await pool.query(
"INSERT INTO tasks (title) VALUES ($1) RETURNING *",
[title]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error("POST /api/tasks error:", err);
res.status(500).json({ error: "Failed to create task" });
}
});
app.use('/api/auth', authRoutes)
app.use('/api/tasks', taskRoutes)
app.use('/api/projects', projectRoutes)
app.use('/api/team', teamRoutes)
// PATCH /api/tasks/:id — toggle completed status
app.patch("/api/tasks/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`UPDATE tasks
SET completed = NOT completed
WHERE id = $1
RETURNING *`,
[id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error("PATCH /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to update task" });
}
});
// DELETE /api/tasks/:id — delete a task
app.delete("/api/tasks/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
"DELETE FROM tasks WHERE id = $1",
[id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.status(204).send();
} catch (err) {
console.error("DELETE /api/tasks/:id error:", err);
res.status(500).json({ error: "Failed to delete task" });
}
});
// ── Error handlers ────────────────────────────
// -- Error handlers -----------------------------------
app.use((req, res) => {
res.status(404).json({ error: "Route not found" });
});
res.status(404).json({ error: 'Route not found' })
})
app.use((err, req, res, _next) => {
console.error(err.stack);
res.status(500).json({ error: "Internal server error" });
});
console.error(err.stack)
res.status(500).json({ error: 'Internal server error' })
})
// -- Start --------------------------------------------
// ── Start ─────────────────────────────────────
// initDb runs first — creates the tasks table if it
// does not exist, then starts the server.
// Wrapped in require.main check so Jest can import
// the app without starting the server or connecting
// to the database.
if (require.main === module) {
initDb()
.then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
console.log(`Server running on port ${PORT}`)
})
})
.catch((err) => {
console.error("Failed to initialise database:", err);
process.exit(1);
});
console.error('Failed to initialise database:', err)
process.exit(1)
})
}
module.exports = app;
module.exports = app