diff --git a/frontend/.gitignore b/.gitignore similarity index 78% rename from frontend/.gitignore rename to .gitignore index 2d0413b..72bc7dd 100644 --- a/frontend/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +node_modules/ +.env +uploads/ + # Logs logs *.log @@ -13,8 +17,8 @@ dist-ssr *.local # Generated from src/seo/seoCatalog.mjs during the build. -public/sitemap.xml -public/robots.txt +frontend/public/sitemap.xml +frontend/public/robots.txt react-snap-routes.json # Editor directories and files @@ -28,4 +32,4 @@ react-snap-routes.json *.sln *.sw? -.env +%SystemDrive%/ diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..078d4c0 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,10 @@ +node_modules +dist +coverage +backend +uploads +*.log +.env +.env.* +!.env.example +.vscode diff --git a/backend/.env.example b/backend/.env.example index fdd69a1..61caf01 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 @@ -31,5 +32,9 @@ STORAGE_DRIVER=local UPLOAD_DIR=uploads MAX_VIDEO_UPLOAD_MB=300 MAX_RECORDING_UPLOAD_MB=100 +SUBMISSION_COOLDOWN_MS=30000 RATE_LIMIT_ENABLED=true + +# Minimum gap, in milliseconds, between two submissions for the same (user, video). +SUBMISSION_COOLDOWN_MS=30000 diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index bd6a1b7..0000000 --- a/backend/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -.env -uploads/ diff --git a/backend/README.md b/backend/README.md index 3e4b4cc..b03f66a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,6 +33,8 @@ Dependencies only point down. Controllers never touch models; services never see `src/app.js` builds the app (no port), `server.js` connects and listens, so tests can import `app` directly. +Interactive API documentation is available at `http://localhost:5000/api/v1/docs` when the server is running. The raw OpenAPI document is at `/api/v1/docs.json`. See [docs/API.md](docs/API.md) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the human-readable reference. + ## Responses ```jsonc @@ -50,7 +52,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 +60,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` | +| `GET /videos/:id/file` | anyone (same visibility) | Published reference videos stream with Range support | +| `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 +79,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 +91,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. @@ -97,7 +99,6 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, ## Not built yet - **ffmpeg trimming and probing.** Submissions store `trimStart` / `trimEnd` / `mirrored` but the recording is saved untouched. -- **Frontend hookup.** `frontend/src/services/api.js` still uses the mock. When it switches: add a Vite proxy for `/api` to `http://localhost:5000` (keeps the cookie same-origin), send `credentials: 'include'`, and note that lists now come back as `{ data: [...] }`. ## Housekeeping diff --git a/backend/docs/API.md b/backend/docs/API.md new file mode 100644 index 0000000..2254b33 --- /dev/null +++ b/backend/docs/API.md @@ -0,0 +1,76 @@ +# SignPak Commons API + +The interactive API reference is available while the backend is running: + +- Swagger UI: `http://localhost:5000/api/v1/docs` +- Swagger UI aliases: `http://localhost:5000/docs` or `http://localhost:5000/api-docs` +- OpenAPI JSON: `http://localhost:5000/api/v1/docs.json` +- OpenAPI JSON aliases: `/docs.json` or `/api-docs.json` +- API base URL: `http://localhost:5000/api/v1` + +The OpenAPI document is maintained in [`src/docs/openapi.js`](../src/docs/openapi.js). Keep it synchronized with the route files and validators when changing the API. + +## Authentication + +Signup and login set an HTTP-only `signpak_token` cookie. Browser clients must send requests with credentials enabled. Swagger UI can describe the cookie scheme, but browser authentication is normally established by calling `/auth/signup` or `/auth/login` first. + +The cookie is cleared by `POST /auth/logout`. `GET /auth/session` is public and returns the current user when a valid session exists, or `null` for a visitor. + +## Response format + +JSON success responses use: + +```json +{ "data": { "id": "..." } } +``` + +Errors use a structured object suitable for form-level and field-level messages: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Some fields need attention.", + "fields": { "email": "Enter a valid email address." } + } +} +``` + +`204 No Content` responses have no body. Responses include `X-Request-Id`, which can be supplied by a client and matched against server logs. + +## Endpoint summary + +| Method | Path | Access | Purpose | +| --- | --- | --- | --- | +| GET | `/health` | Public | Check API and database status. | +| POST | `/auth/signup` | Public | Create a contributor account and session. | +| POST | `/auth/login` | Public | Create a session. | +| POST | `/auth/logout` | Public | Clear the session. | +| GET | `/auth/session` | Public | Read the current session. | +| GET/PATCH | `/users/me` | User | Read or update the current profile. | +| GET | `/categories` | Public | List categories. | +| POST/PATCH/DELETE | `/categories`, `/categories/:id` | Admin | Manage categories. | +| GET | `/videos`, `/videos/:id` | Public | List or read visible video metadata. | +| GET | `/videos/:id/file` | Public when published | Stream a published reference video. | +| GET | `/videos/:id/poster` | Public when published | Stream a published poster. | +| POST/PATCH/DELETE | `/videos`, `/videos/:id` | Admin | Upload and manage base videos. | +| GET/POST | `/submissions` | User | List or create contributor recordings. | +| GET | `/submissions/:id/recording` | Admin | Stream a contributor recording. | +| POST | `/contact` | Public | Send a contact message. | +| GET | `/admin/users` | Admin | List users. | +| GET | `/admin/stats` | Admin | Read dashboard statistics. | +| GET | `/admin/messages` | Admin | Read contact messages. | + +## Uploads + +Video creation uses `multipart/form-data` with `video`, optional `poster`, `title`, optional `categoryId`, `status`, and `durationSec`. Submission creation uses `multipart/form-data` with `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration`. + +The server validates file signatures, size limits, metadata, and route IDs. Client-reported durations are currently accepted for trimming metadata; server-side media probing and trimming are future work. + +## Visibility rules + +- Anonymous users see only published videos assigned to a category. +- Admins can list and manage draft, published, and unassigned videos. +- Contributor submissions are private to their owner in `/submissions`. +- Only admins can stream submission recordings. +- Video reference files are public only when the corresponding video passes the same published visibility check. diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f6c82fe --- /dev/null +++ b/backend/docs/ARCHITECTURE.md @@ -0,0 +1,64 @@ +# SignPak Commons Backend Architecture + +## Runtime + +The backend is an Express application running on Node.js 20+ with MongoDB through Mongoose. `server.js` owns process startup, environment validation, database connection, admin bootstrap, and the listening port. `src/app.js` builds and exports the HTTP application so integration tests can exercise the same middleware and routes without opening a port. + +## Request flow + +```text +HTTP request + -> app middleware (logging, security headers, CORS, compression, parsing) + -> API prefix middleware (rate limit, origin verification) + -> route guards (authentication, role checks, validation, upload parsing) + -> controller + -> service + -> repository + -> Mongoose model + -> MongoDB +``` + +The dependency direction is one way: controllers do not query models, services do not read `req` or write `res`, and repositories do not contain business rules. + +## Responsibilities + +| Layer | Location | Responsibility | +| --- | --- | --- | +| Application | `src/app.js` | Compose global middleware, API routes, and error handling. | +| Routes | `src/routes/` | Map HTTP methods and paths to guards and controllers. Swagger UI is exposed at `/api/v1/docs`. | +| Middleware | `src/middlewares/` | Authentication, roles, validation, uploads, rate limits, origin checks, and errors. | +| Controllers | `src/controllers/` | Translate HTTP input to a service call and return the standard response envelope. | +| Services | `src/services/` | Enforce domain rules, coordinate repositories, and manage stored files. | +| Repositories | `src/repositories/` | Encapsulate MongoDB queries and persistence operations. | +| Models | `src/models/` | Define Mongoose schemas, indexes, and public JSON serialization. | +| Storage | `src/services/storage/` | Hide file storage behind save, stat, stream, and remove operations. | +| Configuration | `src/config/` | Validate environment variables, database state, and shared constants. | +| Documentation | `src/docs/`, `docs/` | Maintain the OpenAPI contract and human-readable system documentation. | + +## API boundary + +All application endpoints are mounted below `/api/v1`. Successful JSON responses use `{ "data": value }`; validation and application failures use `{ "error": { ... } }`. Mutating requests with an `Origin` header must come from a configured client origin. Every request receives an `X-Request-Id` for support and log correlation. + +The authentication token is stored in the `httpOnly` `signpak_token` cookie. The server reloads the user from MongoDB on authenticated requests, so role and account changes take effect without waiting for a token refresh. See [API.md](./API.md) for the endpoint contract and `/api/v1/docs` for the interactive OpenAPI reference. + +## Data and media + +MongoDB stores users, categories, videos, submissions, cooldown records, and contact messages. Video and recording bytes are stored through the storage driver; MongoDB stores a driver reference, key, MIME type, and size rather than file contents. + +Published videos assigned to a category are visible to anonymous visitors. Their metadata, posters, and reference files can be read by the public demo and library. Draft or unassigned videos remain restricted to admins. Contributor recordings are never publicly playable; only admins can access a submission recording. + +Uploads are first written to a temporary directory, checked by file signatures, and moved into storage only after validation. Failure cleanup removes temporary and already-stored files where necessary. + +## Security controls + +- Helmet security headers and disabled `x-powered-by`. +- CORS with an explicit client-origin allowlist and credentials enabled. +- SameSite HTTP-only JWT cookies plus origin verification for state-changing requests. +- Global and operation-specific rate limits. +- Zod validation for JSON, multipart fields, route IDs, and query parameters. +- Role checks for admin routes and submission recording playback. +- File type sniffing instead of trusting browser-provided MIME types. + +## Testing and extension points + +`npm test` runs the HTTP integration suite. The storage driver contract is the extension point for adding a remote provider such as Google Drive. The OpenAPI definition in `src/docs/openapi.js` should be updated whenever a public route, request shape, response shape, or security rule changes. diff --git a/backend/package-lock.json b/backend/package-lock.json index 6c88905..2090c83 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "axios": "^1.20.0", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", "compression": "^1.8.2", @@ -24,11 +25,13 @@ "mongoose": "^7.8.12", "morgan": "^1.10.0", "multer": "^2.4.0", + "openapi-client-axios": "^7.9.0", "pg": "^8.13.1", "pg-hstore": "^2.3.4", "pino": "^10.3.1", "pino-http": "^11.0.0", "sequelize": "^6.37.5", + "swagger-ui-express": "^5.0.1", "zod": "^4.6.5" }, "devDependencies": { @@ -55,6 +58,13 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -144,6 +154,13 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0", + "peer": true + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -160,6 +177,12 @@ "tslib": "^2.4.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -169,6 +192,66 @@ "node": ">=8.0.0" } }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/axios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/b4a": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", @@ -297,6 +380,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bath-es5": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bath-es5/-/bath-es5-3.0.3.tgz", + "integrity": "sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==", + "license": "MIT" + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -482,6 +571,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -614,6 +715,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -623,6 +733,12 @@ "node": ">= 0.8" } }, + "node_modules/dereference-json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.3.tgz", + "integrity": "sha512-CBVoEuf2tKhqnUhoKjvX4wo5NVxDSHxInqwExQfUNEK67aX+LPC27Vg+6CApi+KRebABJZAWhHBVoNpKfhMssg==", + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -720,6 +836,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -920,7 +1051,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -937,6 +1067,22 @@ } } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1092,6 +1238,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1273,6 +1434,29 @@ "node": ">=0.12.0" } }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -2061,6 +2245,30 @@ "node": ">= 0.8" } }, + "node_modules/openapi-client-axios": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/openapi-client-axios/-/openapi-client-axios-7.9.0.tgz", + "integrity": "sha512-1VRBbbNQTz6pAWFALXrqr88GclEb+LirqbzmLnFzqOlnCFC2Ao5Gv4JYf783+A8PQbEAu5Or4Rg32RaetllnwA==", + "license": "MIT", + "dependencies": { + "bath-es5": "^3.0.3", + "dereference-json-schema": "^0.2.1", + "openapi-types": "^12.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/anttiviljami" + }, + "peerDependencies": { + "axios": ">=0.25.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2380,6 +2588,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -2847,6 +3064,30 @@ "node": ">=4" } }, + "node_modules/swagger-ui-dist": { + "version": "5.33.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.0.tgz", + "integrity": "sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar-stream": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index fce9f5b..125b713 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "node": ">=20" }, "dependencies": { + "axios": "^1.20.0", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", "compression": "^1.8.2", @@ -30,11 +31,13 @@ "mongoose": "^7.8.12", "morgan": "^1.10.0", "multer": "^2.4.0", + "openapi-client-axios": "^7.9.0", "pg": "^8.13.1", "pg-hstore": "^2.3.4", "pino": "^10.3.1", "pino-http": "^11.0.0", "sequelize": "^6.37.5", + "swagger-ui-express": "^5.0.1", "zod": "^4.6.5" }, "devDependencies": { diff --git a/backend/src/app.js b/backend/src/app.js index 2e22407..9709bbf 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -7,11 +7,13 @@ import { randomUUID } from 'node:crypto' import pinoHttp from 'pino-http' import { API_PREFIX } from './config/constants.js' import { env } from './config/env.js' +import { openapi } from './docs/openapi.js' import { errorHandler, notFoundHandler } from './middlewares/errorHandler.js' import { apiLimiter } from './middlewares/rateLimiter.js' import { verifyOrigin } from './middlewares/verifyOrigin.js' import routes from './routes/index.js' import { logger } from './utils/logger.js' +import swaggerUi from 'swagger-ui-express' const app = express() @@ -33,6 +35,18 @@ app.use(compression()) app.use(express.json({ limit: '100kb' })) app.use(cookieParser()) +app.get('/', (req, res) => res.json({ data: { + name: 'SignPak Commons API', + api: API_PREFIX, + health: `${API_PREFIX}/health`, + docs: '/docs', +} })) +app.get('/favicon.ico', (req, res) => res.status(204).end()) +app.get('/docs.json', (req, res) => res.json(openapi)) +app.get('/api-docs.json', (req, res) => res.json(openapi)) +app.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) + app.use(API_PREFIX, apiLimiter, verifyOrigin, routes) app.use(notFoundHandler) 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/docs/openapi.js b/backend/src/docs/openapi.js new file mode 100644 index 0000000..0da1afd --- /dev/null +++ b/backend/src/docs/openapi.js @@ -0,0 +1,142 @@ +const envelope = (schema = {}) => ({ + type: 'object', + properties: { data: schema }, +}) + +const idParameter = { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', pattern: '^[a-f0-9]{24}$' }, +} + +const errorResponse = { + description: 'Error response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + message: { type: 'string' }, + fields: { type: 'object', additionalProperties: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +} + +const userSecurity = [{ cookieAuth: [] }] +const adminSecurity = userSecurity + +export const openapi = { + openapi: '3.0.3', + info: { + title: 'SignPak Commons API', + version: '1.0.0', + description: 'API for the SignPak Commons Pakistan Sign Language dataset contribution platform.', + }, + servers: [{ url: '/api/v1', description: 'Current API server' }], + tags: [ + { name: 'Health' }, + { name: 'Auth' }, + { name: 'Users' }, + { name: 'Categories' }, + { name: 'Videos' }, + { name: 'Submissions' }, + { name: 'Contact' }, + { name: 'Admin' }, + ], + components: { + securitySchemes: { + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'token', description: 'HTTP-only JWT cookie set by login or signup.' }, + }, + schemas: { + Category: { + type: 'object', + properties: { + id: { type: 'string' }, label: { type: 'string' }, copy: { type: 'string' }, tone: { type: 'string', enum: ['yellow', 'blue', 'red', 'green'] }, + }, + }, + Video: { + type: 'object', + properties: { + id: { type: 'string' }, title: { type: 'string' }, status: { type: 'string', enum: ['draft', 'published'] }, + categoryId: { type: 'string', nullable: true }, order: { type: 'integer' }, durationSec: { type: 'number' }, + videoUrl: { type: 'string' }, poster: { type: 'string', nullable: true }, size: { type: 'integer' }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string' }, firstName: { type: 'string' }, surname: { type: 'string' }, email: { type: 'string', format: 'email' }, + role: { type: 'string', enum: ['user', 'admin'] }, connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, + }, + }, + AuthInput: { + type: 'object', required: ['email', 'password'], + properties: { + firstName: { type: 'string', maxLength: 60 }, surname: { type: 'string', maxLength: 60 }, + email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string' }, + }, + }, + Submission: { + type: 'object', + properties: { id: { type: 'string' }, videoId: { type: 'string' }, submittedAt: { type: 'string', format: 'date-time' }, duration: { type: 'number' }, mirrored: { type: 'boolean' } }, + }, + Error: { type: 'object', properties: { error: { type: 'object' } } }, + }, + }, + paths: { + '/health': { + get: { tags: ['Health'], summary: 'Check API and database health', responses: { 200: { description: 'Healthy', content: { 'application/json': { schema: envelope({ type: 'object' }) } } }, 503: { description: 'Database unavailable' } } }, + }, + '/docs.json': { get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } } }, + '/auth/signup': { + post: { tags: ['Auth'], summary: 'Create an account', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 201: { description: 'Account created' }, 400: errorResponse } }, + }, + '/auth/login': { + post: { tags: ['Auth'], summary: 'Log in and set the HTTP-only cookie', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 200: { description: 'Logged in' }, 401: errorResponse } }, + }, + '/auth/logout': { post: { tags: ['Auth'], summary: 'Clear the session cookie', responses: { 204: { description: 'Logged out' } } } }, + '/auth/session': { get: { tags: ['Auth'], summary: 'Get the current session', responses: { 200: { description: 'Session state' } } } }, + '/users/me': { + get: { tags: ['Users'], summary: 'Get the current user', security: userSecurity, responses: { 200: { description: 'Current user' }, 401: errorResponse } }, + patch: { tags: ['Users'], summary: 'Update profile connections', security: userSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { connections: { type: 'object' } } } } } }, responses: { 200: { description: 'Updated user' }, 400: errorResponse } }, + }, + '/categories': { + get: { tags: ['Categories'], summary: 'List categories', responses: { 200: { description: 'Categories', content: { 'application/json': { schema: envelope({ type: 'array', items: { $ref: '#/components/schemas/Category' } }) } } } } }, + post: { tags: ['Categories'], summary: 'Create a category', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Category' } } } }, responses: { 201: { description: 'Created' }, 403: errorResponse } }, + }, + '/categories/{id}': { + parameters: [idParameter], + patch: { tags: ['Categories'], summary: 'Update a category', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Category' } } } }, responses: { 200: { description: 'Updated' }, 404: errorResponse } }, + delete: { tags: ['Categories'], summary: 'Delete a category', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 404: errorResponse } }, + }, + '/videos': { + get: { tags: ['Videos'], summary: 'List visible videos', description: 'Anonymous callers receive published videos assigned to a category. Admins receive all videos.', responses: { 200: { description: 'Videos', content: { 'application/json': { schema: envelope({ type: 'array', items: { $ref: '#/components/schemas/Video' } }) } } } } }, + post: { tags: ['Videos'], summary: 'Upload a base video', security: adminSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['video', 'title'], properties: { video: { type: 'string', format: 'binary' }, poster: { type: 'string', format: 'binary' }, title: { type: 'string' }, categoryId: { type: 'string' }, status: { type: 'string', enum: ['draft', 'published'] }, durationSec: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, + }, + '/videos/{id}': { + parameters: [idParameter], + get: { tags: ['Videos'], summary: 'Get one visible video', responses: { 200: { description: 'Video' }, 404: errorResponse } }, + patch: { tags: ['Videos'], summary: 'Update video metadata', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Video' } } } }, responses: { 200: { description: 'Updated' }, 404: errorResponse } }, + delete: { tags: ['Videos'], summary: 'Delete a video and its files', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 404: errorResponse } }, + }, + '/videos/{id}/file': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video file', responses: { 200: { description: 'Video stream', content: { 'video/mp4': {} } }, 404: errorResponse } } }, + '/videos/{id}/poster': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video poster', responses: { 200: { description: 'Image stream' }, 404: errorResponse } } }, + '/submissions': { + get: { tags: ['Submissions'], summary: 'List the current user submissions', security: userSecurity, responses: { 200: { description: 'Submissions' }, 401: errorResponse } }, + post: { tags: ['Submissions'], summary: 'Submit a recording', security: userSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['recording', 'videoId'], properties: { recording: { type: 'string', format: 'binary' }, videoId: { type: 'string' }, trimStart: { type: 'number' }, trimEnd: { type: 'number' }, mirrored: { type: 'boolean' }, duration: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, + }, + '/submissions/{id}/recording': { parameters: [idParameter], get: { tags: ['Submissions'], summary: 'Stream a submission recording', security: adminSecurity, responses: { 200: { description: 'Recording stream' }, 403: errorResponse } } }, + '/contact': { post: { tags: ['Contact'], summary: 'Send a contact message', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['name', 'email', 'message'], properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' }, message: { type: 'string' } } } } } }, responses: { 201: { description: 'Message sent' }, 400: errorResponse } } }, + '/admin/users': { get: { tags: ['Admin'], summary: 'List all users', security: adminSecurity, responses: { 200: { description: 'Users' }, 403: errorResponse } } }, + '/admin/stats': { get: { tags: ['Admin'], summary: 'Get dashboard statistics', security: adminSecurity, parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', minimum: 7, maximum: 90, default: 14 } }], responses: { 200: { description: 'Statistics' }, 403: errorResponse } } }, + '/admin/messages': { get: { tags: ['Admin'], summary: 'List contact messages', security: adminSecurity, parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }], responses: { 200: { description: 'Messages' }, 403: errorResponse } } }, + }, +} 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/index.js b/backend/src/routes/index.js index bbdb583..c1b3a00 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -1,5 +1,7 @@ import { Router } from 'express' +import swaggerUi from 'swagger-ui-express' import { isDbReady } from '../config/db.js' +import { openapi } from '../docs/openapi.js' import adminRoutes from './adminRoutes.js' import authRoutes from './authRoutes.js' import categoryRoutes from './categoryRoutes.js' @@ -15,6 +17,9 @@ router.get('/health', (req, res) => { res.status(db ? 200 : 503).json({ data: { status: db ? 'ok' : 'degraded', database: db ? 'connected' : 'disconnected', uptime: Math.round(process.uptime()) } }) }) +router.get('/docs.json', (req, res) => res.json(openapi)) +router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true })) + router.use('/auth', authRoutes) router.use('/users', userRoutes) router.use('/categories', categoryRoutes) diff --git a/backend/src/routes/videoRoutes.js b/backend/src/routes/videoRoutes.js index 11293ef..9d0f6a9 100644 --- a/backend/src/routes/videoRoutes.js +++ b/backend/src/routes/videoRoutes.js @@ -12,12 +12,13 @@ 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) -// The video itself needs a session. +// Published video files are public reference media; unpublished/admin media still requires visibility checks in the service. +router.get('/:id/file', optionalAuthenticate, validate({ params: idParams }), videoController.file) router.get('/:id/file', authenticate, validate({ params: idParams }), videoController.file) // Order matters: log in, check role, then accept the upload, then validate the text fields that came with it. 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..42f3547 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' }) }) @@ -43,6 +43,19 @@ after(async () => { }) describe('basics', () => { + test('root advertises the API and favicon is ignored', async () => { + const origin = new URL(base).origin + const root = await new Client(origin).get('/') + assert.equal(root.status, 200) + assert.equal(root.body.data.api, '/api/v1') + assert.equal(root.body.data.docs, '/docs') + assert.equal((await new Client(origin).get('/favicon.ico')).status, 204) + assert.equal((await new Client(origin).get('/docs')).status, 200) + assert.equal((await new Client(origin).get('/api-docs')).status, 200) + assert.equal((await new Client(origin).get('/docs.json')).body.openapi, '3.0.3') + assert.equal((await new Client(origin).get('/api-docs.json')).body.openapi, '3.0.3') + }) + test('health check reports the database', async () => { const res = await new Client(base).get('/health') assert.equal(res.status, 200) @@ -74,9 +87,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 +106,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 +137,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 +194,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 +229,115 @@ 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 }) + test('published files stream publicly with Range support', async () => { + const full = await new Client(base).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 +350,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 +399,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..efdcd81 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ # SignPakCommons: frontend -React 19 + Vite + Tailwind CSS v4 + React Router. Backend (Express + MongoDB) comes next; until then the app runs on a mock API that stores data in the browser. +React 19 + Vite + Tailwind CSS v4 + React Router. The frontend uses the Express + MongoDB backend through `src/services/api.js`. ## Run it @@ -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,33 +30,29 @@ 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 hooks/ useRecorder (camera + MediaRecorder) - mock/ seed data and placeholder (lorem) copy + config/ production constants used by the UI pages/ one file per screen, admin/ for the workspace 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 No inline utility classes in JSX. Components use semantic class names (`.btn`, `.lesson-card`, `.recorder`...) defined in `src/styles/*.css` with Tailwind's `@apply`. Colours and fonts are tokens in `styles/theme.css`, so a palette change is one file. -## Connecting the backend +## Backend integration -Every call goes through `src/services/api.js`, and every method is async and returns plain JSON. Replace the method bodies with `fetch()` calls and nothing else changes. +Every server call goes through `src/services/api.js`. Requests use the `/api/v1` backend routes, include the HTTP-only session cookie, unwrap the `{ data }` response envelope, and support multipart video and recording uploads. -- **Passwords** are hashed in the mock only. The real API must use a password-specific hash (bcrypt or argon2) and a proper session or JWT. -- **Recordings** are held in memory until submitted. On submit, `submissions.create` currently sends metadata only; the real call should upload the recorded `Blob` plus `trimStart` / `trimEnd` / `mirrored`, and the server does the cut (for example with ffmpeg). The browser does not re-encode. -- **Base videos** uploaded by admins are kept in IndexedDB on that device. The real call should be a multipart upload. -- **Seed videos** all use one public sample clip. Swap real URLs in `mock/data.js`, or just start uploading in the admin. -- **Demo page video**: set `DEMO_VIDEO` in `mock/data.js`, for example `/demo/how-it-works.mp4` placed in `public/demo/`. - -To reset all mock data, clear this site's localStorage and IndexedDB in browser dev tools. +- Set `VITE_API_ORIGIN` when the deployed frontend and backend use different origins. Leave it empty during local development to use the Vite `/api` proxy. +- Backend environment values are documented in `../backend/.env.example`. +- Admin video uploads are stored by the backend; the browser does not keep the base library in IndexedDB. +- Recording submissions upload the recorded `Blob` with trim metadata. The backend enforces the submission cooldown. +- Notifications remain browser-local until a persisted backend notification API is added. ## Theming @@ -78,6 +65,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 +73,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. @@ -96,9 +85,7 @@ These checks used local test scripts, not shipped in this codebase. This is a video **data-collection** tool, not a learning platform: there's no curriculum, no "complete this lesson," nothing to finish. Terminology reflects that throughout — a **category** groups **videos** by topic, and a **contributor** can record any video as many times as they like. -**Recording again — the 30-second cooldown.** A contributor can submit more than one recording for the same video (more takes make better training data), but not back-to-back: after a submission, that video's recorder is replaced with a countdown for `SUBMISSION_COOLDOWN_MS` (30s, in `src/mock/data.js`) before they can record it again. This is enforced twice, the same defence-in-depth pattern as the rest of the mock API: the Player UI hides the recorder while the countdown is running, and `services/api.js`'s `submissions.create` independently re-checks the most recent submission's timestamp and refuses a too-soon request regardless of what the UI showed — verified with two real browser tabs racing to submit the same video (see `e2e` notes below). What never changes: an individual submitted recording still can't be watched, edited or deleted, and only an admin can review one. - -**⚠ Backend mismatch, needs a matching change.** The Express backend (built earlier) still enforces its *old* rule — one submission per (user, video) *ever*, via a unique Mongo index — which directly conflicts with this cooldown model. Before wiring this frontend to that backend, the backend needs: the unique `(user, video)` index dropped, and `submissionService.create` updated to check `Date.now() - lastSubmission.createdAt < COOLDOWN_MS` the same way the mock does. Flagging this now so it isn't a surprise during integration. +**Recording again — the 30-second cooldown.** A contributor can submit more than one recording for the same video, but not back-to-back. The Player UI shows a countdown, and the backend atomically enforces the same cooldown through `SUBMISSION_COOLDOWN_MS`. ## Routing @@ -119,4 +106,4 @@ This is a video **data-collection** tool, not a learning platform: there's no cu ## Notifications -The bell in the header (`components/NotificationBell.jsx`, `context/NotificationProvider.jsx`) is backed by the same per-user `localStorage` pattern as everything else, so it survives a reload. Right now it fires for: a successful submission, and (client-side only, while the tab is open) when a video's cooldown finishes. `api.notifications` in `services/api.js` has `list`, `create`, `markRead` and `markAllRead` if you want to add more triggers later — for instance, once the real backend exists, an admin could get one when a new contributor signs up. +The bell in the header (`components/NotificationBell.jsx`, `context/NotificationProvider.jsx`) is browser-local and survives a reload. Cross-device notification persistence requires a future backend notification API. diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index 020f76c..b60b5d3 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -122,17 +122,11 @@ The frontend is designed around a service boundary rather than direct fetch call The project expects data access to happen through service modules under `src/services/`. -That boundary is important because the app currently contains mock/local behavior while the backend is still being integrated. In practice, the app intentionally centralizes access patterns so that the UI layer does not care whether the source is mock storage, local storage, or a live API. +That boundary keeps the UI independent from transport details while the app uses the live Express API. Browser-local state is limited to preferences and intentionally local notifications. -### Mock-first strategy +### Live API strategy -This frontend is currently built with a mock data model and browser persistence patterns, including: - -- localStorage for session and preferences -- IndexedDB for generated or uploaded local media assets -- in-browser mock API behavior for likes, submissions, and demo data - -This keeps the UX functional without a backend, which is useful for product iteration and UI validation. +Core users, categories, videos, submissions, and contact messages are managed by the backend API. The frontend keeps only theme/preferences and notifications in browser storage. ## Styling Architecture @@ -193,7 +187,7 @@ frontend/ seo/ # metadata and SEO catalog styles/ # theme and shared CSS utils/ # helpers and shared utilities - mock/ # demo data and local-generated state + config/ # production UI constants docs/ architecture.md # this document scripts/ @@ -211,4 +205,4 @@ frontend/ ## Summary -The frontend is a modular React application organized around a clear provider-based state model, route-driven screens, reusable UI shells, and a mock-first data layer. Its architecture is intentionally structured so it can evolve from a browser-only prototype into a real backend-backed app without major changes to the screen composition or route design. +The frontend is a modular React application organized around a clear provider-based state model, route-driven screens, reusable UI shells, and a live backend service boundary. diff --git a/frontend/package.json b/frontend/package.json index f89930d..1611f8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", + "engines": { + "node": ">=20.19.0" + }, "scripts": { "dev": "vite", "prebuild": "npm run seo:generate", 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