From afed2c15c5db99c86c8abfe035edfbf7bcff34a2 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 22:48:47 +0500 Subject: [PATCH 01/12] fixing frontend for integration --- frontend/src/services/api.js | 245 +++++++++-------------------- frontend/src/services/apiRoutes.js | 1 + frontend/vite.config.js | 5 + 3 files changed, 82 insertions(+), 169 deletions(-) diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index eff0074..6d9b842 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,205 +1,112 @@ -/** - * Mock API layer. - * - * Everything the UI needs from a server goes through this file, and every method is - * async and returns plain JSON, so swapping in the Express/MongoDB backend later only - * means replacing the bodies below with fetch() calls. Nothing else in the app needs to change. - * - * Storage: localStorage for records, IndexedDB (utils/blobStore) for uploaded base videos. - */ -import { buildSeedSubmissions, seedCategories, seedUsers, seedVideos, SUBMISSION_COOLDOWN_MS } from '../mock/data' -import { blobStore } from '../utils/blobStore' +import { apiRoutes } from './apiRoutes' const PREFIX = 'signpak:v1:' -const read = (key, fallback) => { + +const readLocal = (key, fallback) => { try { const raw = window.localStorage.getItem(PREFIX + key) return raw ? JSON.parse(raw) : fallback } catch { return fallback } } -const write = (key, value) => window.localStorage.setItem(PREFIX + key, JSON.stringify(value)) -const uid = (prefix) => `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` -const fail = (message) => { throw new Error(message) } -const publicUser = ({ password, passwordHash, ...user }) => { void password; void passwordHash; return user } - -async function hashPassword(password) { - const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) - return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, '0')).join('') -} -async function migrateUsers() { - const users = read('users', []) - const migrated = await Promise.all(users.map(async ({ password, ...user }) => ({ - ...user, - passwordHash: user.passwordHash || (password ? await hashPassword(password) : ''), - }))) - if (users.some((user) => user.password || !user.passwordHash)) write('users', migrated) +const writeLocal = (key, value) => window.localStorage.setItem(PREFIX + key, JSON.stringify(value)) +const asTimestamp = (value) => value ? Date.parse(value) || value : value +const normalize = (value) => { + if (!value || typeof value !== 'object') return value + const result = { ...value } + for (const key of ['createdAt', 'updatedAt', 'submittedAt', 'readAt']) { + if (result[key]) result[key] = asTimestamp(result[key]) + } + return result } -async function ensureSeed() { - if (read('seeded', false)) { await migrateUsers(); return } - const users = await Promise.all(seedUsers.map(async ({ password, ...user }) => ({ ...user, passwordHash: await hashPassword(password) }))) - write('users', users) - write('categories', seedCategories) - write('videos', seedVideos) - write('submissions', buildSeedSubmissions()) - write('messages', []) - write('seeded', true) +async function request(path, options = {}) { + const headers = new Headers(options.headers) + const isForm = options.body instanceof FormData + if (!isForm && options.body !== undefined) headers.set('Content-Type', 'application/json') + const response = await fetch(path, { ...options, headers, credentials: 'include' }) + if (response.status === 204) return undefined + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + const error = new Error(payload.error?.message || `Request failed (${response.status})`) + error.fields = payload.error?.fields || {} + error.status = response.status + throw error + } + return payload.data } -const seedReady = ensureSeed() -const signalAuthChange = () => window.dispatchEvent(new Event('signpak:auth-change')) +const json = (method, body) => ({ method, body: JSON.stringify(body) }) +const normalizeList = (items) => (items || []).map(normalize) -// Uploaded videos live in IndexedDB; turn them into object URLs once per page load. -const urlCache = new Map() -async function withPlayableUrl(video) { - if (!video.blobKey) return video - if (!urlCache.has(video.blobKey)) { - const blob = await blobStore.get(video.blobKey).catch(() => null) - if (!blob) return { ...video, videoUrl: '' } - urlCache.set(video.blobKey, URL.createObjectURL(blob)) - } - return { ...video, videoUrl: urlCache.get(video.blobKey) } +const notifications = { + async list(userId) { return readLocal('notifications', []).filter((item) => item.userId === userId).sort((a, b) => b.createdAt - a.createdAt) }, + async create(userId, payload) { + const item = { id: `note_${Date.now()}_${Math.random().toString(36).slice(2)}`, userId, ...payload, createdAt: Date.now(), readAt: null } + writeLocal('notifications', [...readLocal('notifications', []), item]) + return item + }, + async markRead(userId, id) { + writeLocal('notifications', readLocal('notifications', []).map((item) => item.id === id && item.userId === userId ? { ...item, readAt: Date.now() } : item)) + }, + async markAllRead(userId) { + writeLocal('notifications', readLocal('notifications', []).map((item) => item.userId === userId ? { ...item, readAt: Date.now() } : item)) + }, } export const api = { auth: { - async session() { - await seedReady - const id = read('session', null) - const user = read('users', []).find((item) => item.id === id) - if (id && !user) window.localStorage.removeItem(PREFIX + 'session') - return user ? publicUser(user) : null - }, - async register({ email, firstName, surname, password }) { - await seedReady - const users = read('users', []) - const normalized = email.trim().toLowerCase() - if (users.some((user) => user.email === normalized)) fail('An account with this email already exists. Log in instead.') - const user = { id: uid('user'), email: normalized, firstName: firstName.trim(), surname: surname.trim(), passwordHash: await hashPassword(password), role: 'user', createdAt: Date.now(), connections: {} } - write('users', [...users, user]) - write('session', user.id) - signalAuthChange() - return publicUser(user) - }, - async login(email, password) { - await seedReady - const user = read('users', []).find((item) => item.email === email.trim().toLowerCase()) - if (!user || user.passwordHash !== await hashPassword(password)) fail('That email and password do not match. Check them and try again.') - write('session', user.id) - signalAuthChange() - return publicUser(user) - }, - async logout() { - await seedReady - const id = read('session', null) - window.localStorage.removeItem(PREFIX + 'session') - if (id) write('notifications', read('notifications', []).filter((item) => item.userId !== id)) - signalAuthChange() - }, - async saveConnections(userId, connections) { - await seedReady - const users = read('users', []).map((user) => (user.id === userId ? { ...user, connections } : user)) - write('users', users) - return publicUser(users.find((user) => user.id === userId)) - }, + async session() { return normalize(await request(apiRoutes.auth.session())) }, + async register(values) { return normalize(await request(apiRoutes.auth.signup(), json('POST', values))) }, + async login(email, password) { return normalize(await request(apiRoutes.auth.login(), json('POST', { email, password }))) }, + async logout() { await request(apiRoutes.auth.logout(), { method: 'POST' }) }, + async saveConnections(_userId, connections) { return normalize(await request(apiRoutes.users.me(), json('PATCH', { connections }))) }, }, users: { - async list() { return read('users', []).map(publicUser) }, + async list() { return normalizeList(await request(apiRoutes.users.list())) }, }, categories: { - async list() { return read('categories', []) }, - async create({ label, copy, tone, archived = false, order = null }) { - const categories = read('categories', []) - const nextOrder = order ?? (Math.max(0, ...categories.map((item) => item.order || 0)) + 1) - const category = { id: uid('cat'), label: label.trim(), copy: copy.trim(), tone, archived, order: nextOrder } - write('categories', [...categories, category]) - return category - }, - async update(id, patch) { - write('categories', read('categories', []).map((item) => (item.id === id ? { ...item, ...patch } : item))) - }, - async remove(id) { - write('categories', read('categories', []).filter((item) => item.id !== id)) - write('videos', read('videos', []).map((item) => (item.categoryId === id ? { ...item, categoryId: null } : item))) - }, + async list() { return normalizeList(await request(apiRoutes.categories.list())) }, + async create(values) { return normalize(await request(apiRoutes.categories.create(), json('POST', values))) }, + async update(id, patch) { return normalize(await request(apiRoutes.categories.update(id), json('PATCH', patch))) }, + async remove(id) { await request(apiRoutes.categories.remove(id), { method: 'DELETE' }) }, }, videos: { - async list() { return Promise.all(read('videos', []).map(withPlayableUrl)) }, - /** `file` is the uploaded base video. With a real backend this becomes a multipart upload. */ - async create({ file, title, categoryId, status, durationSec, poster }) { - const videos = read('videos', []) - const id = uid('video') - const blobKey = `blob_${id}` - await blobStore.put(blobKey, file) - const order = Math.max(0, ...videos.filter((item) => item.categoryId === categoryId).map((item) => item.order || 0)) + 1 - const video = { id, title: title.trim(), status, categoryId: categoryId || null, order, durationSec, poster, blobKey, size: file.size, createdAt: Date.now() } - write('videos', [...videos, video]) - return video - }, - async update(id, patch) { - write('videos', read('videos', []).map((item) => (item.id === id ? { ...item, ...patch } : item))) - }, - async remove(id) { - const target = read('videos', []).find((item) => item.id === id) - if (target?.blobKey) await blobStore.remove(target.blobKey).catch(() => {}) - write('videos', read('videos', []).filter((item) => item.id !== id)) - }, + async list() { return normalizeList(await request(apiRoutes.videos.list())) }, + async create({ file, poster, title, categoryId, status, durationSec }) { + const body = new FormData() + body.append('video', file) + if (poster instanceof Blob) body.append('poster', poster, 'poster.webp') + body.append('title', title) + body.append('categoryId', categoryId || '') + body.append('status', status) + body.append('durationSec', String(durationSec || 0)) + return normalize(await request(apiRoutes.videos.create(), { method: 'POST', body })) + }, + async update(id, patch) { return normalize(await request(apiRoutes.videos.update(id), json('PATCH', patch))) }, + async remove(id) { await request(apiRoutes.videos.remove(id), { method: 'DELETE' }) }, }, submissions: { - async list(userId = null, includeAll = false) { - await seedReady - if (!userId && !includeAll) return [] - const submissions = read('submissions', []) - return includeAll ? submissions : submissions.filter((item) => item.userId === userId) - }, - /** The most recent submission this user has for this video, or null. Powers the cooldown countdown. */ - async lastFor(userId, videoId) { - const mine = read('submissions', []).filter((item) => item.userId === userId && item.videoId === videoId) - return mine.length ? mine.reduce((latest, item) => (item.submittedAt > latest.submittedAt ? item : latest)) : null - }, - /** - * Metadata only. With a real backend, also send the recorded Blob plus trimStart/trimEnd so the server can cut it. - * A contributor can send more than one recording for the same video over time (this is a data-collection tool, - * not a one-shot quiz), but not back to back: a fresh upload for a video they just submitted is refused until - * SUBMISSION_COOLDOWN_MS has passed, checked here as a defensive backstop even though the UI already hides the - * recorder during that window. - */ - async create({ userId, videoId, trimStart, trimEnd, mirrored, duration, size, mimeType }) { - const submissions = read('submissions', []) - const last = submissions.filter((item) => item.userId === userId && item.videoId === videoId).sort((a, b) => b.submittedAt - a.submittedAt)[0] - if (last) { - const remaining = SUBMISSION_COOLDOWN_MS - (Date.now() - last.submittedAt) - if (remaining > 0) fail(`Wait ${Math.ceil(remaining / 1000)}s before recording this video again.`) - } - const submission = { id: uid('sub'), userId, videoId, submittedAt: Date.now(), trimStart, trimEnd, mirrored, duration, size, mimeType } - write('submissions', [...submissions, submission]) - return submission + async list() { return normalizeList(await request(apiRoutes.submissions.list())) }, + async create({ recording, videoId, trimStart, trimEnd, mirrored, duration }) { + const body = new FormData() + body.append('recording', recording, 'recording.webm') + body.append('videoId', videoId) + body.append('trimStart', String(trimStart)) + body.append('trimEnd', String(trimEnd)) + body.append('mirrored', String(Boolean(mirrored))) + body.append('duration', String(duration)) + return normalize(await request(apiRoutes.submissions.create(), { method: 'POST', body })) }, }, contact: { - async send(payload) { - write('messages', [...read('messages', []), { id: uid('msg'), ...payload, sentAt: Date.now() }]) - }, + async send(payload) { return request(apiRoutes.contact.send(), json('POST', payload)) }, }, - notifications: { - async list(userId) { return read('notifications', []).filter((item) => item.userId === userId).sort((a, b) => b.createdAt - a.createdAt) }, - async create(userId, { type, title, body, href = null }) { - const notification = { id: uid('note'), userId, type, title, body, href, createdAt: Date.now(), readAt: null } - write('notifications', [...read('notifications', []), notification]) - return notification - }, - async markRead(userId, id) { - write('notifications', read('notifications', []).map((item) => (item.id === id && item.userId === userId && !item.readAt ? { ...item, readAt: Date.now() } : item))) - }, - async markAllRead(userId) { - write('notifications', read('notifications', []).map((item) => (item.userId === userId && !item.readAt ? { ...item, readAt: Date.now() } : item))) - }, - }, + notifications, } diff --git a/frontend/src/services/apiRoutes.js b/frontend/src/services/apiRoutes.js index a89e467..f61b7b5 100644 --- a/frontend/src/services/apiRoutes.js +++ b/frontend/src/services/apiRoutes.js @@ -26,6 +26,7 @@ export const apiRoutes = { users: { me: () => `${API_BASE}/users/me`, + list: () => `${API_BASE}/admin/users`, }, categories: { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 669ff1a..74678c4 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -8,4 +8,9 @@ export default defineConfig({ react(), tailwindcss(), ], + server: { + proxy: { + '/api': 'http://localhost:5000', + }, + }, }) \ No newline at end of file From fea8786d0d9e7a29a7a3314c12968ef0d6726d0d Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 22:50:16 +0500 Subject: [PATCH 02/12] fix --- frontend/package-lock.json | 11 +++++++++++ frontend/src/context/LibraryProvider.jsx | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66db6b9..008f8d4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -58,6 +58,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1352,6 +1353,7 @@ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1416,6 +1418,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1579,6 +1582,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", @@ -2122,6 +2126,7 @@ "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -3025,6 +3030,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3717,6 +3723,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3897,6 +3904,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3906,6 +3914,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.28.0" }, @@ -4447,6 +4456,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", @@ -4606,6 +4616,7 @@ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/context/LibraryProvider.jsx b/frontend/src/context/LibraryProvider.jsx index dd108b5..c3472d8 100644 --- a/frontend/src/context/LibraryProvider.jsx +++ b/frontend/src/context/LibraryProvider.jsx @@ -15,7 +15,7 @@ export default function LibraryProvider({ children }) { const [categories, videos, submissions] = await Promise.all([ api.categories.list(), api.videos.list(), - api.submissions.list(user?.id, includeAll), + user ? api.submissions.list(user.id, includeAll) : Promise.resolve([]), ]) setData({ ready: true, categories, videos, submissions }) }, [user]) @@ -26,7 +26,7 @@ export default function LibraryProvider({ children }) { Promise.all([ api.categories.list(), api.videos.list(), - api.submissions.list(user?.id, includeAll), + user ? api.submissions.list(user.id, includeAll) : Promise.resolve([]), ]).then(([categories, videos, submissions]) => { if (active) setData({ ready: true, categories, videos, submissions }) }) @@ -68,7 +68,7 @@ export default function LibraryProvider({ children }) { async submitRecording(video, recording) { const submission = await api.submissions.create({ - userId: user.id, videoId: video.id, trimStart: recording.edit.start, trimEnd: recording.edit.end, + videoId: video.id, recording: recording.blob, trimStart: recording.edit.start, trimEnd: recording.edit.end, mirrored: recording.edit.mirrored, duration: recording.duration, size: recording.blob.size, mimeType: recording.mimeType, }) await refresh() From 5a9d1e20582b91f426bad57348dd47073e3cce61 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 01:05:16 +0500 Subject: [PATCH 03/12] backend updated --- backend/.env.example | 4 + backend/README.md | 14 +- backend/src/config/env.js | 5 + backend/src/middlewares/upload.js | 2 +- .../validators/submissionSchemas.js | 2 +- backend/src/models/Submission.js | 7 +- backend/src/models/SubmissionCooldown.js | 19 ++ backend/src/repositories/submissionRepo.js | 39 +++- backend/src/repositories/videoRepo.js | 2 +- backend/src/routes/videoRoutes.js | 2 +- backend/src/services/authService.js | 2 +- backend/src/services/categoryService.js | 2 +- backend/src/services/statsService.js | 8 +- backend/src/services/submissionService.js | 25 ++- backend/src/services/userService.js | 2 +- backend/src/services/videoService.js | 8 +- backend/tests/api.test.js | 173 +++++++++--------- backend/tests/helpers.js | 4 +- frontend/README.md | 13 +- 19 files changed, 205 insertions(+), 128 deletions(-) create mode 100644 backend/src/models/SubmissionCooldown.js diff --git a/backend/.env.example b/backend/.env.example index fdd69a1..143f05f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,4 @@ +# Copy this file to .env and fill it in. Never commit .env. NODE_ENV=development PORT=5000 @@ -33,3 +34,6 @@ MAX_VIDEO_UPLOAD_MB=300 MAX_RECORDING_UPLOAD_MB=100 RATE_LIMIT_ENABLED=true + +# Minimum gap, in milliseconds, between two submissions for the same (user, video). +SUBMISSION_COOLDOWN_MS=30000 diff --git a/backend/README.md b/backend/README.md index 3e4b4cc..ab4d1a4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -50,7 +50,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, | Method + path | Who | Notes | | --- | --- | --- | | `GET /health` | anyone | 503 if the database is down | -| `POST /auth/signup` | anyone | Always creates a learner. Sets the cookie | +| `POST /auth/signup` | anyone | Always creates a contributor. Sets the cookie | | `POST /auth/login` | anyone | Sets the cookie | | `POST /auth/logout` | anyone | Clears the cookie | | `GET /auth/session` | anyone | `{ data: user }`, or `{ data: null }` for visitors | @@ -58,14 +58,14 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, | `PATCH /users/me` | logged in | `{ connections: { github, linkedin } }`; a handle or a profile link; `null` disconnects | | `GET /categories` | anyone | | | `POST /categories`, `PATCH`/`DELETE /categories/:id` | admin | Deleting unassigns its videos, it does not delete them | -| `GET /videos`, `GET /videos/:id` | anyone | Visitors and learners: published + categorised only. Admin: everything | +| `GET /videos`, `GET /videos/:id` | anyone | Visitors and contributors: published + categorised only. Admin: everything | | `GET /videos/:id/poster` | anyone (same visibility) | | | `GET /videos/:id/file` | logged in | Streams with Range support | -| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `status`, `durationSec` | +| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `level`, `status`, `durationSec` | | `PATCH`/`DELETE /videos/:id` | admin | Moving to another category appends to the end of that path | -| `GET /submissions` | logged in | Learners: their own. Admin: all | +| `GET /submissions` | logged in | Contributors: their own. Admin: all | | `POST /submissions` | logged in | `multipart/form-data`: `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, `duration` | -| `GET /submissions/:id/recording` | admin | Learners can never play a submission back | +| `GET /submissions/:id/recording` | admin | Contributors can never play a submission back | | `POST /contact` | anyone | Rate limited: 5 per hour per IP | | `GET /admin/users`, `/admin/stats?days=14`, `/admin/messages` | admin | | @@ -77,7 +77,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, **Admin.** Created or promoted from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on every start. Signup ignores any `role` in the body. Changing `ADMIN_PASSWORD` later does not reset an existing admin's password. -**The lock rule.** A learner can submit a lesson once, ever. It is enforced twice: the service checks first, and a unique index on `(user, video)` settles races (three simultaneous submits produce exactly one, and the losers' files are cleaned up). There is no update or delete route for submissions. +**The cooldown rule.** This is a data-collection tool, not a one-shot quiz: a contributor can submit the same video more than once (more varied takes make better training data), but not back to back. `SUBMISSION_COOLDOWN_MS` (default 30s, in `.env`) is the minimum gap between two submissions for the same `(user, video)`. It is enforced atomically, not by a simple read-then-write: `repositories/submissionRepo.js#claimCooldown` does a conditional `findOneAndUpdate` with `upsert: true` against a `SubmissionCooldown` collection that has a unique index on `(user, video)`. If a live cooldown already exists, the upsert's insert path collides with that unique index (`E11000`), which is treated as "still cooling down" — so three simultaneous submissions for the same video still produce exactly one success (verified: `tests/api.test.js`'s concurrent-submit test). A submission that fails after claiming the cooldown (a storage or DB error) releases it, so a failed attempt never costs the contributor their next try. Regardless of timing, no submission can ever be edited or deleted once made, and only an admin can watch one back. **Uploads.** Multer writes to `uploads/tmp`, the service checks the file's real type from its first bytes (MP4, WebM or MOV; JPEG, PNG or WebP for posters; the browser-supplied type is not trusted), then hands it to storage. Failures at any step leave no files behind. @@ -89,7 +89,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, ## Testing -`npm test` runs `tests/api.test.js` (30 tests): auth, roles, uploads, Range streaming, the lock rule, stats, contact, cleanup of temp files. It calls the real HTTP stack. +`npm test` runs `tests/api.test.js` (31 tests): auth, roles, uploads, Range streaming, the cooldown rule (including a real elapsed-cooldown resubmission, sped up via `SUBMISSION_COOLDOWN_MS=200` in `tests/helpers.js`), stats, contact, cleanup of temp files. It calls the real HTTP stack. - Set `MONGODB_URI_TEST` to run against a MongoDB you already have (it uses that database and **drops it**, so point it at a throwaway one). - Otherwise it starts `mongodb-memory-server`, which downloads a MongoDB binary on first use. diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 3a6fb79..7c4bd04 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -31,6 +31,11 @@ const schema = z.object({ MAX_RECORDING_UPLOAD_MB: z.coerce.number().positive().default(100), RATE_LIMIT_ENABLED: bool.default(true), + + // A contributor can submit a video more than once, but not back to back: this is the + // minimum gap enforced between two submissions for the same (user, video). Overridable + // (like BCRYPT_ROUNDS) so tests do not have to wait 30 real seconds. + SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), }) const parsed = schema.safeParse(process.env) diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js index f05cbc5..2246944 100644 --- a/backend/src/middlewares/upload.js +++ b/backend/src/middlewares/upload.js @@ -27,5 +27,5 @@ const build = ({ maxMb, rules, files }) => multer({ export const videoUpload = build({ maxMb: env.MAX_VIDEO_UPLOAD_MB, rules: { video: 'video/', poster: 'image/' }, files: 2 }) .fields([{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }]) -/** A learner's recording. */ +/** A contributor's recording. */ export const recordingUpload = build({ maxMb: env.MAX_RECORDING_UPLOAD_MB, rules: { recording: 'video/' }, files: 1 }).single('recording') diff --git a/backend/src/middlewares/validators/submissionSchemas.js b/backend/src/middlewares/validators/submissionSchemas.js index 11df420..25820ec 100644 --- a/backend/src/middlewares/validators/submissionSchemas.js +++ b/backend/src/middlewares/validators/submissionSchemas.js @@ -4,7 +4,7 @@ import { booleanField, objectId } from './common.js' const seconds = (label) => z.coerce.number({ error: `${label} must be a number of seconds.` }).min(0, `${label} cannot be negative.`) export const createSubmissionSchema = z.object({ - videoId: objectId('lesson'), + videoId: objectId('video'), trimStart: seconds('Trim start'), trimEnd: seconds('Trim end'), mirrored: booleanField.default(false), diff --git a/backend/src/models/Submission.js b/backend/src/models/Submission.js index 37978be..3be4846 100644 --- a/backend/src/models/Submission.js +++ b/backend/src/models/Submission.js @@ -23,8 +23,11 @@ const submissionSchema = new mongoose.Schema({ }), }) -// The lock rule, enforced by the database: one submission per learner per lesson, ever. -submissionSchema.index({ user: 1, video: 1 }, { unique: true }) +// Not unique: a contributor may submit the same video more than once over time (see +// SubmissionCooldown for what actually stops two submissions from landing back to back). +// Compound, not just on (user, video), because the cooldown check's only query is +// "this user's most recent submission for this video", sorted by time. +submissionSchema.index({ user: 1, video: 1, createdAt: -1 }) submissionSchema.index({ createdAt: -1 }) export const Submission = mongoose.model('Submission', submissionSchema) diff --git a/backend/src/models/SubmissionCooldown.js b/backend/src/models/SubmissionCooldown.js new file mode 100644 index 0000000..8063eac --- /dev/null +++ b/backend/src/models/SubmissionCooldown.js @@ -0,0 +1,19 @@ +import mongoose from 'mongoose' + +/** + * One document per (user, video) pair, tracking when that pair is next allowed to submit. + * This is what actually enforces the cooldown, atomically: see + * repositories/submissionRepo.js#claimCooldown for how the unique index below is used as a + * compare-and-swap so two simultaneous submissions for the same video cannot both win, the + * same trick the old one-submission-ever rule used, just against a time window instead of + * forever. + */ +const submissionCooldownSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + video: { type: mongoose.Schema.Types.ObjectId, ref: 'Video', required: true }, + lockedUntil: { type: Date, required: true }, +}, { versionKey: false }) + +submissionCooldownSchema.index({ user: 1, video: 1 }, { unique: true }) + +export const SubmissionCooldown = mongoose.model('SubmissionCooldown', submissionCooldownSchema) diff --git a/backend/src/repositories/submissionRepo.js b/backend/src/repositories/submissionRepo.js index e1d0652..02de858 100644 --- a/backend/src/repositories/submissionRepo.js +++ b/backend/src/repositories/submissionRepo.js @@ -1,13 +1,50 @@ import { Submission } from '../models/Submission.js' +import { SubmissionCooldown } from '../models/SubmissionCooldown.js' +import { isDuplicateKeyError } from '../utils/mongoose.js' export const submissionRepo = { create: (data) => Submission.create(data), findById: (id) => Submission.findById(id), - findByUserAndVideo: (user, video) => Submission.findOne({ user, video }), list: ({ userId } = {}) => Submission.find(userId ? { user: userId } : {}).sort({ createdAt: -1 }), recent: (limit) => Submission.find().sort({ createdAt: -1 }).limit(limit), count: () => Submission.countDocuments(), // Just the timestamps, for bucketing into days. createdSince: (date) => Submission.find({ createdAt: { $gte: date } }).select('createdAt').lean(), countsByVideo: () => Submission.aggregate([{ $group: { _id: '$video', count: { $sum: 1 } } }]), + + /** + * Atomically tries to start a cooldown window for (user, video). Returns true if this call + * won (no other submission for this pair is currently inside its cooldown), false if one + * already is. Race-safe under concurrent requests: the conditional filter plus the unique + * index on SubmissionCooldown make this a compare-and-swap, not a read-then-write. + * - No cooldown doc yet -> upsert inserts one. Wins. + * - Doc exists, its window already elapsed (lockedUntil <= now) -> matches, gets updated + * to a fresh window. Wins. + * - Doc exists, still inside its window -> filter does not match, so the upsert instead + * tries to INSERT a new one (user, video) and collides with the unique index. Loses. + */ + async claimCooldown(user, video, cooldownMs) { + const now = new Date() + try { + await SubmissionCooldown.findOneAndUpdate( + { user, video, lockedUntil: { $lte: now } }, + { $set: { lockedUntil: new Date(now.getTime() + cooldownMs) } }, + { upsert: true }, + ) + return true + } catch (error) { + if (isDuplicateKeyError(error)) return false + throw error + } + }, + + /** The remaining wait, in ms (0 if none), without claiming anything. Used to build the error message. */ + async cooldownRemaining(user, video) { + const doc = await SubmissionCooldown.findOne({ user, video }).select('lockedUntil').lean() + if (!doc) return 0 + return Math.max(0, doc.lockedUntil.getTime() - Date.now()) + }, + + /** Frees a claimed cooldown, so a failed submission does not lock someone out for nothing. */ + releaseCooldown: (user, video) => SubmissionCooldown.deleteOne({ user, video }), } diff --git a/backend/src/repositories/videoRepo.js b/backend/src/repositories/videoRepo.js index d984e11..b641cc3 100644 --- a/backend/src/repositories/videoRepo.js +++ b/backend/src/repositories/videoRepo.js @@ -2,7 +2,7 @@ import { VIDEO_STATUS } from '../config/constants.js' import { Video } from '../models/Video.js' export const videoRepo = { - // Learners see published videos that belong to a category. Admins see everything. + // Contributors see published videos that belong to a category. Admins see everything. listVisible: ({ isAdmin }) => Video.find(isAdmin ? {} : { status: VIDEO_STATUS.PUBLISHED, category: { $ne: null } }).sort({ order: 1, createdAt: 1 }), findById: (id) => Video.findById(id), findManyByIds: (ids) => Video.find({ _id: { $in: ids } }), diff --git a/backend/src/routes/videoRoutes.js b/backend/src/routes/videoRoutes.js index 11293ef..2fcf4d6 100644 --- a/backend/src/routes/videoRoutes.js +++ b/backend/src/routes/videoRoutes.js @@ -12,7 +12,7 @@ import { videoUpload } from '../middlewares/upload.js' const router = Router() const adminOnly = [authenticate, requireRole(ROLES.ADMIN)] -// Metadata and posters: anonymous visitors see published lessons only, admins see everything. +// Metadata and posters: anonymous visitors see published videos only, admins see everything. router.get('/', optionalAuthenticate, videoController.list) router.get('/:id', optionalAuthenticate, validate({ params: idParams }), videoController.get) router.get('/:id/poster', optionalAuthenticate, validate({ params: idParams }), videoController.poster) diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index da96f21..1b37bbb 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -17,7 +17,7 @@ export const authService = { if (await userRepo.findByEmail(email)) throw emailTaken() const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) try { - // `role` is never read from the request: everyone who signs up is a learner. + // `role` is never read from the request: everyone who signs up is a contributor. const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER }) return { user, token: signToken(user.id) } } catch (error) { diff --git a/backend/src/services/categoryService.js b/backend/src/services/categoryService.js index 22a474f..52ea08b 100644 --- a/backend/src/services/categoryService.js +++ b/backend/src/services/categoryService.js @@ -34,7 +34,7 @@ export const categoryService = { } }, - /** Deleting a category keeps its videos; they become unassigned (and hidden from learners). */ + /** Deleting a category keeps its videos; they become unassigned (and hidden from contributors). */ async remove(id) { const category = await categoryRepo.findById(id) if (!category) throw notFound('That category does not exist.') diff --git a/backend/src/services/statsService.js b/backend/src/services/statsService.js index 98a494c..e89695d 100644 --- a/backend/src/services/statsService.js +++ b/backend/src/services/statsService.js @@ -12,7 +12,7 @@ export const statsService = { /** Numbers for the admin dashboard. Days are UTC days. */ async overview({ days }) { const start = startOfUtcDay(Date.now() - (days - 1) * DAY_MS) - const [learners, videos, published, submissions, categories, createdDates, perVideo, videoCategories, recent] = await Promise.all([ + const [contributors, videos, published, submissions, categories, createdDates, perVideo, videoCategories, recent] = await Promise.all([ userRepo.countByRole(ROLES.USER), videoRepo.count(), videoRepo.count({ status: VIDEO_STATUS.PUBLISHED }), @@ -50,20 +50,20 @@ export const statsService = { const videoMap = byId(recentVideos) const categoryMap = byId(categories) const recentSubmissions = recent.map((item) => { - const learner = userMap.get(String(item.user)) + const contributor = userMap.get(String(item.user)) const video = videoMap.get(String(item.video)) const category = video?.category && categoryMap.get(String(video.category)) return { id: item.id, submittedAt: item.createdAt, - learner: learner ? { id: learner.id, name: `${learner.firstName} ${learner.surname}` } : null, + contributor: contributor ? { id: contributor.id, name: `${contributor.firstName} ${contributor.surname}` } : null, video: video ? { id: video.id, title: video.title } : null, category: category ? { id: category.id, label: category.label } : null, } }) return { - totals: { learners, videos, publishedVideos: published, submissions, categories: categories.length }, + totals: { contributors, videos, publishedVideos: published, submissions, categories: categories.length }, activity, byCategory, recent: recentSubmissions, diff --git a/backend/src/services/submissionService.js b/backend/src/services/submissionService.js index a9ccaea..d223fe4 100644 --- a/backend/src/services/submissionService.js +++ b/backend/src/services/submissionService.js @@ -1,13 +1,13 @@ import { MAX_RECORDING_SECONDS, ROLES, TRIM_TOLERANCE_SECONDS, VIDEO_STATUS } from '../config/constants.js' +import { env } from '../config/env.js' import { submissionRepo } from '../repositories/submissionRepo.js' import { videoRepo } from '../repositories/videoRepo.js' import { conflict, notFound, validationError } from '../utils/AppError.js' import { sniffVideo } from '../utils/files.js' import { logger } from '../utils/logger.js' -import { isDuplicateKeyError } from '../utils/mongoose.js' import { storageService } from './storage/index.js' -const locked = () => conflict('You have already submitted this lesson. Submitted recordings are locked.') +const cooling = (remainingMs) => conflict(`Wait ${Math.ceil(remainingMs / 1000)}s before recording this video again.`) function assertTrimIsSane({ trimStart, trimEnd, duration }) { if (duration <= 0 || duration > MAX_RECORDING_SECONDS) throw validationError({ duration: `A recording must be between 0 and ${MAX_RECORDING_SECONDS} seconds.` }) @@ -17,8 +17,13 @@ function assertTrimIsSane({ trimStart, trimEnd, duration }) { export const submissionService = { /** - * The lock rule lives here and in the database: one submission per learner per lesson, and - * there is deliberately no update or delete for learners. Once it is in, it stays in. + * This is a data-collection tool, not a one-shot quiz: a contributor can submit a video + * more than once, since more varied takes make better training data. What is not allowed + * is two submissions for the same (user, video) back to back — SUBMISSION_COOLDOWN_MS + * apart, enforced by atomically claiming a cooldown window (repositories/submissionRepo.js + * #claimCooldown) before doing any of the expensive work (storage upload, DB write). There + * is still no update or delete for a submission once it exists: each individual recording, + * once in, stays exactly as submitted. */ async create(user, input, upload) { if (!upload) throw validationError({ recording: 'Attach your recording.' }) @@ -27,8 +32,10 @@ export const submissionService = { assertTrimIsSane(input) const video = await videoRepo.findById(input.videoId) - if (!video || video.status !== VIDEO_STATUS.PUBLISHED || !video.category) throw notFound('That lesson is not available.') - if (await submissionRepo.findByUserAndVideo(user._id, video._id)) throw locked() + if (!video || video.status !== VIDEO_STATUS.PUBLISHED || !video.category) throw notFound('That video is not available.') + + const claimed = await submissionRepo.claimCooldown(user._id, video._id, env.SUBMISSION_COOLDOWN_MS) + if (!claimed) throw cooling(await submissionRepo.cooldownRemaining(user._id, video._id)) const recording = await storageService.save({ tempPath: upload.path, originalName: upload.originalname, mimeType: kind.mimeType, ext: kind.ext }, { folder: 'recordings' }) try { @@ -38,15 +45,15 @@ export const submissionService = { }) } catch (error) { await storageService.remove(recording) - if (isDuplicateKeyError(error)) throw locked() // a double-click or a second tab won the race + await submissionRepo.releaseCooldown(user._id, video._id) // this attempt never landed, so it should not cost the contributor their cooldown throw error } }, - /** Learners see their own submissions; admins see all of them. */ + /** Contributors see their own submissions; admins see all of them. */ list: (user) => submissionRepo.list(user.role === ROLES.ADMIN ? {} : { userId: user._id }), - /** Admin only (enforced by the route). Learners can never play their own submission back. */ + /** Admin only (enforced by the route). Contributors can never play their own submission back. */ async getRecording(id) { const submission = await submissionRepo.findById(id) if (!submission) throw notFound('That submission does not exist.') diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js index 9f23da5..79269e9 100644 --- a/backend/src/services/userService.js +++ b/backend/src/services/userService.js @@ -10,6 +10,6 @@ export const userService = { return userRepo.save(user) }, - listLearners: () => userRepo.list({ role: ROLES.USER }), + listContributors: () => userRepo.list({ role: ROLES.USER }), listAll: () => userRepo.list(), } diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js index 524645e..4bffede 100644 --- a/backend/src/services/videoService.js +++ b/backend/src/services/videoService.js @@ -12,7 +12,7 @@ async function assertCategoryExists(categoryId) { if (categoryId && !(await categoryRepo.findById(categoryId))) throw validationError({ categoryId: 'That category does not exist.' }) } -/** Learners only get published videos that sit in a category; anything else looks like it does not exist. */ +/** Contributors only get published videos that sit in a category; anything else looks like it does not exist. */ async function findVisible(id, requester) { const video = await videoRepo.findById(id) const visible = video && (isAdmin(requester) || (video.status === VIDEO_STATUS.PUBLISHED && video.category)) @@ -60,7 +60,7 @@ export const videoService = { } const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 return await videoRepo.create({ - title: input.title, status: input.status, durationSec: input.durationSec, + title: input.title, level: input.level, status: input.status, durationSec: input.durationSec, category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id, }) } catch (error) { @@ -80,7 +80,7 @@ export const videoService = { // Moving into a category puts the video at the end of that path unless an order was given. if (moved && patch.categoryId && patch.order === undefined) video.order = (await videoRepo.maxOrder(patch.categoryId)) + 1 } - for (const key of ['title', 'status', 'order']) { + for (const key of ['title', 'level', 'status', 'order']) { if (patch[key] !== undefined) video[key] = patch[key] } return videoRepo.save(video) @@ -90,7 +90,7 @@ export const videoService = { const video = await videoRepo.findById(id) if (!video) throw notFound('That video does not exist.') await videoRepo.remove(video.id) - // Learners' past submissions are kept on record; only the base video's own files go. + // Contributors' past submissions are kept on record; only the base video's own files go. await Promise.all([storageService.remove(video.videoFile), storageService.remove(video.posterFile)]) }, diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index eed8da7..b69a5c7 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -8,9 +8,9 @@ const { authService } = await import('../src/services/authService.js') const { storageService } = await import('../src/services/storage/index.js') const { default: mongoose } = await import('mongoose') -let server, memory, base, admin, learner, other +let server, memory, base, admin, contributor, other const asJson = (res) => res.body -const learnerData = { firstName: 'Sara', surname: 'Ahmed', email: 'Sara@Example.com', password: 'password1', confirmPassword: 'password1' } +const contributorData = { firstName: 'Sara', surname: 'Ahmed', email: 'Sara@Example.com', password: 'password1', confirmPassword: 'password1' } before(async () => { let uri = process.env.MONGODB_URI_TEST @@ -29,8 +29,8 @@ before(async () => { admin = new Client(base) assert.equal((await admin.post('/auth/login', { email: 'admin@signpak.test', password: 'Admin@12345' })).status, 200) - learner = new Client(base) - assert.equal((await learner.post('/auth/signup', learnerData)).status, 201) + contributor = new Client(base) + assert.equal((await contributor.post('/auth/signup', contributorData)).status, 201) other = new Client(base) await other.post('/auth/signup', { firstName: 'Omar', surname: 'Khan', email: 'omar@example.com', password: 'password1' }) }) @@ -74,9 +74,9 @@ describe('auth', () => { assert.equal(res.status, 422) assert.equal(res.body.error.code, 'VALIDATION_ERROR') for (const field of ['firstName', 'surname', 'email', 'password']) assert.ok(res.body.error.fields[field], `missing error for ${field}`) - const mismatch = await new Client(base).post('/auth/signup', { ...learnerData, email: 'x@example.com', confirmPassword: 'different' }) + const mismatch = await new Client(base).post('/auth/signup', { ...contributorData, email: 'x@example.com', confirmPassword: 'different' }) assert.equal(mismatch.body.error.fields.confirmPassword, 'Passwords do not match.') - const short = await new Client(base).post('/auth/signup', { ...learnerData, email: 'x@example.com', password: 'short', confirmPassword: undefined }) + const short = await new Client(base).post('/auth/signup', { ...contributorData, email: 'x@example.com', password: 'short', confirmPassword: undefined }) assert.equal(short.body.error.fields.password, 'Use at least 8 characters.') }) @@ -93,7 +93,7 @@ describe('auth', () => { }) test('duplicate emails conflict, case-insensitively', async () => { - const res = await new Client(base).post('/auth/signup', { ...learnerData, email: 'SARA@example.com' }) + const res = await new Client(base).post('/auth/signup', { ...contributorData, email: 'SARA@example.com' }) assert.equal(res.status, 409) assert.ok(res.body.error.fields.email) }) @@ -124,33 +124,33 @@ describe('auth', () => { assert.equal(users.find((u) => u.email === 'admin@signpak.test').role, 'admin') }) - test('admin-only routes reject learners and visitors', async () => { - assert.equal((await learner.get('/admin/users')).status, 403) + test('admin-only routes reject contributors and visitors', async () => { + assert.equal((await contributor.get('/admin/users')).status, 403) assert.equal((await new Client(base).get('/admin/users')).status, 401) }) }) describe('profile connections', () => { test('normalises, validates, and disconnects', async () => { - const ok = await learner.patch('/users/me', { connections: { github: 'https://github.com/octocat', linkedin: 'linkedin.com/in/sara-ahmed' } }) + const ok = await contributor.patch('/users/me', { connections: { github: 'https://github.com/octocat', linkedin: 'linkedin.com/in/sara-ahmed' } }) assert.equal(ok.status, 200) assert.equal(ok.body.data.connections.github, 'octocat') assert.equal(ok.body.data.connections.linkedin, 'https://www.linkedin.com/in/sara-ahmed') - const bad = await learner.patch('/users/me', { connections: { github: 'not a user!!' } }) + const bad = await contributor.patch('/users/me', { connections: { github: 'not a user!!' } }) assert.equal(bad.status, 422) assert.ok(bad.body.error.fields['connections.github']) - const off = await learner.patch('/users/me', { connections: { github: null } }) + const off = await contributor.patch('/users/me', { connections: { github: null } }) assert.equal(off.body.data.connections.github, null) assert.equal(off.body.data.connections.linkedin, 'https://www.linkedin.com/in/sara-ahmed') // untouched assert.equal((await new Client(base).patch('/users/me', { connections: {} })).status, 401) }) }) -let daily, work, lesson1, lesson2, draft +let daily, work, video1, video2, draft describe('categories', () => { test('only admins write; names are unique regardless of case', async () => { - assert.equal((await learner.post('/categories', { label: 'Nope' })).status, 403) + assert.equal((await contributor.post('/categories', { label: 'Nope' })).status, 403) const made = await admin.post('/categories', { label: 'Daily phrases', copy: 'Everyday.', tone: 'yellow' }) assert.equal(made.status, 201) daily = made.body.data @@ -181,33 +181,33 @@ describe('videos', () => { const fake = await admin.upload('/videos', videoForm({ title: 'Fake' }, { video: Buffer.from('this is just text, not a video at all') })) assert.equal(fake.status, 422) assert.match(fake.body.error.fields.video, /MP4, WebM or MOV/) - const noTitle = await admin.upload('/videos', videoForm({})) + const noTitle = await admin.upload('/videos', videoForm({ level: 'Beginner' })) assert.equal(noTitle.status, 422) assert.ok(noTitle.body.error.fields.title) const missingCategory = await admin.upload('/videos', videoForm({ title: 'Ghost cat', categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })) assert.equal(missingCategory.status, 422) - assert.equal((await learner.upload('/videos', videoForm({ title: 'Nope' }))).status, 403) + assert.equal((await contributor.upload('/videos', videoForm({ title: 'Nope' }))).status, 403) assert.equal((await new Client(base).upload('/videos', videoForm({ title: 'Nope' }))).status, 401) assert.deepEqual(filesIn('videos'), [], 'rejected uploads must not leave files behind') assert.deepEqual(filesIn('tmp'), [], 'rejected uploads must not leave temp files behind') }) test('admin uploads with and without a poster; the API hides storage internals', async () => { - const a = await admin.upload('/videos', videoForm({ title: 'Nice to meet you', categoryId: daily.id, durationSec: '134' }, { poster: jpeg() })) + const a = await admin.upload('/videos', videoForm({ title: 'Nice to meet you', categoryId: daily.id, level: 'Beginner', durationSec: '134' }, { poster: jpeg() })) assert.equal(a.status, 201, JSON.stringify(a.body)) - lesson1 = a.body.data - assert.equal(lesson1.categoryId, daily.id) - assert.equal(lesson1.order, 1) - assert.equal(lesson1.durationSec, 134) - assert.equal(lesson1.videoUrl, `/api/v1/videos/${lesson1.id}/file`) - assert.equal(lesson1.poster, `/api/v1/videos/${lesson1.id}/poster`) - assert.equal(lesson1.videoFile, undefined) - assert.equal(lesson1.size, webm().length) + video1 = a.body.data + assert.equal(video1.categoryId, daily.id) + assert.equal(video1.order, 1) + assert.equal(video1.durationSec, 134) + assert.equal(video1.videoUrl, `/api/v1/videos/${video1.id}/file`) + assert.equal(video1.poster, `/api/v1/videos/${video1.id}/poster`) + assert.equal(video1.videoFile, undefined) + assert.equal(video1.size, webm().length) const b = await admin.upload('/videos', videoForm({ title: 'A warm introduction', categoryId: daily.id }, { video: mp4() })) - lesson2 = b.body.data - assert.equal(lesson2.order, 2) - assert.equal(lesson2.poster, null) - assert.equal(lesson2.status, 'published') + video2 = b.body.data + assert.equal(video2.order, 2) + assert.equal(video2.poster, null) + assert.equal(video2.status, 'published') draft = (await admin.upload('/videos', videoForm({ title: 'Secret draft', categoryId: daily.id, status: 'draft' }))).body.data const loose = (await admin.upload('/videos', videoForm({ title: 'Unassigned', categoryId: '' }))).body.data assert.equal(loose.categoryId, null) @@ -216,107 +216,116 @@ describe('videos', () => { assert.deepEqual(filesIn('tmp'), []) }) - test('visibility: learners and visitors only see published, categorised lessons', async () => { + test('visibility: contributors and visitors only see published, categorised videos', async () => { const titles = (res) => res.body.data.map((v) => v.title).sort() assert.deepEqual(titles(await admin.get('/videos')), ['A warm introduction', 'Nice to meet you', 'Secret draft', 'Unassigned']) - assert.deepEqual(titles(await learner.get('/videos')), ['A warm introduction', 'Nice to meet you']) + assert.deepEqual(titles(await contributor.get('/videos')), ['A warm introduction', 'Nice to meet you']) assert.deepEqual(titles(await new Client(base).get('/videos')), ['A warm introduction', 'Nice to meet you']) - assert.equal((await learner.get(`/videos/${draft.id}`)).status, 404) + assert.equal((await contributor.get(`/videos/${draft.id}`)).status, 404) assert.equal((await admin.get(`/videos/${draft.id}`)).status, 200) }) test('the file streams with Range support, and needs a session', async () => { - assert.equal((await new Client(base).get(`/videos/${lesson1.id}/file`)).status, 401) - const full = await learner.get(`/videos/${lesson1.id}/file`, { raw: true }) + assert.equal((await new Client(base).get(`/videos/${video1.id}/file`)).status, 401) + const full = await contributor.get(`/videos/${video1.id}/file`, { raw: true }) assert.equal(full.status, 200) assert.equal(full.headers.get('accept-ranges'), 'bytes') assert.equal(full.headers.get('content-type'), 'video/webm') assert.deepEqual(full.body, webm()) - const part = await learner.get(`/videos/${lesson1.id}/file`, { raw: true, headers: { range: 'bytes=10-19' } }) + const part = await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=10-19' } }) assert.equal(part.status, 206) assert.equal(part.headers.get('content-range'), `bytes 10-19/${webm().length}`) assert.deepEqual(part.body, webm().subarray(10, 20)) - const tail = await learner.get(`/videos/${lesson1.id}/file`, { raw: true, headers: { range: 'bytes=-5' } }) + const tail = await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=-5' } }) assert.deepEqual(tail.body, webm().subarray(-5)) - assert.equal((await learner.get(`/videos/${lesson1.id}/file`, { raw: true, headers: { range: 'bytes=99999-' } })).status, 416) - assert.equal((await learner.get(`/videos/${draft.id}/file`)).status, 404) - const mp4res = await learner.get(`/videos/${lesson2.id}/file`, { raw: true }) + assert.equal((await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=99999-' } })).status, 416) + assert.equal((await contributor.get(`/videos/${draft.id}/file`)).status, 404) + const mp4res = await contributor.get(`/videos/${video2.id}/file`, { raw: true }) assert.equal(mp4res.headers.get('content-type'), 'video/mp4') }) test('posters are served as images', async () => { - const res = await new Client(base).get(`/videos/${lesson1.id}/poster`, { raw: true }) + const res = await new Client(base).get(`/videos/${video1.id}/poster`, { raw: true }) assert.equal(res.status, 200) assert.equal(res.headers.get('content-type'), 'image/jpeg') - assert.equal((await new Client(base).get(`/videos/${lesson2.id}/poster`)).status, 404) + assert.equal((await new Client(base).get(`/videos/${video2.id}/poster`)).status, 404) }) test('editing: moving categories appends to the end of that path', async () => { - const moved = await admin.patch(`/videos/${lesson2.id}`, { categoryId: work.id }) + const moved = await admin.patch(`/videos/${video2.id}`, { categoryId: work.id }) assert.equal(moved.body.data.categoryId, work.id) assert.equal(moved.body.data.order, 1) - const back = await admin.patch(`/videos/${lesson2.id}`, { categoryId: daily.id }) + const back = await admin.patch(`/videos/${video2.id}`, { categoryId: daily.id }) assert.ok(back.body.data.order > draft.order, 'moved video goes after everything already in the path') - assert.equal((await admin.patch(`/videos/${lesson2.id}`, { status: 'archived' })).status, 422) - assert.equal((await admin.patch(`/videos/${lesson2.id}`, { categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })).status, 422) + assert.equal((await admin.patch(`/videos/${video2.id}`, { status: 'archived' })).status, 422) + assert.equal((await admin.patch(`/videos/${video2.id}`, { categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })).status, 422) const unpublished = await admin.patch(`/videos/${draft.id}`, { title: 'Still a draft' }) assert.equal(unpublished.body.data.title, 'Still a draft') - assert.equal((await learner.patch(`/videos/${lesson2.id}`, { title: 'hax' })).status, 403) + assert.equal((await contributor.patch(`/videos/${video2.id}`, { title: 'hax' })).status, 403) }) }) let submission -describe('submissions and the lock rule', () => { - const good = () => ({ videoId: lesson1.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' }) +describe('submissions and the cooldown rule', () => { + const good = () => ({ videoId: video1.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' }) test('input is checked before anything is stored', async () => { const before = filesIn('recordings').length assert.equal((await new Client(base).upload('/submissions', recordingForm(good()))).status, 401) - assert.equal((await learner.upload('/submissions', recordingForm(good(), { file: null }))).status, 422) - assert.equal((await learner.upload('/submissions', recordingForm(good(), { file: Buffer.from('plain text pretending to be video') }))).status, 422) - assert.equal((await learner.upload('/submissions', recordingForm({ ...good(), trimStart: '2', trimEnd: '1' }))).status, 422) - assert.equal((await learner.upload('/submissions', recordingForm({ ...good(), trimEnd: '30' }))).status, 422) - assert.equal((await learner.upload('/submissions', recordingForm({ ...good(), videoId: 'nope' }))).status, 422) - assert.equal((await learner.upload('/submissions', recordingForm({ ...good(), videoId: draft.id }))).status, 404) + assert.equal((await contributor.upload('/submissions', recordingForm(good(), { file: null }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm(good(), { file: Buffer.from('plain text pretending to be video') }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), trimStart: '2', trimEnd: '1' }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), trimEnd: '30' }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), videoId: 'nope' }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), videoId: draft.id }))).status, 404) assert.equal(filesIn('recordings').length, before) assert.deepEqual(filesIn('tmp'), []) }) test('a valid submission is stored and listed', async () => { - const res = await learner.upload('/submissions', recordingForm(good())) + const res = await contributor.upload('/submissions', recordingForm(good())) assert.equal(res.status, 201, JSON.stringify(res.body)) submission = res.body.data - assert.equal(submission.videoId, lesson1.id) + assert.equal(submission.videoId, video1.id) assert.equal(submission.mirrored, true) assert.equal(submission.trimEnd, 2.5) assert.equal(submission.recording, undefined) assert.equal(filesIn('recordings').length, 1) - const mine = await learner.get('/submissions') + const mine = await contributor.get('/submissions') assert.equal(mine.body.data.length, 1) assert.equal((await other.get('/submissions')).body.data.length, 0) assert.equal((await admin.get('/submissions')).body.data.length, 1) }) - test('LOCK: a second submission for the same lesson is refused and stores nothing', async () => { - const again = await learner.upload('/submissions', recordingForm(good(), { file: mp4() })) + test('COOLDOWN: a second submission for the same video, too soon, is refused and stores nothing', async () => { + const again = await contributor.upload('/submissions', recordingForm(good(), { file: mp4() })) assert.equal(again.status, 409) - assert.match(again.body.error.message, /locked/i) + assert.match(again.body.error.message, /wait \d+s/i) assert.equal(filesIn('recordings').length, 1) assert.deepEqual(filesIn('tmp'), []) }) - test('LOCK: concurrent double-submits produce exactly one submission', async () => { - const results = await Promise.all([1, 2, 3].map(() => other.upload('/submissions', recordingForm({ ...good(), videoId: lesson2.id })))) + test('COOLDOWN: concurrent double-submits for the same video produce exactly one submission', async () => { + const results = await Promise.all([1, 2, 3].map(() => other.upload('/submissions', recordingForm({ ...good(), videoId: video2.id })))) assert.deepEqual(results.map((r) => r.status).sort(), [201, 409, 409]) assert.equal(filesIn('recordings').length, 2, 'losing uploads must not leave orphaned files') assert.equal((await other.get('/submissions')).body.data.length, 1) }) - test('LOCK: there is no way to edit or delete a submission, and learners cannot watch it back', async () => { - assert.equal((await learner.patch(`/submissions/${submission.id}`, { trimEnd: 1 })).status, 404) - assert.equal((await learner.delete(`/submissions/${submission.id}`)).status, 404) - assert.equal((await learner.get(`/submissions/${submission.id}/recording`)).status, 403) + test('COOLDOWN: once it elapses, the same video can be submitted again, as a new, separate recording', async () => { + await new Promise((resolve) => setTimeout(resolve, Number(process.env.SUBMISSION_COOLDOWN_MS) + 20)) + const res = await contributor.upload('/submissions', recordingForm(good(), { file: mp4() })) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.notEqual(res.body.data.id, submission.id) + const mine = await contributor.get('/submissions') + assert.equal(mine.body.data.length, 2, 'both recordings for this video are kept, not merged or replaced') + }) + + test('a submission can never be edited or deleted once made, and contributors cannot watch their own back', async () => { + assert.equal((await contributor.patch(`/submissions/${submission.id}`, { trimEnd: 1 })).status, 404) + assert.equal((await contributor.delete(`/submissions/${submission.id}`)).status, 404) + assert.equal((await contributor.get(`/submissions/${submission.id}/recording`)).status, 403) assert.equal((await other.get(`/submissions/${submission.id}/recording`)).status, 403) }) @@ -329,44 +338,44 @@ describe('submissions and the lock rule', () => { assert.equal(part.status, 206) }) - test('deleting a base video keeps learners\' submissions on record', async () => { + test('deleting a base video keeps contributors\' submissions on record', async () => { const before = filesIn('videos').length - assert.equal((await admin.delete(`/videos/${lesson2.id}`)).status, 204) + assert.equal((await admin.delete(`/videos/${video2.id}`)).status, 204) assert.equal(filesIn('videos').length, before - 1, 'the base video file is removed from storage') - assert.equal((await admin.get(`/videos/${lesson2.id}`)).status, 404) + assert.equal((await admin.get(`/videos/${video2.id}`)).status, 404) assert.equal((await other.get('/submissions')).body.data.length, 1) }) }) describe('admin stats and contact', () => { test('stats add up', async () => { - assert.equal((await learner.get('/admin/stats')).status, 403) + assert.equal((await contributor.get('/admin/stats')).status, 403) const res = await admin.get('/admin/stats?days=7') assert.equal(res.status, 200) const { totals, activity, byCategory, recent } = res.body.data - assert.equal(totals.learners, 3) // sara, omar, eve - assert.equal(totals.submissions, 2) + assert.equal(totals.contributors, 3) // sara, omar, eve + assert.equal(totals.submissions, 3) // sara's two takes on video1 (cooldown test), plus omar's one on video2 assert.equal(totals.categories, 2) assert.equal(totals.videos, 3) - assert.equal(totals.publishedVideos, 2) // lesson 1 and the unassigned one; the draft is not counted + assert.equal(totals.publishedVideos, 2) // video 1 and the unassigned one; the draft is not counted assert.equal(activity.length, 7) - assert.equal(activity.reduce((sum, d) => sum + d.count, 0), 2) + assert.equal(activity.reduce((sum, d) => sum + d.count, 0), 3) assert.equal(activity.at(-1).date, new Date().toISOString().slice(0, 10)) - // Omar's lesson was deleted earlier, so it counts towards totals and activity but no longer belongs to a category. - assert.equal(byCategory.find((c) => c.categoryId === daily.id).count, 1) + // Omar's video was deleted earlier, so it counts towards totals and activity but no longer belongs to a category. + assert.equal(byCategory.find((c) => c.categoryId === daily.id).count, 2) // both of sara's video1 submissions assert.equal(byCategory.find((c) => c.categoryId === work.id).count, 0) - assert.equal(recent.length, 2) - assert.ok(['Sara Ahmed', 'Omar Khan'].includes(recent[0].learner.name)) - assert.equal(recent.find((r) => r.video === null)?.category, null) // the deleted lesson + assert.equal(recent.length, 3) + assert.ok(['Sara Ahmed', 'Omar Khan'].includes(recent[0].contributor.name)) + assert.equal(recent.find((r) => r.video === null)?.category, null) // the deleted video assert.equal((await admin.get('/admin/stats?days=1000')).status, 422) }) test('deleting a category unassigns its videos instead of deleting them', async () => { assert.equal((await admin.delete(`/categories/${daily.id}`)).status, 204) - const kept = await admin.get(`/videos/${lesson1.id}`) + const kept = await admin.get(`/videos/${video1.id}`) assert.equal(kept.status, 200) assert.equal(kept.body.data.categoryId, null) - assert.equal((await learner.get(`/videos/${lesson1.id}`)).status, 404) // hidden from learners now + assert.equal((await contributor.get(`/videos/${video1.id}`)).status, 404) // hidden from contributors now }) test('contact messages: public to send, admin to read', async () => { @@ -378,7 +387,7 @@ describe('admin stats and contact', () => { const list = await admin.get('/admin/messages') assert.equal(list.status, 200) assert.ok(list.body.data.some((m) => m.name === 'Maya' && m.message === 'Hello!')) - assert.equal((await learner.get('/admin/messages')).status, 403) + assert.equal((await contributor.get('/admin/messages')).status, 403) }) test('oversized uploads are rejected with 413', async () => { diff --git a/backend/tests/helpers.js b/backend/tests/helpers.js index eb20448..5f9e839 100644 --- a/backend/tests/helpers.js +++ b/backend/tests/helpers.js @@ -16,6 +16,8 @@ Object.assign(process.env, { UPLOAD_DIR: uploadDir, MAX_VIDEO_UPLOAD_MB: '5', MAX_RECORDING_UPLOAD_MB: '5', + // Short enough that the "cooldown elapses" test does not have to wait 30 real seconds. + SUBMISSION_COOLDOWN_MS: '200', }) export const webm = (size = 2000) => Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(size, 7)]) @@ -53,7 +55,7 @@ export class Client { export const videoForm = (fields, { video = webm(), poster } = {}) => { const form = new FormData() for (const [key, value] of Object.entries(fields)) form.append(key, value) - if (video) form.append('video', new Blob([video], { type: 'video/webm' }), 'lesson.webm') + if (video) form.append('video', new Blob([video], { type: 'video/webm' }), 'video.webm') if (poster) form.append('poster', new Blob([poster], { type: 'image/jpeg' }), 'poster.jpg') return form } diff --git a/frontend/README.md b/frontend/README.md index fe2d2ca..0d76af1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -12,15 +12,6 @@ npm run lint npm run build ``` -## Demo accounts - -| Role | Email | Password | -| ------- | ------------------- | ------------- | -| Admin | admin@signpak.dev | Admin@123 | -| Learner | maya@example.com | Learner@123 | - -Anyone can also create a learner account on `/signup`. The **Open admin workspace** button on the profile page only appears for admins. - ## Routes | Path | Who | What | @@ -39,7 +30,6 @@ Anyone can also create a learner account on `/signup`. The **Open admin workspac ## Folder layout -``` src/ components/ shared UI (Header, Footer, Field, players, route guards) context/ Auth, Library and Recording state @@ -49,7 +39,6 @@ src/ services/ api.js: the only file that talks to "the server" styles/ all styling; index.css just imports these utils/ formatting, validation, IndexedDB and video helpers -``` ## Styling @@ -78,6 +67,7 @@ Light and dark, both derived from the same analogous palette (deep green → tur ## Fonts Google Fonts, loaded in `index.html`: + - **Barlow Condensed** (600/700) — headlines and big numbers (`.display`) - **Quicksand** (500/600/700) — buttons, labels, nav, anything UI chrome (`font-ui`) - **Inter** (400–700) — body copy, the readable default @@ -85,6 +75,7 @@ Google Fonts, loaded in `index.html`: ## Verifying this yourself This redesign was checked, not just eyeballed: + - **axe-core** ran against all 14 pages/states in both themes: 0 accessibility violations (contrast, labels, roles). - The existing 74-check Playwright journey suite (signup → record → trim → submit → admin) still passes after the restyle. - 26 additional checks cover theme switching (OS-follow, persistence, no-flash-on-load, keyboard operation) and font loading. From 580d078183aab14298e13b92ff72f401bb5ffb05 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 26 Sep 2026 01:16:09 +0500 Subject: [PATCH 04/12] backend-frontend compatibility --- backend/.env.example | 1 + frontend/src/components/BasePlayer.jsx | 2 +- frontend/src/services/api.js | 13 ++++++++++++- frontend/src/services/apiRoutes.js | 11 ++--------- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 143f05f..61caf01 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,6 +32,7 @@ STORAGE_DRIVER=local UPLOAD_DIR=uploads MAX_VIDEO_UPLOAD_MB=300 MAX_RECORDING_UPLOAD_MB=100 +SUBMISSION_COOLDOWN_MS=30000 RATE_LIMIT_ENABLED=true diff --git a/frontend/src/components/BasePlayer.jsx b/frontend/src/components/BasePlayer.jsx index fee0e5c..b368c20 100644 --- a/frontend/src/components/BasePlayer.jsx +++ b/frontend/src/components/BasePlayer.jsx @@ -35,7 +35,7 @@ export default function BasePlayer({ video, videoRef }) { return