diff --git a/.gitignore b/.gitignore index f189a18..861f84d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules -/.env \ No newline at end of file +/.env +/uploads \ No newline at end of file diff --git a/README.md b/README.md index b6925d6..ff44a2e 100644 --- a/README.md +++ b/README.md @@ -25,5 +25,5 @@ This is the backend for skillstack platform. 5. Start the server ```bash - node server.js + npm run server ``` diff --git a/database/schema.sql b/database/schema.sql index a59f0e3..665ae32 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -19,6 +19,7 @@ CREATE TABLE organisations ( REFERENCES users(id) ON DELETE NO ACTION, description TEXT NOT NULL DEFAULT '', ai_enabled BOOLEAN NOT NULL DEFAULT FALSE, + current_invitation_id TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(organisation_name, admin_user_id) ); @@ -40,11 +41,12 @@ CREATE TABLE courses ( id SERIAL PRIMARY KEY, organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, description TEXT, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(organisation_id, name) ); CREATE TABLE enrollments ( @@ -68,7 +70,8 @@ CREATE TABLE modules ( title VARCHAR(255) NOT NULL, module_type VARCHAR(50) NOT NULL, -- e.g. 'video', 'quiz', 'pdf' description TEXT, - position INTEGER NOT NULL DEFAULT 0 + position INTEGER NOT NULL DEFAULT 0, + file_url TEXT NOT NULL ); CREATE TABLE revisions ( diff --git a/package-lock.json b/package-lock.json index 798a5cd..b3c8582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,11 @@ "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", "express": "^5.1.0", + "multer": "^2.0.0", "pg": "^8.16.0" }, "devDependencies": { + "@types/multer": "^1.4.12", "jest": "^29.7.0" } }, @@ -963,6 +965,52 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.2.tgz", + "integrity": "sha512-BtjL3ZwbCQriyb0DGw+Rt12qAXPiBTPs815lsUvtt1Grk0vLRMZNMUZ741d5rjk+UQOxfDiBZ3dxpX00vSkK3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -973,6 +1021,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1000,6 +1055,23 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.15.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", @@ -1010,6 +1082,43 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1103,6 +1212,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1341,9 +1456,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1533,6 +1658,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -1598,6 +1738,12 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2404,6 +2550,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3329,12 +3481,94 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "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/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-bS8rPZurbAuHGAnApbM9d4h1wSoYqrOqkE+6a64KLMK9yWU7gJXBDDVklKQ3TPi9DRb85cRs6yXaC0+cjxRtRg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3408,6 +3642,15 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3785,6 +4028,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3875,6 +4124,27 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4212,6 +4482,29 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -4394,6 +4687,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4441,6 +4740,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 7bda0cb..6018c52 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "jest" }, "devDependencies": { + "@types/multer": "^1.4.12", "jest": "^29.7.0" }, "dependencies": { @@ -27,6 +28,7 @@ "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", "express": "^5.1.0", + "multer": "^2.0.0", "pg": "^8.16.0" } } diff --git a/routes/auth.js b/routes/auth.js index 51f5232..3615166 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -64,7 +64,7 @@ router.post("/login", async (req, res) => { const mem = await pool.query( `SELECT o.id AS id, - o.organisation_name AS organisationName, + o.organisation_name AS organisationname, ou.role AS role FROM organisation_users ou JOIN organisations o @@ -126,7 +126,7 @@ router.post("/complete-onboarding", async (req, res) => { const mem = await pool.query( `SELECT o.id AS id, - o.organisation_name AS organisationName, + o.organisation_name AS organisationname, ou.role AS role FROM organisation_users ou JOIN organisations o diff --git a/routes/courses.js b/routes/courses.js new file mode 100644 index 0000000..71ba645 --- /dev/null +++ b/routes/courses.js @@ -0,0 +1,491 @@ +// routes/courses.js +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); +const multer = require("multer"); +const path = require("path"); + +const storage = multer.diskStorage({ + destination: "uploads/", + filename: (req, file, cb) => + cb(null, `${Date.now()}${path.extname(file.originalname)}`), +}); +const upload = multer({ storage }); + +router.post("/", 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 userId = session.userId; + const organisationId = session.organisation?.id; + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + const courseName = req.body.courseName; + const courseDescription = req.body.description || ""; + if (!courseName) { + return res.status(400).json({ message: "courseName is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseRes = await client.query( + `INSERT INTO courses (organisation_id, name, description, created_by ) + VALUES ($1, $2, $3, $4) + RETURNING id, name, created_at`, + [organisationId, courseName, courseDescription, userId] + ); + + if (!courseRes.rows.length) { + throw new Error("Failed to create course"); + } + + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + if (err.code === "23505") { + return res.status(400).json({ message: "Course name already taken" }); + } + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/", 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; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseRes = await client.query( + `SELECT c.id, c.name, c.description FROM courses c + WHERE c.organisation_id = $1`, + [organisationId] + ); + + await client.query("COMMIT"); + return res.status(201).json({ courses: courseRes.rows }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/get-course", 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 courseId = req.body.courseId; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseRes = await client.query( + `SELECT c.id, c.name, c.description FROM courses c + WHERE c.id = $1`, + [courseId] + ); + + if (!courseRes.rows.length) { + console.error("Course not found for ID:", courseId); + return res.status(404).json({ message: "Course not found" }); + } + + await client.query("COMMIT"); + return res.status(200).json({ + id: courseRes.rows[0].id, + name: courseRes.rows[0].name, + description: courseRes.rows[0].description, + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/", 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 organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + const courseId = req.body.courseId; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const _ = await client.query( + `DELETE FROM courses c + WHERE c.id = $1`, + [courseId] + ); + + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.put("/", 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 organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const courseId = req.body.courseId; + const courseName = req.body.courseName; + const courseDescription = req.body.description || ""; + if (!courseId || !courseName) { + return res + .status(400) + .json({ message: "courseId and courseName are required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseRes = await client.query( + `UPDATE courses + SET name = $1, + description = $2 + WHERE id = $3 + RETURNING id, name, description`, + [courseName, courseDescription, courseId] + ); + + if (!courseRes.rows.length) { + throw new Error("Failed to update course"); + } + + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + if (err.code === "23505") { + return res.status(400).json({ message: "Course name already taken" }); + } + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// MODULES ENDPOINTS + +router.post("/get-modules", 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 courseId = req.body.courseId; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const modulesRes = await client.query( + `SELECT id, title, module_type, position FROM modules WHERE course_id = $1`, + [courseId] + ); + + await client.query("COMMIT"); + return res.status(201).json({ modules: modulesRes.rows || [] }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/add-module", upload.single("file"), 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" }); + } + if (session.organisation?.role !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const { courseId, name, type, description = "" } = req.body; + const file = req.file; + if (!courseId || !name || !type || !file) { + return res + .status(400) + .json({ message: "courseId, title, moduleType & file required" }); + } + const fileUrl = `/uploads/${req.file.filename}`; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const posRes = await client.query( + `SELECT COALESCE(MAX(position), 0) AS max_pos + FROM modules + WHERE course_id = $1`, + [courseId] + ); + const nextPosition = posRes.rows[0].max_pos + 1; + + const moduleRes = await client.query( + `INSERT INTO modules (course_id, title, module_type, description, position, file_url) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, title, module_type, position, file_url`, + [courseId, name, type, description, nextPosition, fileUrl] + ); + + if (!moduleRes.rows.length) { + throw new Error("Failed to create module"); + } + + const module_id = moduleRes.rows[0].id; + + await client.query("COMMIT"); + return res.status(201).json({ + module_id, + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/delete-module", 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" }); + } + + if (session.organisation?.role !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const { moduleId } = req.body; + if (!moduleId) { + return res.status(400).json({ message: "moduleId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const _ = await client.query(`DELETE FROM modules WHERE id = $1`, [ + moduleId, + ]); + + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/get-module", 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 moduleId = req.body.moduleId; + if (!moduleId) { + return res.status(400).json({ message: "moduleId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const moduleRes = await client.query( + `SELECT id, title, module_type, description, file_url + FROM modules WHERE id = $1`, + [moduleId] + ); + + if (!moduleRes.rows.length) { + console.error("Module not found for ID:", moduleId); + return res.status(404).json({ message: "Module not found" }); + } + + await client.query("COMMIT"); + return res.status(200).json(moduleRes.rows[0]); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.put("/update-module", upload.single("file"), 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" }); + } + if (session.organisation?.role !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const { moduleId, name, description = "" } = req.body; + if (!moduleId || !name) { + return res + .status(400) + .json({ message: "moduleId, title, description required" }); + } + const file = req.file; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const fileUrl = file ? `/uploads/${req.file.filename}` : null; + let moduleRes; + if (fileUrl) { + const { type } = req.body; + if (!type) { + return res.status(400).json({ message: "moduleType is required" }); + } + moduleRes = await client.query( + `UPDATE modules + SET title = $1, + description = $2, + module_type = $3, + file_url = $4 + WHERE id = $5 + RETURNING id, title, module_type, position, file_url`, + [name, description, type, fileUrl, moduleId] + ); + } else { + moduleRes = await client.query( + `UPDATE modules + SET title = $1, + description = $2 + WHERE id = $3 + RETURNING id, title, module_type, position, file_url`, + [name, description, moduleId] + ); + } + + if (!moduleRes.rows.length) { + throw new Error("Failed to update module"); + } + + const module_id = moduleRes.rows[0].id; + + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +module.exports = router; diff --git a/routes/orgs.js b/routes/orgs.js index 592d17f..fe383d5 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -2,6 +2,17 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const crypto = require("crypto"); + +function setAuthCookie(res, payload) { + res.cookie("auth", JSON.stringify(payload), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + path: "/", + }); +} // Create a new organization AND make the current user its admin router.post("/", async (req, res) => { @@ -74,8 +85,8 @@ router.post("/addemployee", async (req, res) => { } const userId = session.userId; - const { organisationId } = req.body; - if (!organisationId) { + const { inviteCode } = req.body; + if (!inviteCode) { return res .status(400) .json({ message: "organisation invite code is required" }); @@ -87,13 +98,14 @@ router.post("/addemployee", async (req, res) => { // 1) See if org exists const orgRes = await client.query( - `SELECT id, organisation_name FROM organisations WHERE id = $1`, - [organisationId] + `SELECT id, organisation_name FROM organisations WHERE current_invitation_id = $1`, + [inviteCode] ); if (!orgRes.rows.length) { return res.status(400).json({ message: "Organization not found" }); } const org = orgRes.rows[0]; + const organisationId = org.id; // 2) Link user → new org as employee await client.query( @@ -129,7 +141,7 @@ router.get("/my", async (req, res) => { const result = await pool.query( `SELECT o.id, - o.organisation_name AS organisationName, + o.organisation_name AS organisationname, ou.role FROM organisation_users ou JOIN organisations o @@ -151,4 +163,204 @@ router.get("/my", async (req, res) => { } }); +router.get("/settings", 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" }); + } + + try { + const result = await pool.query( + `SELECT + o.id, + o.organisation_name, + o.ai_enabled, + o.description + FROM organisations o + where o.admin_user_id = $1`, + [session.userId] + ); + + if (!result.rows.length) { + return res.status(400).json({ message: "Organization not found" }); + } + + // exactly one row guaranteed by PK on user_id + return res.json({ organisation: result.rows[0] }); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/settings", 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 userId = session.userId; + const { organisation_id, organisation_name, ai_enabled, description } = + req.body; + + if (!organisation_id || !organisation_name) { + return res + .status(400) + .json({ message: "organisation_id and organisation_name are required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const updateRes = await client.query( + ` + UPDATE organisations + SET organisation_name = $1, + ai_enabled = $2, + description = $3 + WHERE id = $4 + AND admin_user_id = $5 + RETURNING id, organisation_name, ai_enabled, description + `, + [organisation_name, ai_enabled, description, organisation_id, userId] + ); + + if (!updateRes.rows.length) { + await client.query("ROLLBACK"); + return res + .status(403) + .json({ message: "Not authorized to update this organization" }); + } + + await client.query("COMMIT"); + + const mem = await pool.query( + `SELECT + o.id AS id, + o.organisation_name AS organisationname, + ou.role AS role + FROM organisation_users ou + JOIN organisations o + ON o.id = ou.organisation_id + WHERE ou.user_id = $1`, + [userId] + ); + + const neworganisation = mem.rows[0] || null; + + // Regenerate auth cookie + setAuthCookie(res, { + ...session, + organisation: neworganisation, + }); + + return res.json({ organisation: updateRes.rows[0] }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/generate-invite-code", 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 userId = session.userId; + const organisationId = session.organisation?.id; + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const inviteCode = organisationId + crypto.randomBytes(16).toString("hex"); + + const updateRes = await client.query( + `UPDATE organisations + SET current_invitation_id = $1 + WHERE id = $2 + AND admin_user_id = $3 + RETURNING current_invitation_id`, + [inviteCode, organisationId, userId] + ); + + if (!updateRes.rows.length) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ message: "Organization not found or not owned by you" }); + } + + await client.query("COMMIT"); + return res.json({ inviteCode: updateRes.rows[0].current_invitation_id }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error generating invite code:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/get-curent-invitecode", 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 userId = session.userId; + const organisationId = session.organisation?.id; + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + try { + const result = await pool.query( + `SELECT current_invitation_id + FROM organisations + WHERE id = $1 AND admin_user_id = $2`, + [organisationId, userId] + ); + + if (!result.rows.length) { + return res.status(404).json({ message: "Invitation code not found" }); + } + + return res.json({ inviteCode: result.rows[0].current_invitation_id }); + } catch (err) { + console.error("Error fetching invite code:", err); + return res.status(500).json({ message: "Server error" }); + } +}); + module.exports = router; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..d2d4665 --- /dev/null +++ b/routes/users.js @@ -0,0 +1,87 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +router.get("/", 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; + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const client = await pool.connect(); + try { + const users = await client.query( + `SELECT u.id, u.firstname, u.lastname, u.email, ou.role + FROM users u + JOIN organisation_users ou ON u.id = ou.user_id + WHERE ou.organisation_id = $1`, + [organisationId] + ); + await client.query("COMMIT"); + return res.status(201).json(users.rows); + } catch (error) { + await client.query("ROLLBACK"); + console.error(error.message); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/", 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 organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const { userId: deleteUserId } = req.body; + if (!deleteUserId) { + return res.status(400).json({ message: "Missing user ID to delete" }); + } + + const client = await pool.connect(); + try { + const delRes = await client.query( + `DELETE FROM users + WHERE id = $1 + RETURNING id`, + [deleteUserId] + ); + if (!delRes.rows.length) { + return res.status(404).json({ message: "User not found" }); + } + await client.query("COMMIT"); + return res.status(201).json({ + message: "User deleted successfully", + }); + } catch (error) { + await client.query("ROLLBACK"); + console.error(error.message); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index a89d58b..87d1d7f 100644 --- a/server.js +++ b/server.js @@ -2,9 +2,18 @@ const express = require("express"); const cookieParser = require("cookie-parser"); const authRoutes = require("./routes/auth"); const orgRoutes = require("./routes/orgs"); +const courseRoutes = require("./routes/courses"); +const userRoutes = require("./routes/users"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; +const path = require("path"); +const fs = require("fs"); +const uploadsDir = path.join(__dirname, "uploads"); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); +} +app.use("/uploads", express.static(uploadsDir)); app.use(express.json()); app.use(cookieParser()); @@ -34,6 +43,8 @@ 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.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);