feat(week8): JWT auth, SSM Parameter Store, and UI overhaul
This commit is contained in:
+57
-17
@@ -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 }
|
||||
|
||||
@@ -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 };
|
||||
Generated
+138
-12
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user