From 489df3fa145a9398d5280b1349357664e33dde41 Mon Sep 17 00:00:00 2001 From: aboldguess Date: Thu, 24 Jul 2025 22:23:17 +0100 Subject: [PATCH 1/2] Add social feature with posts and following --- backend/src/index.ts | 2 + backend/src/models/follow.ts | 19 ++++++++ backend/src/models/post.ts | 19 ++++++++ backend/src/routes/social.ts | 73 ++++++++++++++++++++++++++++++ frontend/admin.html | 1 + frontend/css/style.css | 31 +++++++++++++ frontend/dashboard.html | 1 + frontend/index.html | 1 + frontend/js/social.js | 87 ++++++++++++++++++++++++++++++++++++ frontend/login.html | 1 + frontend/pricing.html | 1 + frontend/signup.html | 1 + frontend/social.html | 38 ++++++++++++++++ 13 files changed, 275 insertions(+) create mode 100644 backend/src/models/follow.ts create mode 100644 backend/src/models/post.ts create mode 100644 backend/src/routes/social.ts create mode 100644 frontend/js/social.js create mode 100644 frontend/social.html diff --git a/backend/src/index.ts b/backend/src/index.ts index 2529403..bdd81d4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,7 @@ import leaveRoutes from './routes/leaves'; import userRoutes from './routes/users'; import teamRoutes from './routes/teams'; import adminRoutes from './routes/admin'; +import socialRoutes from './routes/social'; import { connectDB } from './db'; import { Message } from './models/message'; import { DirectMessage } from './models/directMessage'; @@ -149,6 +150,7 @@ app.use('/api/leaves', leaveRoutes); app.use('/api/users', userRoutes); app.use('/api/teams', teamRoutes); app.use('/api/admin', adminRoutes); +app.use('/api/social', socialRoutes); // Serve static frontend files. The path is resolved relative to the compiled // JavaScript location so it works when running from the 'dist' directory. diff --git a/backend/src/models/follow.ts b/backend/src/models/follow.ts new file mode 100644 index 0000000..2fb312e --- /dev/null +++ b/backend/src/models/follow.ts @@ -0,0 +1,19 @@ +import { Schema, model, Document } from 'mongoose'; + +/** + * Relationship indicating that `follower` subscribes to `following`. + */ +export interface IFollow extends Document { + follower: string; + following: string; +} + +const FollowSchema = new Schema({ + follower: { type: String, required: true }, + following: { type: String, required: true } +}); + +// Prevent duplicate follow records per pair of users +FollowSchema.index({ follower: 1, following: 1 }, { unique: true }); + +export const Follow = model('Follow', FollowSchema); diff --git a/backend/src/models/post.ts b/backend/src/models/post.ts new file mode 100644 index 0000000..540d450 --- /dev/null +++ b/backend/src/models/post.ts @@ -0,0 +1,19 @@ +import { Schema, model, Document } from 'mongoose'; + +/** + * Simple social post authored by a user. + */ +export interface IPost extends Document { + author: string; + content: string; + createdAt: Date; +} + +const PostSchema = new Schema({ + author: { type: String, required: true }, + content: { type: String, required: true }, + // Default timestamp so posts are ordered chronologically + createdAt: { type: Date, default: Date.now } +}); + +export const Post = model('Post', PostSchema); diff --git a/backend/src/routes/social.ts b/backend/src/routes/social.ts new file mode 100644 index 0000000..d8486a2 --- /dev/null +++ b/backend/src/routes/social.ts @@ -0,0 +1,73 @@ +import { Router } from 'express'; +import { authMiddleware, AuthRequest } from '../middleware/authMiddleware'; +import { Post } from '../models/post'; +import { Follow } from '../models/follow'; +import { User } from '../models/user'; + +const router = Router(); + +// Require authentication for all social routes +router.use(authMiddleware); + +/** + * List recent posts. Currently returns all posts regardless of follow + * relationships for simplicity. + */ +router.get('/posts', async (_req, res) => { + const posts = await Post.find().sort({ createdAt: -1 }).exec(); + res.json(posts); +}); + +/** + * Create a new post authored by the logged in user. + */ +router.post('/posts', async (req: AuthRequest, res) => { + const content = req.body.content; + if (!content) { + return res.status(400).json({ message: 'Content required' }); + } + const post = new Post({ author: req.user!.username, content }); + await post.save(); + res.status(201).json(post); +}); + +/** + * Get the list of usernames the current user is following. + */ +router.get('/follows', async (req: AuthRequest, res) => { + const records = await Follow.find({ follower: req.user!.username }).exec(); + res.json(records.map(r => r.following)); +}); + +/** + * Follow another user by username. + */ +router.post('/follow/:user', async (req: AuthRequest, res) => { + const target = req.params.user; + if (target === req.user!.username) { + return res.status(400).json({ message: 'Cannot follow yourself' }); + } + const exists = await User.findOne({ username: target }).exec(); + if (!exists) { + return res.status(404).json({ message: 'User not found' }); + } + await Follow.updateOne( + { follower: req.user!.username, following: target }, + {}, + { upsert: true } + ); + res.json({ message: 'Followed' }); +}); + +/** + * Unfollow a previously followed user. + */ +router.delete('/follow/:user', async (req: AuthRequest, res) => { + await Follow.deleteOne({ + follower: req.user!.username, + following: req.params.user + }).exec(); + res.json({ message: 'Unfollowed' }); +}); + +export default router; diff --git a/frontend/admin.html b/frontend/admin.html index 47e3b7b..3c7e29d 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -12,6 +12,7 @@ Dash Pricing Dashboard + Social Logout diff --git a/frontend/css/style.css b/frontend/css/style.css index b5152d1..b6f484e 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -215,3 +215,34 @@ button { text-align: left; } + +/* Layout for the social page with a sidebar of people */ +.social-layout { + display: flex; + height: calc(100vh - 60px); +} + +.social-sidebar { + width: 200px; + background: #f4f4f4; + padding: 1rem; + box-sizing: border-box; + border-right: 1px solid #ddd; +} + +.social-sidebar ul { + list-style: none; + padding: 0; + margin: 0; +} + +.post { + border-bottom: 1px solid #ccc; + padding: 0.5rem 0; +} + +.post .time { + color: #777; + font-size: 0.8rem; + margin-left: 0.5rem; +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 0a5967f..7ad798d 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -12,6 +12,7 @@ Dash Pricing + Social Logout diff --git a/frontend/index.html b/frontend/index.html index 368f2a7..add10e0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,6 +12,7 @@ diff --git a/frontend/js/social.js b/frontend/js/social.js new file mode 100644 index 0000000..a3c0627 --- /dev/null +++ b/frontend/js/social.js @@ -0,0 +1,87 @@ +/** + * Minimal social features allowing users to follow each other and + * publish short text updates. This script relies on helpers in app.js + * for authentication and API base URL configuration. + */ + +let following = []; // usernames the current user follows + +function initSocial() { + // Ensure the user is logged in and populate the admin link + checkAuth(); + loadSocialUsers(); + loadPosts(); +} + +/** Fetch all users and the current follow list to render the sidebar. */ +function loadSocialUsers() { + Promise.all([ + fetch(`${API_BASE_URL}/api/users`, { + headers: { Authorization: `Bearer ${currentUser.token}` } + }).then(r => r.json()), + fetch(`${API_BASE_URL}/api/social/follows`, { + headers: { Authorization: `Bearer ${currentUser.token}` } + }).then(r => r.json()) + ]).then(([users, follows]) => { + following = follows; + const list = document.getElementById('followList'); + list.innerHTML = ''; + users.forEach(u => { + if (u.username === currentUser.username) return; // skip self + const li = document.createElement('li'); + const btn = document.createElement('button'); + const isFollowing = following.includes(u.username); + btn.textContent = isFollowing ? 'Unfollow' : 'Follow'; + btn.onclick = () => toggleFollow(u.username, isFollowing); + li.textContent = u.username + ' '; + li.appendChild(btn); + list.appendChild(li); + }); + }); +} + +/** Follow or unfollow the given user then refresh the sidebar. */ +function toggleFollow(user, currentlyFollowing) { + const method = currentlyFollowing ? 'DELETE' : 'POST'; + fetch(`${API_BASE_URL}/api/social/follow/${user}`, { + method, + headers: { Authorization: `Bearer ${currentUser.token}` } + }).then(() => loadSocialUsers()); +} + +/** Retrieve all posts and display them newest first. */ +function loadPosts() { + fetch(`${API_BASE_URL}/api/social/posts`, { + headers: { Authorization: `Bearer ${currentUser.token}` } + }) + .then(r => r.json()) + .then(posts => { + const feed = document.getElementById('postFeed'); + feed.innerHTML = ''; + posts.forEach(p => { + const div = document.createElement('div'); + div.className = 'post'; + const time = new Date(p.createdAt).toLocaleString(); + div.innerHTML = `${p.author} ${time}

