diff --git a/database/schema.sql b/database/schema.sql index 943c800..aeccf93 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -269,4 +269,29 @@ CREATE TABLE roadmap_items ( PRIMARY KEY(roadmap_id, material_id) ); -COMMIT; +--- + + -- Table to store onboarding questions + CREATE TABLE onboarding_questions ( + id SERIAL PRIMARY KEY, + question_text TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0 + ); + + -- Table to store answer options for each question + CREATE TABLE onboarding_question_options ( + id SERIAL PRIMARY KEY, + question_id INTEGER NOT NULL REFERENCES onboarding_questions(id) ON DELETE CASCADE, + option_text TEXT NOT NULL, + tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE + ); + + -- Table to store user responses + CREATE TABLE onboarding_responses ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_id INTEGER NOT NULL REFERENCES onboarding_question_options(id) ON DELETE CASCADE, + PRIMARY KEY(user_id, option_id) + ); + + +COMMIT; \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js index 3615166..0d601f9 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -13,7 +13,6 @@ function setAuthCookie(res, payload) { }); } -// SIGN UP → POST /api/signup router.post("/signup", async (req, res) => { const { email, password, firstname, lastname } = req.body; try { @@ -45,7 +44,6 @@ router.post("/signup", async (req, res) => { } }); -// LOG IN → POST /api/login router.post("/login", async (req, res) => { const { email, password } = req.body; try { @@ -90,12 +88,10 @@ router.post("/login", async (req, res) => { } }); -// LOGOUT → POST /api/logout router.post("/logout", (req, res) => { res.clearCookie("auth", { path: "/" }).json({ success: true }); }); -// WHOAMI → GET /api/me router.get("/me", (req, res) => { const { auth } = req.cookies; if (!auth) return res.json({ isLoggedIn: false }); @@ -118,11 +114,6 @@ router.post("/complete-onboarding", async (req, res) => { } try { - await pool.query( - `UPDATE users SET has_completed_onboarding = true WHERE id = $1`, - [user.userId] - ); - const mem = await pool.query( `SELECT o.id AS id, @@ -137,7 +128,33 @@ router.post("/complete-onboarding", async (req, res) => { const organisation = mem.rows[0] || null; - // Regenerate auth cookie + if (organisation && organisation.role === "employee") { + const questionCheck = await pool.query( + `SELECT COUNT(*) as question_count FROM onboarding_questions WHERE organisation_id = $1`, + [organisation.id] + ); + + const hasQuestions = parseInt(questionCheck.rows[0].question_count) > 0; + + if (hasQuestions) { + const responseCheck = await pool.query( + `SELECT COUNT(*) as response_count FROM onboarding_responses WHERE user_id = $1`, + [user.userId] + ); + + if (parseInt(responseCheck.rows[0].response_count) === 0) { + return res.status(400).json({ + message: "Onboarding questionnaire must be completed first", + }); + } + } + } + + await pool.query( + `UPDATE users SET has_completed_onboarding = true WHERE id = $1`, + [user.userId] + ); + setAuthCookie(res, { ...user, hasCompletedOnboarding: true, diff --git a/routes/courses.js b/routes/courses.js index 6ef818a..2065ff2 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1810,10 +1810,14 @@ router.post("/add-tags", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { return res.status(403).json({ message: "Forbidden" }); } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } const { tags } = req.body; if (!Array.isArray(tags)) { return res.status(400).json({ message: "tags[] are required" }); @@ -1827,10 +1831,10 @@ router.post("/add-tags", async (req, res) => { continue; // skip if tag has no name } await client.query( - `INSERT INTO tags (name) - VALUES ($1) - ON CONFLICT (name) DO NOTHING`, - [tag.name] + `INSERT INTO tags (name, organisation_id) + VALUES ($1, $2) + ON CONFLICT (name, organisation_id) DO NOTHING`, + [tag.name, organisationId] ); } await client.query("COMMIT"); @@ -1845,10 +1849,26 @@ router.post("/add-tags", async (req, res) => { }); router.get("/tags", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + const client = await pool.connect(); try { const { rows } = await client.query( - `SELECT id, name FROM tags ORDER BY name` + `SELECT id, name FROM tags WHERE organisation_id = $1 ORDER BY name`, + [organisationId] ); res.json(rows); } catch (err) { @@ -1870,10 +1890,14 @@ router.delete("/delete-tag", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { return res.status(403).json({ message: "Forbidden" }); } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } const { tagId } = req.body; if (!tagId) { return res.status(400).json({ message: "tagId is required" }); @@ -1882,7 +1906,7 @@ router.delete("/delete-tag", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM tags WHERE id = $1`, [tagId]); + await client.query(`DELETE FROM tags WHERE id = $1 AND organisation_id = $2`, [tagId, organisationId]); await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { diff --git a/routes/onboarding.js b/routes/onboarding.js new file mode 100644 index 0000000..021b8d5 --- /dev/null +++ b/routes/onboarding.js @@ -0,0 +1,385 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +function isAdmin(user) { + return user && user.organisation && user.organisation.role === "admin"; +} + +function isEmployee(user) { + return user && user.organisation && user.organisation.role === "employee"; +} + +router.get("/questions", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + const questionsResult = await pool.query( + ` + SELECT id, question_text, position + FROM onboarding_questions + WHERE organisation_id = $1 + ORDER BY position ASC + `, + [organisationId] + ); + + const questions = []; + for (const question of questionsResult.rows) { + const optionsResult = await pool.query( + ` + SELECT oqo.id, oqo.option_text, oqo.tag_id, t.name as tag_name + FROM onboarding_question_options oqo + LEFT JOIN tags t ON t.id = oqo.tag_id + WHERE oqo.question_id = $1 + ORDER BY oqo.id ASC + `, + [question.id] + ); + + questions.push({ + id: question.id, + question_text: question.question_text, + position: question.position, + options: optionsResult.rows, + }); + } + + res.json({ questions }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/questions", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { question_text, position = 0 } = req.body; + if (!question_text) { + return res.status(400).json({ message: "question_text is required" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + const result = await pool.query( + ` + INSERT INTO onboarding_questions (question_text, position, organisation_id) + VALUES ($1, $2, $3) + RETURNING id, question_text, position + `, + [question_text, position, organisationId] + ); + + res.status(201).json({ question: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/questions/:id/options", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { id } = req.params; + const { option_text, tag_id } = req.body; + + if (!option_text) { + return res.status(400).json({ message: "option_text is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const organisationId = user.organisation?.id; + if (!organisationId) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Organization required" }); + } + + const questionCheck = await client.query( + "SELECT id FROM onboarding_questions WHERE id = $1 AND organisation_id = $2", + [id, organisationId] + ); + if (questionCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Question not found" }); + } + + let tagCheck = { rows: [] }; + if (tag_id) { + tagCheck = await client.query( + "SELECT id, name FROM tags WHERE id = $1 AND organisation_id = $2", + [tag_id, organisationId] + ); + if (tagCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Tag not found" }); + } + } + + const result = await client.query( + ` + INSERT INTO onboarding_question_options (question_id, option_text, tag_id) + VALUES ($1, $2, $3) + RETURNING id, option_text, tag_id + `, + [id, option_text, tag_id || null] + ); + + await client.query("COMMIT"); + + const option = { + ...result.rows[0], + tag_name: tag_id ? tagCheck.rows[0].name : null, + }; + + res.status(201).json({ option }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/questions/:id", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { id } = req.params; + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + // Check if question has any options before deletion + const optionCheck = await pool.query( + "SELECT COUNT(*) as option_count FROM onboarding_question_options WHERE question_id = $1", + [id] + ); + + const hasOptions = parseInt(optionCheck.rows[0].option_count) > 0; + if (hasOptions) { + return res.status(400).json({ + message: + "Cannot delete question that has options. Please delete all options first.", + }); + } + + const result = await pool.query( + "DELETE FROM onboarding_questions WHERE id = $1 AND organisation_id = $2 RETURNING id", + [id, organisationId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ message: "Question not found" }); + } + + res.json({ message: "Question deleted successfully" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.delete("/options/:optionId", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { optionId } = req.params; + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + const optionCheck = await pool.query( + ` + SELECT oqo.question_id, oq.organisation_id + FROM onboarding_question_options oqo + JOIN onboarding_questions oq ON oq.id = oqo.question_id + WHERE oqo.id = $1 + `, + [optionId] + ); + + if (optionCheck.rows.length === 0) { + return res.status(404).json({ message: "Option not found" }); + } + + const option = optionCheck.rows[0]; + if (option.organisation_id !== organisationId) { + return res.status(403).json({ message: "Access denied" }); + } + + const optionCountCheck = await pool.query( + "SELECT COUNT(*) as option_count FROM onboarding_question_options WHERE question_id = $1", + [option.question_id] + ); + + const optionCount = parseInt(optionCountCheck.rows[0].option_count); + if (optionCount <= 1) { + return res.status(400).json({ + message: + "Cannot delete the last option. Questions must have at least one option.", + }); + } + + const result = await pool.query( + "DELETE FROM onboarding_question_options WHERE id = $1 RETURNING id", + [optionId] + ); + + res.json({ message: "Option deleted successfully" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/responses", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + const { option_ids } = req.body; + if (!option_ids || !Array.isArray(option_ids) || option_ids.length === 0) { + return res.status(400).json({ message: "option_ids array is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query("DELETE FROM onboarding_responses WHERE user_id = $1", [ + user.userId, + ]); + + for (const optionId of option_ids) { + await client.query( + ` + INSERT INTO onboarding_responses (user_id, option_id) + VALUES ($1, $2) + ON CONFLICT (user_id, option_id) DO NOTHING + `, + [user.userId, optionId] + ); + } + + await client.query( + "UPDATE users SET has_completed_onboarding = true WHERE id = $1", + [user.userId] + ); + + await client.query("COMMIT"); + + res.json({ message: "Responses submitted successfully" }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/responses", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isEmployee(user)) { + return res.status(403).json({ message: "Employee access required" }); + } + + try { + const result = await pool.query( + ` + SELECT + or.option_id, + oqo.option_text, + oqo.tag_id, + t.name as tag_name, + oq.question_text, + oq.id as question_id + FROM onboarding_responses or + JOIN onboarding_question_options oqo ON oqo.id = or.option_id + JOIN onboarding_questions oq ON oq.id = oqo.question_id + LEFT JOIN tags t ON t.id = oqo.tag_id + WHERE or.user_id = $1 + ORDER BY oq.position ASC + `, + [user.userId] + ); + + const responses = result.rows.map((row) => ({ + option_id: row.option_id, + option_text: row.option_text, + tag_id: row.tag_id, + tag_name: row.tag_name, + question_text: row.question_text, + question_id: row.question_id, + })); + + res.json({ responses }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; diff --git a/routes/orgs.js b/routes/orgs.js index fe383d5..1ec4bbb 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -14,7 +14,6 @@ function setAuthCookie(res, payload) { }); } -// Create a new organization AND make the current user its admin router.post("/", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -115,6 +114,16 @@ router.post("/addemployee", async (req, res) => { ); await client.query("COMMIT"); + + setAuthCookie(res, { + ...session, + organisation: { + id: org.id, + organisationname: org.organisation_name, + role: "employee", + }, + }); + return res.status(201).json({ organisation: { ...org, role: "employee" } }); } catch (err) { await client.query("ROLLBACK"); diff --git a/server.js b/server.js index 0a736ba..83b5518 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const orgRoutes = require("./routes/orgs"); const courseRoutes = require("./routes/courses"); const userRoutes = require("./routes/users"); const reportsRoutes = require("./routes/reports"); +const onboardingRoutes = require("./routes/onboarding"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -19,7 +20,6 @@ app.use("/uploads", express.static(uploadsDir)); app.use(express.json()); app.use(cookieParser()); -// optional CORS settings if Next.js runs on a different origin app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "http://localhost:3000"); res.header("Access-Control-Allow-Credentials", "true"); @@ -27,7 +27,6 @@ app.use((req, res, next) => { next(); }); -// your existing endpoints app.get("/", (req, res) => res.send("Welcome to PostgreSQL with Node.js and Express!") ); @@ -41,12 +40,12 @@ app.get("/checkconnection", async (req, res) => { } }); -// mount all auth routes under /api app.use("/api", authRoutes); app.use("/api/orgs", orgRoutes); app.use("/api/courses", courseRoutes); app.use("/api/users", userRoutes); app.use("/api/reports", reportsRoutes); +app.use("/api/onboarding", onboardingRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);