${p.content}

`; + feed.appendChild(div); + }); + }); +} + +/** Submit a new post then reload the feed. */ +function createPost(e) { + e.preventDefault(); + const content = document.getElementById('postContent').value; + if (!content) return; + fetch(`${API_BASE_URL}/api/social/posts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${currentUser.token}` + }, + body: JSON.stringify({ content }) + }).then(() => { + document.getElementById('postContent').value = ''; + loadPosts(); + }); +} diff --git a/frontend/login.html b/frontend/login.html index 3e5ceb9..259a0f0 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -11,6 +11,7 @@ diff --git a/frontend/pricing.html b/frontend/pricing.html index da822e0..082cd4b 100644 --- a/frontend/pricing.html +++ b/frontend/pricing.html @@ -12,6 +12,7 @@ Dash Login Sign Up + Social diff --git a/frontend/signup.html b/frontend/signup.html index 6b9efbb..4bcbd46 100644 --- a/frontend/signup.html +++ b/frontend/signup.html @@ -11,6 +11,7 @@ diff --git a/frontend/social.html b/frontend/social.html new file mode 100644 index 0000000..897749e --- /dev/null +++ b/frontend/social.html @@ -0,0 +1,38 @@ + + + + + + Social - Dash + + + +
+ +
+ + + + + + + From 99efd918de7265c5bbd8f5553447c0e82c7cb3f1 Mon Sep 17 00:00:00 2001 From: aboldguess Date: Thu, 24 Jul 2025 22:42:20 +0100 Subject: [PATCH 2/2] Merge main and restore profile features --- backend/jest.config.js | 3 +- backend/package-lock.json | 211 ++++++++++++++++++++++- backend/package.json | 6 +- backend/src/index.ts | 6 + backend/src/middleware/authMiddleware.ts | 3 + backend/src/models/profile.ts | 24 +++ backend/src/routes/profile.ts | 55 ++++++ backend/test/profile.test.ts | 62 +++++++ backend/uploads/.gitkeep | 0 frontend/admin.html | 1 + frontend/css/style.css | 72 ++++++-- frontend/dashboard.html | 1 + frontend/js/profile.js | 54 ++++++ frontend/profile.html | 36 ++++ 14 files changed, 513 insertions(+), 21 deletions(-) create mode 100644 backend/src/models/profile.ts create mode 100644 backend/src/routes/profile.ts create mode 100644 backend/test/profile.test.ts create mode 100644 backend/uploads/.gitkeep create mode 100644 frontend/js/profile.js create mode 100644 frontend/profile.html diff --git a/backend/jest.config.js b/backend/jest.config.js index c53379e..8875899 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,5 +1,6 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['**/test/**/*.test.ts'] + testMatch: ['**/test/**/*.test.ts'], + maxWorkers: 1 }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 220c042..ae95dde 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.16.4", + "multer": "^1.4.5-lts.1", "open": "^8.4.2", "socket.io": "^4.7.2" }, @@ -27,6 +28,7 @@ "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/mongoose": "^5.11.96", + "@types/multer": "^1.4.7", "@types/node": "^24.0.12", "@types/supertest": "^6.0.3", "jest": "^30.0.5", @@ -1449,6 +1451,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.0.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", @@ -1942,6 +1954,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/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2275,9 +2293,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", @@ -2608,6 +2636,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", @@ -2661,6 +2704,12 @@ "dev": true, "license": "MIT" }, + "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/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3930,6 +3979,12 @@ "node": ">=8" } }, + "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", @@ -5032,6 +5087,15 @@ "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/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -5042,6 +5106,18 @@ "node": ">=16 || 14 >=14.17" } }, + "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/mongodb": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", @@ -5199,6 +5275,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "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": ">= 6.0.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/napi-postinstall": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", @@ -5626,6 +5764,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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5718,6 +5862,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/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6192,6 +6357,14 @@ "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/streamx": { "version": "2.22.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", @@ -6206,6 +6379,21 @@ "bare-events": "^2.2.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", @@ -6712,6 +6900,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/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -6814,6 +7008,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-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -7040,6 +7240,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index 7dd9e57..aa72fcb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,8 @@ "open": "^8.4.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.16.4", - "socket.io": "^4.7.2" + "socket.io": "^4.7.2", + "multer": "^1.4.5-lts.1" }, "devDependencies": { "@types/body-parser": "^1.19.6", @@ -41,7 +42,8 @@ "supertest": "^7.1.4", "ts-jest": "^29.4.0", "ts-node": "^10.9.2", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "@types/multer": "^1.4.7" }, "overrides": { "test-exclude": "^7.0.1" diff --git a/backend/src/index.ts b/backend/src/index.ts index bdd81d4..10c6553 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -19,6 +19,7 @@ import userRoutes from './routes/users'; import teamRoutes from './routes/teams'; import adminRoutes from './routes/admin'; import socialRoutes from './routes/social'; +import profileRoutes from './routes/profile'; import { connectDB } from './db'; import { Message } from './models/message'; import { DirectMessage } from './models/directMessage'; @@ -151,6 +152,11 @@ app.use('/api/users', userRoutes); app.use('/api/teams', teamRoutes); app.use('/api/admin', adminRoutes); app.use('/api/social', socialRoutes); +app.use('/api/profile', profileRoutes); + +// Expose uploaded profile photos as static files +const uploadsDir = path.resolve(__dirname, '..', '..', 'uploads'); +app.use('/uploads', express.static(uploadsDir)); // Serve static frontend files. The path is resolved relative to the compiled // JavaScript location so it works when running from the 'dist' directory. diff --git a/backend/src/middleware/authMiddleware.ts b/backend/src/middleware/authMiddleware.ts index 4755b09..802c5bb 100644 --- a/backend/src/middleware/authMiddleware.ts +++ b/backend/src/middleware/authMiddleware.ts @@ -1,9 +1,12 @@ import { Request, Response, NextFunction } from 'express'; +// Import multer types so Express.Multer.File is available +import 'multer'; import jwt from 'jsonwebtoken'; import { Role } from '../models/user'; export interface AuthRequest extends Request { user?: { id: string; username: string; role: Role; team?: string }; + file?: Express.Multer.File; // populated by multer when handling uploads } const JWT_SECRET = process.env.JWT_SECRET || 'secret'; diff --git a/backend/src/models/profile.ts b/backend/src/models/profile.ts new file mode 100644 index 0000000..1a24688 --- /dev/null +++ b/backend/src/models/profile.ts @@ -0,0 +1,24 @@ +import { Schema, model, Document, Types } from 'mongoose'; + +/** + * Profile information for a user. Stored separately from the core user + * document so additional details can be added without altering the + * authentication model. Each user has at most one profile. + */ +export interface IProfile extends Document { + user: Types.ObjectId; + photo?: string; // URL path to the uploaded profile image + career?: string; // brief career history + education?: string; // education summary + statement?: string; // personal statement +} + +const ProfileSchema = new Schema({ + user: { type: Schema.Types.ObjectId, ref: 'User', unique: true, required: true }, + photo: String, + career: String, + education: String, + statement: String +}); + +export const Profile = model('Profile', ProfileSchema); diff --git a/backend/src/routes/profile.ts b/backend/src/routes/profile.ts new file mode 100644 index 0000000..b8e5a15 --- /dev/null +++ b/backend/src/routes/profile.ts @@ -0,0 +1,55 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import { authMiddleware, AuthRequest } from '../middleware/authMiddleware'; +import { Profile } from '../models/profile'; + +const router = Router(); + +// Directory used for uploaded profile photos. Resolve relative to the project +// root so uploads end up in `/backend/uploads` regardless of whether +// the TypeScript source or compiled JavaScript is executed. +const uploadDir = path.resolve(__dirname, '..', '..', 'uploads'); +const upload = multer({ dest: uploadDir }); + +// All profile endpoints require authentication +router.use(authMiddleware); + +/** Fetch the logged in user's profile data. */ +router.get('/me', async (req: AuthRequest, res) => { + const profile = await Profile.findOne({ user: req.user!.id }).exec(); + res.json(profile); +}); + +/** + * Update career history, education and personal statement for the current user. + * If a profile document does not yet exist it will be created automatically. + */ +router.post('/me', async (req: AuthRequest, res) => { + const { career, education, statement } = req.body; + const profile = await Profile.findOneAndUpdate( + { user: req.user!.id }, + { career, education, statement }, + { new: true, upsert: true } + ).exec(); + res.json(profile); +}); + +/** + * Upload a new profile photo. The uploaded file is stored on disk and the + * relative URL is saved in the profile document for easy retrieval. + */ +router.post('/me/photo', upload.single('photo'), async (req: AuthRequest, res) => { + if (!req.file) { + return res.status(400).json({ message: 'File missing' }); + } + const photoPath = `/uploads/${req.file.filename}`; + const profile = await Profile.findOneAndUpdate( + { user: req.user!.id }, + { photo: photoPath }, + { new: true, upsert: true } + ).exec(); + res.json(profile); +}); + +export default router; diff --git a/backend/test/profile.test.ts b/backend/test/profile.test.ts new file mode 100644 index 0000000..548335c --- /dev/null +++ b/backend/test/profile.test.ts @@ -0,0 +1,62 @@ +import request from 'supertest'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import mongoose from 'mongoose'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +jest.setTimeout(20000); +import { app } from '../src/index'; +import { connectDB } from '../src/db'; +import { User } from '../src/models/user'; +import fs from 'fs'; +import path from 'path'; + +let mongo: MongoMemoryServer; +let userToken: string; + +beforeAll(async () => { + mongo = await MongoMemoryServer.create(); + process.env.DB_URI = mongo.getUri(); + await connectDB(); + + const hashed = await bcrypt.hash('secret', 10); + const user = new User({ username: 'user@test.com', password: hashed, role: 'user' }); + await user.save(); + userToken = jwt.sign( + { id: user.id, username: user.username, role: user.role }, + 'secret', + { expiresIn: '1h' } + ); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongo.stop(); +}); + +/** Verify profile can be created and photo uploaded */ +test('profile lifecycle', async () => { + const update = await request(app) + .post('/api/profile/me') + .set('Authorization', `Bearer ${userToken}`) + .send({ career: 'Developer', education: 'CS Degree', statement: 'Hello' }); + expect(update.status).toBe(200); + expect(update.body.career).toBe('Developer'); + + // create a temporary file to upload + const tmp = path.join(__dirname, 'temp.txt'); + fs.writeFileSync(tmp, 'data'); + const photoRes = await request(app) + .post('/api/profile/me/photo') + .set('Authorization', `Bearer ${userToken}`) + .attach('photo', tmp); + expect(photoRes.status).toBe(200); + expect(photoRes.body.photo).toContain('/uploads/'); + fs.unlinkSync(tmp); + + const get = await request(app) + .get('/api/profile/me') + .set('Authorization', `Bearer ${userToken}`); + expect(get.body.education).toBe('CS Degree'); + expect(get.body.photo).toEqual(photoRes.body.photo); +}); diff --git a/backend/uploads/.gitkeep b/backend/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/admin.html b/frontend/admin.html index 3c7e29d..1ed06a5 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -12,6 +12,7 @@ Dash Pricing Dashboard + Profile Social Logout diff --git a/frontend/css/style.css b/frontend/css/style.css index b6f484e..455d917 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1,20 +1,43 @@ -/* Global layout styles */ +/*-------------------------------------------------------------- + Global palette and base typography + Using CSS custom properties makes it easy to apply a consistent + look across all pages while allowing individual sections to use + different accents from the same palette. +--------------------------------------------------------------*/ +:root { + --color-primary: #1e3a8a; /* brand blue */ + --color-secondary: #0e7490; /* teal accent */ + --color-accent: #f59e0b; /* orange highlight */ + --color-light: #ffffff; + --color-background: #f7fafc; + --color-text: #374151; + --color-muted: #94a3b8; +} + body { margin: 0; - font-family: sans-serif; + color: var(--color-text); + background: var(--color-background); + font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.5; } /* Simple brand header used across pages */ header { - background: #004080; - color: #fff; + background: var(--color-primary); + color: var(--color-light); padding: 1rem; } header nav a { - color: #fff; + color: var(--color-light); margin-right: 1rem; text-decoration: none; + font-weight: 600; +} + +header nav a:hover { + text-decoration: underline; } /* Landing page hero section */ @@ -25,6 +48,8 @@ header nav a { justify-content: center; height: calc(100vh - 70px); text-align: center; + color: var(--color-light); + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); } .hero h1 { @@ -40,8 +65,8 @@ header nav a { /* Narrow tool selector on the far left */ .tool-sidebar { width: 60px; - background: #333; - color: #fff; + background: var(--color-primary); + color: var(--color-light); padding-top: 1rem; box-sizing: border-box; } @@ -60,7 +85,7 @@ header nav a { } .tool-sidebar li.active { - background: #555; + background: var(--color-secondary); } #gantt { @@ -70,10 +95,10 @@ header nav a { /* Contextual sidebar shown for the selected tool */ .context-sidebar { width: 200px; - background: #f4f4f4; + background: var(--color-light); padding: 1rem; box-sizing: border-box; - border-right: 1px solid #ddd; + border-right: 1px solid var(--color-muted); } .context-sidebar ul { @@ -117,17 +142,30 @@ header nav a { } } -/* Form elements */ +/* Base styles for form elements */ input { margin: 0.5rem 0; padding: 0.5rem; width: 100%; box-sizing: border-box; + border: 1px solid var(--color-muted); + border-radius: 4px; } +/* Primary action button */ button { padding: 0.5rem 1rem; margin-top: 0.5rem; + border: none; + border-radius: 4px; + background: var(--color-accent); + color: var(--color-light); + cursor: pointer; + font-weight: 600; +} + +button:hover { + background: var(--color-secondary); } /* Styles for direct messages */ @@ -157,7 +195,7 @@ button { } .message .time { - color: #777; + color: var(--color-muted); margin-left: 0.5rem; /* space after the message text */ font-size: 0.8rem; } @@ -174,11 +212,11 @@ button { height: 8px; border-radius: 50%; margin-right: 4px; - background: #ccc; + background: var(--color-muted); } .user-online .online-indicator { - background: #0a0; + background: #16a34a; /* green for online presence */ } /* Layout for the admin dashboard */ @@ -189,10 +227,10 @@ button { /* Simple sidebar menu used to switch between admin sections */ .admin-menu { width: 150px; - background: #eee; + background: var(--color-light); padding: 1rem; box-sizing: border-box; - border-right: 1px solid #ccc; + border-right: 1px solid var(--color-muted); } .admin-menu button { @@ -210,7 +248,7 @@ button { .admin-table th, .admin-table td { - border: 1px solid #ccc; + border: 1px solid var(--color-muted); padding: 0.5rem; text-align: left; } diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 7ad798d..64adbea 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -11,6 +11,7 @@