diff --git a/docs/API.md b/docs/API.md index e00badef..766ed2b6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,480 +1,736 @@ -# Learnault API Documentation +# Learnault API Reference + +> **Live spec:** `GET /api-docs` (Swagger UI) or `GET /api-docs/swagger.json` ## Overview -The Learnault API provides endpoints for user management, learning modules, rewards, and credential verification. The API follows RESTful principles and returns JSON responses. +The Learnault API is a JSON REST API for a decentralized learn-to-earn platform on Stellar. + +| Item | Value | +|------|-------| +| Base URL (production) | `https://api.learnault.io/api/v1` | +| Base URL (local) | `http://localhost:3000/api/v1` | +| Auth scheme | JWT Bearer (`Authorization: Bearer `) | +| Content-Type | `application/json` | -**Base URL:** `https://api.learnault.io/v1` (production) or `http://localhost:3001/v1` (development) +--- ## Authentication -Most endpoints require authentication using a JWT token. +Obtain a JWT from `POST /auth/login`. Pass it on every protected request: -```txt -Authorization: Bearer +```http +Authorization: Bearer eyJhbGciOiJIUzI1NiIs... ``` -### Get JWT Token +JWT payload contains `{ id, email, role }`. Roles are `learner`, `employer`, `admin`. + +--- + +## Standard Response Envelopes -```txt -POST /v1/auth/login +### Success + +Varies by endpoint — see individual routes. Most use one of: + +```json +{ "message": "...", "data": { ... } } +``` +```json +{ "success": true, "data": { ... } } ``` -**Request:** +### Error (all 4xx / 5xx) ```json { - "email": "user@example.com", - "password": "securepassword" + "success": false, + "error": { + "message": "Resource not found", + "code": 404 + } } ``` -**Response:** +Validation errors from the `validate()` middleware: ```json { - "status": "success", - "token": "eyJhbGciOiJIUzI1NiIs...", - "data": { - "id": "usr_123", - "email": "user@example.com", - "walletAddress": "GABC...123" + "message": "Validation failed", + "errors": { + "body": ["Invalid email format"], + "params": ["Invalid ID format"] } } ``` -## Endpoints +--- -### Users +## Rate Limiting -#### Get Current User +Every response includes: -```txt -GET /v1/users/me +```http +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 97 +X-RateLimit-Reset: 2026-07-19T10:15:00.000Z ``` -**Response:** +When exceeded (HTTP 429): + +```http +Retry-After: 60 +``` + +| Limiter | Applies to | Default window | Default max | +|---------|-----------|----------------|-------------| +| `authLimiter` | `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password` | 15 min | 10 | +| `employerLimiter` | All `/employer/*` routes | 15 min | 500 | +| `authenticatedLimiter` | All `/sync/*` routes | 15 min | 1000 | +| `generalLimiter` | Everything else | 15 min | 100 | + +All values are overridable via environment variables (`RATE_LIMIT_*_WINDOW_MS`, `RATE_LIMIT_*_MAX`). + +--- + +## Health + +### `GET /health` + +No authentication. Returns immediately. + +```json +{ "status": "ok", "timestamp": "2026-07-19T10:00:00.000Z" } +``` + +--- + +## Auth — `/auth` + +All auth routes are public (no JWT required) unless noted. + +### `POST /auth/register` + +Register a new user. Queues a verification email. + +**Request body** + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `email` | string (email) | ✅ | | +| `password` | string | ✅ | min 8 chars | +| `username` | string | ✅ | min 3 chars | +| `role` | `"learner"` \| `"employer"` | ❌ | default `"learner"` | + +**Responses** + +| Status | Meaning | +|--------|---------| +| 201 | User created; JWT and user object returned | +| 400 | Validation failed | +| 409 | Email or username already taken | ```json { - "status": "success", - "data": { - "id": "usr_123", - "email": "user@example.com", - "name": "John Doe", - "walletAddress": "GABC...123", - "createdAt": "2024-01-01T00:00:00Z", - "stats": { - "modulesCompleted": 15, - "totalEarned": "25.50", - "currentStreak": 7 - } - } + "message": "User registered successfully", + "token": "", + "user": { "id": "...", "email": "...", "username": "...", "role": "learner" } } ``` -#### Update User Profile +--- -```txt -PATCH /v1/users/me -``` +### `POST /auth/login` + +Rate-limited (10 req / 15 min). + +**Request body:** `{ "email": "...", "password": "..." }` + +**Responses:** 200 (same shape as register), 400, 401 (invalid credentials) + +--- + +### `POST /auth/logout` + +Stateless — no session is stored server-side. Returns a reminder to clear the token client-side. + +**Response:** `200 { "message": "Logged out successfully. Please clear your token client-side." }` + +--- + +### `POST /auth/verify-email` + +**Request body:** `{ "token": "<64-char hex string from email>" }` + +| Status | Meaning | +|--------|---------| +| 200 | Verified (or already verified) | +| 400 | Invalid, expired, or revoked token | + +--- + +### `POST /auth/resend-verification` + +Rate-limited (10 req / 15 min). Always returns 200 to avoid leaking whether an account exists. + +**Request body:** `{ "email": "..." }` + +**Response:** `200 { "message": "If the account exists, a verification email has been sent." }` -**Request:** +--- + +### `POST /auth/forgot-password` + +Rate-limited (10 req / 15 min). Token expires after 30 minutes. Always returns 200. + +**Request body:** `{ "email": "..." }` + +**Response:** `200 { "message": "If the account exists, a password reset email has been sent." }` + +--- + +### `POST /auth/reset-password` + +On success, all active sessions are revoked and all pending verification tokens are cancelled. + +**Request body:** `{ "token": "<64-char hex>", "newPassword": "" }` + +| Status | Meaning | +|--------|---------| +| 200 | Password reset | +| 400 | Invalid/expired token or weak password | + +--- + +## Users — `/users` + +### `GET /users/me` 🔒 + +Returns the authenticated user's full profile. ```json { - "name": "John Updated", - "preferences": { - "language": "fr", - "notifications": true - } + "id": "uuid", "email": "...", "username": "...", + "firstName": null, "lastName": null, + "bio": null, "avatar": null, "walletAddress": null, + "isActive": true, "createdAt": "...", "updatedAt": "..." } ``` -### Learning Modules +--- + +### `PATCH /users/me` 🔒 + +Update profile fields. All fields optional. + +**Request body** (any subset of): + +| Field | Type | Constraints | +|-------|------|-------------| +| `username` | string | 3–30 chars, alphanumeric + underscore | +| `firstName` | string | max 50 | +| `lastName` | string | max 50 | +| `bio` | string | max 500 | +| `avatar` | string (URL) | | + +**Response:** `200` full user object (same as `GET /users/me`) + +--- -#### List Modules +### `GET /users/:id` -```txt -GET /v1/modules?category=finance&page=1&limit=20 +Public — no auth needed. Returns a reduced public profile. + +```json +{ "id": "...", "username": "...", "firstName": null, "lastName": null, "avatar": null, "role": "learner", "createdAt": "..." } ``` -**Query Parameters:** +--- + +### `PATCH /users/password` 🔒 + +> ⚠️ **Preview** — the service implementation is stubbed. Will return `500` until completed. + +**Request body:** `{ "currentPassword": "...", "newPassword": "..." }` + +Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (`@$!%*?&`). Must differ from current. -- `category` - Filter by category -- `difficulty` - beginner, intermediate, advanced -- `language` - en, fr, es, etc. -- `page` - Page number -- `limit` - Items per page +--- -**Response:** +### `PATCH /users/wallet` 🔒 + +> ⚠️ **Preview** — the wallet update may not persist to the database until the service layer is completed. + +**Request body:** `{ "walletAddress": "G..." }` + +Address must match `^G[A-Z0-9]{55}$`. + +--- + +## Modules — `/modules` + +### `GET /modules` + +Optional auth — when a valid token is provided, each module includes `userProgress`. + +**Query parameters** + +| Param | Type | Default | +|-------|------|---------| +| `page` | integer | 1 | +| `limit` | integer | 10 | +| `category` | string | — | +| `difficulty` | string | — | +| `search` | string | — | + +**Response `200`** ```json { - "status": "success", - "data": [ + "modules": [ { - "id": "mod_456", - "title": "Understanding Stablecoins", - "description": "Learn how stablecoins work", - "category": "finance", - "difficulty": "beginner", - "duration": 15, - "reward": "0.25", - "language": "en", - "completions": 1243, - "thumbnail": "https://cdn.learnault.io/modules/stablecoins.jpg" + "id": "...", "title": "...", "description": "...", + "category": "finance", "difficulty": "beginner", + "reward": 0.25, "createdAt": "...", "updatedAt": "...", + "completionCount": 120, + "userProgress": null } ], - "pagination": { - "page": 1, - "limit": 20, - "total": 45 - } + "pagination": { "page": 1, "limit": 10, "total": 45, "totalPages": 5, "hasNext": true, "hasPrev": false } } ``` -#### Get Module Details +--- + +### `GET /modules/:id` + +Optional auth. Same `userProgress` inclusion behaviour. -```txt -GET /v1/modules/:moduleId +**Response `200`:** single module object (same fields as list item). +**404** if not found. + +--- + +### `POST /modules/:id/start` 🔒 + +Creates a progress record. Must be called before `complete`. + +**Response `201`:** +```json +{ "message": "Module started successfully", "completionId": "...", "startedAt": "..." } ``` -**Response:** +**400** if already started or completed. + +--- + +### `POST /modules/:id/complete` 🔒 +Submit quiz answers. Module must have been started first. + +A score ≥ 70% qualifies for the XLM reward and triggers a push notification. + +**Request body:** ```json { - "status": "success", - "data": { - "id": "mod_456", - "title": "Understanding Stablecoins", - "description": "Learn how stablecoins work", - "content": [ - { - "type": "text", - "data": "Stablecoins are cryptocurrencies designed to maintain a stable value..." - }, - { - "type": "image", - "url": "https://cdn.learnault.io/content/stablecoin-diagram.jpg" - }, - { - "type": "quiz", - "questions": [ - { - "id": "q1", - "question": "What is a stablecoin?", - "options": [ - "A volatile cryptocurrency", - "A cryptocurrency with stable value", - "A type of stock", - "A government bond" - ], - "correctOption": 1 - } - ] - } - ], - "reward": "0.25", - "prerequisites": [] - } + "quizAnswers": [ + { "questionId": "q1", "answer": "B" } + ] } ``` -#### Submit Module Completion - -```txt -POST /v1/modules/:moduleId/complete +**Response `200`:** +```json +{ + "message": "Module completed successfully", + "score": 80, + "isEligibleForReward": true, + "reward": 0.25, + "rewardTransaction": "", + "completedAt": "..." +} ``` -**Request:** +--- + +## Credentials — `/credentials` + +### `GET /credentials` 🔒 + +**Query parameters** + +| Param | Type | Notes | +|-------|------|-------| +| `moduleId` | UUID | filter | +| `fromDate` | ISO datetime | filter | +| `toDate` | ISO datetime | filter | +| `page` | integer | default 1 | +| `limit` | integer | default 10, max 100 | +**Response `200`:** ```json { - "answers": [ - { - "questionId": "q1", - "selectedOption": 1 - } - ], - "timeSpent": 320 + "success": true, + "data": [ { "id": "...", "moduleId": "...", "moduleName": "...", "onChainId": null, "issuedAt": "...", "shareableLink": "..." } ], + "meta": { "page": 1, "limit": 10, "total": 5, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false } } ``` -**Response:** +--- + +### `GET /credentials/verify/:onChainId` +Public — no auth needed. Looks up by `onChainId` first, falls back to credential UUID. + +**Response `200`:** ```json { - "status": "success", + "success": true, "data": { - "passed": true, - "score": 100, - "reward": { - "amount": "0.25", - "asset": "USDC", - "transactionHash": "a1b2c3...", - "status": "completed" - }, - "credential": { - "id": "cred_789", - "onChainId": "0x123...", - "issuedAt": "2024-01-15T10:30:00Z" - } + "valid": true, + "credential": { "id": "...", "holderName": "...", "moduleName": "...", "onChainId": "...", "issuedAt": "..." }, + "verification": { "verifiedAt": "...", "status": "verified", "message": "This credential is valid and has been verified on-chain" } } } ``` -### Rewards & Wallet +**404** if not found. -#### Get Wallet Balance +--- -```txt -GET /v1/rewards/balance -``` +### `GET /credentials/:id` 🔒 + +Returns full credential detail. Returns `401` if the credential belongs to another user. + +--- + +## Rewards — `/rewards` 🔒 -**Response:** +All reward routes require authentication. + +### `GET /rewards/balance` ```json { - "status": "success", - "data": [ - { - "asset": "USDC", - "amount": "45.75", - "valueInUSD": "45.75" - }, - { - "asset": "XLM", - "amount": "125.50", - "valueInUSD": "12.55" - } - ], - "totalValueUSD": "58.30" + "success": true, + "data": { + "balance": { "available": 10.5, "pending": 2.0, "lifetime": 25.0 }, + "updatedAt": "..." + } } ``` -#### Get Reward History +--- -```txt -GET /v1/rewards/history?page=1&limit=20 -``` +### `GET /rewards/history` + +**Query parameters** -**Response:** +| Param | Values | Default | +|-------|--------|---------| +| `type` | `module_reward`, `streak_bonus`, `referral_reward`, `withdrawal` | — | +| `status` | `pending`, `completed`, `failed` | — | +| `fromDate` | ISO datetime | — | +| `toDate` | ISO datetime | — | +| `limit` | 1–100 | 20 | +| `offset` | ≥ 0 | 0 | +**Response `200`:** ```json { - "status": "success", - "data": [ - { - "id": "tx_abc", - "type": "module_reward", - "amount": "0.25", - "asset": "USDC", - "moduleId": "mod_456", - "moduleTitle": "Understanding Stablecoins", - "timestamp": "2024-01-15T10:30:00Z", - "transactionHash": "a1b2c3..." - }, - { - "id": "tx_def", - "type": "referral_bonus", - "amount": "0.50", - "asset": "USDC", - "referralEmail": "friend@example.com", - "timestamp": "2024-01-14T14:20:00Z", - "transactionHash": "d4e5f6..." - } - ], - "pagination": { - "page": 1, - "limit": 20, - "total": 47 + "success": true, + "data": { + "transactions": [ { "id": "...", "type": "module_reward", "status": "completed", "amount": 0.25, "moduleId": "...", "stellarTxHash": null, "createdAt": "...", "completedAt": "..." } ], + "pagination": { "total": 15, "limit": 20, "offset": 0, "hasMore": false } } } ``` -#### Withdraw Funds +--- -```txt -POST /v1/rewards/withdraw -``` +### `POST /rewards/withdraw` + +**Request body:** -**Request:** +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `walletAddress` | string | ✅ | Stellar address matching `^G[A-Z0-9]{50,55}$` | +| `amount` | number | ✅ | XLM, must be > 0 | +| `memo` | string | ❌ | | +**Response `201`:** ```json { - "amount": "25.00", - "asset": "USDC", - "destination": "GA...", // Stellar address or mobile money identifier - "method": "stellar" // or "mobile_money" + "success": true, + "message": "Withdrawal processed successfully", + "data": { "transactionId": "...", "amount": 5.0, "stellarTxHash": "...", "status": "completed", "requestedAt": "...", "completedAt": "..." } } ``` -**Response:** +**400** for invalid address, zero/negative amount, or insufficient balance. + +--- + +## Referrals — `/referrals` 🔒 + +All referral routes require authentication. + +### `POST /referrals/code` + +Generate (or retrieve existing) referral code for the authenticated user. + +- Returns `200` if the user already has a code. +- Returns `201` if a new 8-character hex code was created. + +```json +{ "success": true, "message": "...", "data": { "code": "A1B2C3D4" } } +``` + +--- + +### `POST /referrals/apply` + +Apply a referral code. Cannot apply your own code or apply more than once. + +**Request body:** `{ "code": "A1B2C3D4" }` + +| Status | Meaning | +|--------|---------| +| 201 | Referral applied | +| 400 | Missing code, self-referral, or code not found | +| 409 | Already used a referral code | + +--- + +### `GET /referrals/stats` ```json { - "status": "success", + "success": true, "data": { - "withdrawalId": "wd_123", - "amount": "25.00", - "asset": "USDC", - "fee": "0.01", - "netAmount": "24.99", - "status": "processing", - "estimatedCompletion": "2024-01-15T12:30:00Z" + "totalReferrals": 3, + "activeReferrals": 2, + "earnedBonuses": 10.0, + "pendingBonuses": 5.0 } } ``` -### Credentials +`activeReferrals` = referrees who have completed at least one module. +`pendingBonuses` = (total − paid) × 5 XLM per referral. + +--- + +## Notifications — `/notifications` 🔒 + +All notification routes require authentication. -#### Get User Credentials +### `POST /notifications/devices` -```txt -GET /v1/credentials +Register a Firebase device token for push notifications. + +**Request body:** + +| Field | Type | Required | +|-------|------|----------| +| `token` | string | ✅ | +| `platform` | `"ios"` \| `"android"` \| `"web"` | ✅ | + +**Response `201`:** device token record. + +--- + +### `PATCH /notifications/preferences` + +At least one field must be provided. + +**Request body** (any subset of): + +| Field | Type | +|-------|------| +| `rewardReceipt` | boolean | +| `quizPassFail` | boolean | +| `streakReminders` | boolean | + +--- + +### `GET /notifications/delivery-status` + +**Query parameters:** `limit` (default 20, max 100), `status` (`pending` \| `success` \| `failed` \| `dead-letter`). + +```json +{ + "data": [ { "id": "...", "type": "quizPassFail", "title": "Quiz Passed!", "body": "...", "status": "success", "error": null, "attemptCount": 1, "createdAt": "..." } ], + "count": 1 +} ``` -**Response:** +--- + +## Sync — `/sync` 🔒 + +All sync routes require authentication and are subject to the `authenticatedLimiter` (1000 req / 15 min). +### `POST /sync/progress` + +Upload batched offline progress events. Each event is deduplicated by `idempotencyKey`. Events with a stale `syncVersion` are skipped without error. + +**Request body:** ```json { - "status": "success", - "data": [ + "events": [ { - "id": "cred_789", - "moduleId": "mod_456", - "moduleTitle": "Understanding Stablecoins", - "issuedAt": "2024-01-15T10:30:00Z", - "onChainId": "0x123...", - "verifiableUrl": "https://verify.learnault.io/cred_789" + "idempotencyKey": "device-abc-mod-xyz-1", + "deviceId": "device-abc", + "moduleId": "", + "progressPercent": 60, + "clientTimestamp": "2026-07-19T09:00:00.000Z", + "syncVersion": 3 } ] } ``` -#### Verify Credential - -```txt -GET /v1/credentials/verify/:onChainId -``` - -**Response:** - +**Response `200`:** ```json { - "status": "success", + "success": true, "data": { - "valid": true, - "credential": { - "userId": "usr_123", - "userName": "John Doe", - "moduleId": "mod_456", - "moduleTitle": "Understanding Stablecoins", - "issuedAt": "2024-01-15T10:30:00Z", - "issuer": "Learnault" - } + "results": [ + { "idempotencyKey": "device-abc-mod-xyz-1", "status": "applied" } + ] } } ``` -### Employer Endpoints (B2B) - -#### Search Talent +Each result has `status`: `applied` | `skipped` | `rejected`, plus an optional `reason`. -```txt -GET /v1/employer/search?skills=finance,defi&location=kenya -``` +--- -**Authentication:** Requires employer API key +### `POST /sync/completions` -**Response:** +Reconcile offline quiz/completion attempts. If the user already has a completion with an equal or higher score, the event is skipped. A higher score updates the existing record. +**Request body:** ```json { - "status": "success", - "data": [ + "events": [ { - "userId": "usr_123", - "anonymousId": "anon_456", // For privacy until contact - "skills": [ - { - "name": "Financial Literacy", - "level": "advanced", - "modules": 12, - "verified": true - } - ], - "matchScore": 95, - "availableForHire": true + "idempotencyKey": "device-abc-comp-xyz-1", + "deviceId": "device-abc", + "moduleId": "", + "score": 85, + "clientTimestamp": "2026-07-19T09:05:00.000Z", + "syncVersion": 1 } - ], - "pagination": { - "page": 1, - "limit": 20, - "total": 47 - } + ] } ``` -## Error Handling +Response shape identical to `POST /sync/progress`. + +--- + +## Employer — `/employer` 🔒 (employer role required) + +All employer routes require authentication with `role: employer`. An `employerLimiter` (500 req / 15 min) is applied. -The API uses conventional HTTP response codes: +Plan tier is read from the `x-employer-plan` request header. Valid values: `starter` (default), `pro`, `enterprise`. -- `200` - Success -- `201` - Created -- `400` - Bad request -- `401` - Unauthorized -- `403` - Forbidden -- `404` - Not found -- `429` - Too many requests -- `500` - Internal server error +### `GET /employer/search` -Error response format: +Search the learner talent pool. +**Query parameters** + +| Param | Type | Default | Notes | +|-------|------|---------|-------| +| `page` | integer | 1 | | +| `limit` | integer | 20 | Capped by plan: starter ≤ 10, pro ≤ 50, enterprise ≤ 100 | +| `skills` | string | — | Comma-separated keywords, e.g. `finance,defi` | +| `location` | string | — | | +| `credentials` | `any` \| `verified` \| `none` | `any` | | +| `search` | string | — | Free-text search on username/email | + +**Request header:** `x-employer-plan: starter | pro | enterprise` + +**Response `200`:** ```json { - "status": "error", - "error": { - "code": "RESOURCE_NOT_FOUND", - "message": "The requested module was not found", - "details": { - "moduleId": "mod_invalid" + "candidates": [ + { + "id": "...", "name": "alice42", "location": "lagos", + "skills": ["finance", "defi"], + "completions": 5, "averageScore": 82.4, + "verifiedCredentialCount": 2 } - } + ], + "pagination": { "page": 1, "limit": 10, "total": 3, "totalPages": 1, "hasNext": false, "hasPrev": false }, + "filters": { "skills": ["finance"], "location": null, "credentials": "any" }, + "plan": "starter" } ``` -## Rate Limiting +**400** if `limit` exceeds plan maximum. + +--- + +### `GET /employer/candidates/:id` + +Full candidate profile with all verified credentials. + +**403** if candidate profile is private or caller is not an employer. +**404** if candidate not found or has no module completions. + +--- -- Public endpoints: 60 requests per minute -- Authenticated endpoints: 120 requests per minute -- Employer endpoints: Based on subscription tier +### `POST /employer/contact` -Rate limit headers: +Record a candidate outreach attempt. Requires **pro** or **enterprise** plan — **starter returns HTTP 402**. -```txt -X-RateLimit-Limit: 60 -X-RateLimit-Remaining: 58 -X-RateLimit-Reset: 1627583492 +**Request header:** `x-employer-plan: pro` (or `enterprise`) + +**Request body:** + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `candidateId` | UUID | ✅ | | +| `subject` | string | ✅ | 3–120 chars | +| `message` | string | ✅ | 10–3000 chars | +| `channel` | `"platform"` \| `"email"` \| `"both"` | ❌ | default `"platform"` | + +**Response `201`:** +```json +{ + "message": "Candidate outreach recorded", + "outreach": { "id": "...", "candidateId": "...", "channel": "platform", "status": "recorded", "createdAt": "..." } +} ``` -## Webhooks +--- -You can register webhooks to receive real-time events: +## Unimplemented / Stubbed Routes -- `user.completed_module` -- `reward.issued` -- `credential.verified` +The following routes are wired but not fully implemented: -See [Webhook Documentation](./WEBHOOKS.md) for details. +| Route | Status | +|-------|--------| +| `PATCH /users/password` | Service method throws "Not implemented" — returns 500 | +| `PATCH /users/wallet` | Service method uses mock data — changes do not persist | -## SDKs +These are marked as **Preview** in the OpenAPI spec (`/api-docs`). -## Support +--- -For API support, please: +## Error Code Reference -- Check our [API status page](https://status.learnault.io) -- Join our [Discord](https://discord.gg) #api channel -- Email: learnault@toneflix.net +| HTTP | Typical cause | +|------|--------------| +| 400 | Validation failed, malformed body, business rule violation | +| 401 | Missing, expired, or invalid JWT | +| 402 | Employer plan upgrade required | +| 403 | Authenticated but insufficient role or resource is private | +| 404 | Resource not found | +| 409 | Conflict (duplicate email, already applied referral, etc.) | +| 429 | Rate limit exceeded — see `Retry-After` header | +| 500 | Internal server error (includes not-yet-implemented stubs) | diff --git a/src/app.ts b/src/app.ts index 09098da5..5ac50661 100644 --- a/src/app.ts +++ b/src/app.ts @@ -27,6 +27,30 @@ app.use('/api', routes) app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)) // Health check endpoint +/** + * @openapi + * /health: + * get: + * operationId: healthCheck + * summary: Service health check + * description: Returns HTTP 200 when the server is running. No authentication required. + * tags: [Health] + * security: [] + * responses: + * 200: + * description: Service is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * timestamp: + * type: string + * format: date-time + */ app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }) }) diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 48b6b1bd..7e6d1db2 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -1,23 +1,62 @@ import swaggerJsdoc from 'swagger-jsdoc' - const options: swaggerJsdoc.Options = { definition: { openapi: '3.0.0', info: { - title: 'Learnault API Documentation', + title: 'Learnault API', version: '1.0.0', - description: 'Comprehensive API documentation for Learnault - a decentralized learn-to-earn platform on Stellar', + description: [ + 'REST API for Learnault — a decentralized learn-to-earn platform on Stellar.', + '', + '**Base path for all v1 routes:** `/api/v1`', + '', + '**Authentication:** Most routes require a Bearer JWT obtained from `POST /api/v1/auth/login`.', + 'Pass it as `Authorization: Bearer `.', + '', + '**Rate-limiting headers** are returned on every response:', + '`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`.', + 'A `Retry-After` header is added when the limit is exceeded (HTTP 429).', + '', + '**Error envelope** (all 4xx/5xx responses):', + '```json', + '{ "success": false, "error": { "message": "...", "code": 500 } }', + '```', + '', + '> ⚠️ **Preview / not-yet-implemented routes** — `PATCH /api/v1/users/password` and', + '> `PATCH /api/v1/users/wallet` are wired but their underlying service methods are stubs.', + '> They will return errors in production until the service layer is completed.', + ].join('\n'), contact: { name: 'Learnault Contributors', url: 'https://github.com/learnault/learnault', + email: 'learnault@toneflix.net', + }, + license: { + name: 'MIT', }, }, servers: [ { - url: '/api', - description: 'Main API base path', + url: '/api/v1', + description: 'Current version (v1)', }, + { + url: 'http://localhost:3000/api/v1', + description: 'Local development', + }, + ], + tags: [ + { name: 'Health', description: 'Service health check' }, + { name: 'Auth', description: 'Registration, login, email verification, password reset' }, + { name: 'Users', description: 'User profile management' }, + { name: 'Modules', description: 'Learning module catalogue and progress tracking' }, + { name: 'Credentials', description: 'On-chain verifiable credentials' }, + { name: 'Rewards', description: 'XLM balance, transaction history, and withdrawals' }, + { name: 'Referrals', description: 'Referral code generation and bonus tracking' }, + { name: 'Notifications', description: 'Push-notification device tokens and preferences' }, + { name: 'Sync', description: 'Offline progress and completion reconciliation' }, + { name: 'Employer', description: 'B2B talent search and candidate outreach (employer role required)' }, ], components: { securitySchemes: { @@ -25,17 +64,21 @@ const options: swaggerJsdoc.Options = { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', + description: 'JWT obtained from POST /auth/login', }, }, + // All component schemas are defined via JSDoc in src/docs/schemas.ts }, - security: [ - { - bearerAuth: [], - }, - ], + // Global security — individual operations that are public override this with security: [] + security: [{ bearerAuth: [] }], }, - apis: ['./src/controllers/**/*.ts', './src/docs/*.ts'], // Path to the API docs + // Scan controllers (operations) and the dedicated schema file + apis: [ + './src/controllers/**/*.ts', + './src/routes/**/*.ts', + './src/docs/*.ts', + './src/app.ts', + ], } export const specs = swaggerJsdoc(options) - diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 1e8dadbf..9c3a6962 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -81,8 +81,10 @@ export class AuthController { * @openapi * /auth/register: * post: + * operationId: authRegister * summary: Register a new user * tags: [Auth] + * security: [] * requestBody: * required: true * content: @@ -91,17 +93,29 @@ export class AuthController { * $ref: '#/components/schemas/RegisterInput' * responses: * 201: - * description: User registered successfully + * description: User registered successfully; a verification email is queued. * content: * application/json: * schema: * $ref: '#/components/schemas/AuthResponse' * 400: * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 409: - * description: User already exists + * description: Email or username already taken + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async register(req: Request, res: Response): Promise { try { @@ -184,8 +198,10 @@ export class AuthController { * @openapi * /auth/verify-email: * post: + * operationId: authVerifyEmail * summary: Verify email address with a token * tags: [Auth] + * security: [] * requestBody: * required: true * content: @@ -194,13 +210,19 @@ export class AuthController { * $ref: '#/components/schemas/VerifyEmailInput' * responses: * 200: - * description: Email verified successfully + * description: Email verified (or already verified) * 400: - * description: Invalid or malformed token - * 410: - * description: Token expired or already used + * description: Invalid or expired token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async verifyEmail(req: Request, res: Response): Promise { try { @@ -280,8 +302,13 @@ export class AuthController { * @openapi * /auth/resend-verification: * post: + * operationId: authResendVerification * summary: Resend verification email + * description: > + * Always returns 200 with a neutral message to avoid leaking user existence. + * Rate-limited by IP (1 req/min) and per account (5 req/24 h). * tags: [Auth] + * security: [] * requestBody: * required: true * content: @@ -290,11 +317,19 @@ export class AuthController { * $ref: '#/components/schemas/ResendVerificationInput' * responses: * 200: - * description: If the account exists, a verification email will be sent + * description: If the account exists, a verification email will be sent. * 429: - * description: Too many requests + * description: Too many requests — IP or account rate limit reached. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async resendVerification(req: Request, res: Response): Promise { try { @@ -386,8 +421,11 @@ export class AuthController { * @openapi * /auth/login: * post: - * summary: Login a user + * operationId: authLogin + * summary: Log in and receive a JWT + * description: Rate-limited to 10 requests per 15 minutes per IP. * tags: [Auth] + * security: [] * requestBody: * required: true * content: @@ -403,10 +441,22 @@ export class AuthController { * $ref: '#/components/schemas/AuthResponse' * 400: * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Invalid credentials + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async login(req: Request, res: Response): Promise { try { @@ -466,11 +516,16 @@ export class AuthController { * @openapi * /auth/logout: * post: - * summary: Logout user + * operationId: authLogout + * summary: Log out (stateless — client must discard the token) + * description: > + * The server has no session state; this endpoint simply returns a + * reminder to clear the token client-side. * tags: [Auth] + * security: [] * responses: * 200: - * description: Logged out successfully + * description: Logged out successfully. */ async logout(req: Request, res: Response): Promise { res.status(200).json({ message: 'Logged out successfully. Please clear your token client-side.' }) @@ -480,8 +535,13 @@ export class AuthController { * @openapi * /auth/forgot-password: * post: - * summary: Request a password reset email + * operationId: authForgotPassword + * summary: Request a password-reset email + * description: > + * Always returns 200 to avoid leaking user existence. + * Token expires in 30 minutes. Rate-limited by IP and per account. * tags: [Auth] + * security: [] * requestBody: * required: true * content: @@ -490,11 +550,19 @@ export class AuthController { * $ref: '#/components/schemas/ForgotPasswordInput' * responses: * 200: - * description: If the account exists, a password reset email will be sent + * description: If the account exists, a password reset email will be sent. * 429: - * description: Too many requests + * description: Too many requests. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async forgotPassword(req: Request, res: Response): Promise { try { @@ -572,8 +640,13 @@ export class AuthController { * @openapi * /auth/reset-password: * post: - * summary: Reset password with a token + * operationId: authResetPassword + * summary: Reset password using a token from the reset email + * description: > + * On success, all existing sessions are revoked and all pending + * verification tokens are cancelled. * tags: [Auth] + * security: [] * requestBody: * required: true * content: @@ -582,11 +655,19 @@ export class AuthController { * $ref: '#/components/schemas/ResetPasswordInput' * responses: * 200: - * description: Password reset successful + * description: Password reset successful. * 400: - * description: Invalid token or password + * description: Invalid, expired, or already-used token; or weak password. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async resetPassword(req: Request, res: Response): Promise { try { diff --git a/src/controllers/credential.controller.ts b/src/controllers/credential.controller.ts index 34394d22..2c3f914e 100644 --- a/src/controllers/credential.controller.ts +++ b/src/controllers/credential.controller.ts @@ -8,7 +8,8 @@ export class CredentialController { * @openapi * /credentials: * get: - * summary: Retrieve user credentials + * operationId: credentialsList + * summary: List credentials for the authenticated user * tags: [Credentials] * security: * - bearerAuth: [] @@ -17,16 +18,17 @@ export class CredentialController { * name: moduleId * schema: * type: string + * format: uuid * - in: query * name: fromDate * schema: * type: string - * format: date + * format: date-time * - in: query * name: toDate * schema: * type: string - * format: date + * format: date-time * - in: query * name: page * schema: @@ -37,6 +39,7 @@ export class CredentialController { * schema: * type: integer * default: 10 + * maximum: 100 * responses: * 200: * description: Credentials retrieved successfully @@ -46,6 +49,10 @@ export class CredentialController { * $ref: '#/components/schemas/CredentialList' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ getUserCredentials = asyncHandler( async (req: Request, res: Response): Promise => { @@ -141,7 +148,8 @@ export class CredentialController { * @openapi * /credentials/{id}: * get: - * summary: Get credential by ID + * operationId: credentialsGetById + * summary: Get a single credential by ID (must be owned by caller) * tags: [Credentials] * security: * - bearerAuth: [] @@ -151,17 +159,31 @@ export class CredentialController { * required: true * schema: * type: string + * format: uuid * responses: * 200: - * description: Credential details retrieved successfully + * description: Credential details * content: * application/json: * schema: - * $ref: '#/components/schemas/Credential' + * type: object + * properties: + * success: + * type: boolean + * data: + * $ref: '#/components/schemas/Credential' * 401: - * description: Unauthorized + * description: Unauthorized or credential belongs to another user + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 404: * description: Credential not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ getCredentialById = asyncHandler( async (req: Request, res: Response): Promise => { @@ -231,23 +253,33 @@ export class CredentialController { * @openapi * /credentials/verify/{onChainId}: * get: - * summary: Verify a credential + * operationId: credentialsVerify + * summary: Publicly verify a credential by on-chain ID (or credential ID) + * description: > + * No authentication required. Looks up by `onChainId` first; falls back to + * the credential's primary `id` if no on-chain record is found. * tags: [Credentials] + * security: [] * parameters: * - in: path * name: onChainId * required: true * schema: * type: string + * description: On-chain credential ID or the credential's database UUID. * responses: * 200: - * description: Credential verification status + * description: Credential verification result * content: * application/json: * schema: * $ref: '#/components/schemas/VerificationResponse' * 404: - * description: Credential not found + * description: Credential not found or invalid + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ verifyCredential = asyncHandler( async (req: Request, res: Response): Promise => { diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts index edd41b46..e2c12ff9 100644 --- a/src/controllers/employer.controller.ts +++ b/src/controllers/employer.controller.ts @@ -145,6 +145,79 @@ function isEmployer(req: Request) { return req.user?.role === 'employer' } +/** + * @openapi + * /employer/search: + * get: + * operationId: employerSearchTalent + * summary: Search learner talent pool with skill and location filters + * description: > + * Requires `employer` role. Results are filtered by plan tier: + * starter ≤ 10 results/page, pro ≤ 50, enterprise ≤ 100. + * Plan is read from the `x-employer-plan` request header (defaults to `starter`). + * tags: [Employer] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * maximum: 100 + * description: Capped by plan tier. + * - in: query + * name: skills + * schema: + * type: string + * description: Comma-separated skill keywords (e.g. `finance,defi`). + * - in: query + * name: location + * schema: + * type: string + * description: Location string to match against candidate profile. + * - in: query + * name: credentials + * schema: + * type: string + * enum: [any, verified, none] + * default: any + * - in: query + * name: search + * schema: + * type: string + * description: Free-text search on username or email. + * - in: header + * name: x-employer-plan + * schema: + * type: string + * enum: [starter, pro, enterprise] + * description: Employer subscription tier (defaults to `starter` if absent or invalid). + * responses: + * 200: + * description: Paginated list of matching candidates + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EmployerSearchResponse' + * 400: + * description: Invalid query parameters or limit exceeds plan maximum + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Employer role required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ export const searchTalent = async (req: Request, res: Response) => { if (!isEmployer(req)) { return res.status(403).json({ message: 'Employer account required' }) @@ -270,6 +343,45 @@ export const searchTalent = async (req: Request, res: Response) => { }) } +/** + * @openapi + * /employer/candidates/{id}: + * get: + * operationId: employerGetCandidateProfile + * summary: Get a candidate's full profile with verified credentials + * description: > + * Requires `employer` role. Returns 404 if the candidate has no module + * completions. Returns 403 if the candidate has opted out of visibility. + * tags: [Employer] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Candidate profile with verified credentials + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CandidateProfile' + * 403: + * description: Employer role required or candidate profile is private + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: Candidate not found or has no completions + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ export const getCandidateProfile = async (req: Request, res: Response) => { if (!isEmployer(req)) { return res.status(403).json({ message: 'Employer account required' }) @@ -325,6 +437,65 @@ export const getCandidateProfile = async (req: Request, res: Response) => { }) } +/** + * @openapi + * /employer/contact: + * post: + * operationId: employerContactCandidate + * summary: Record a candidate outreach attempt + * description: > + * Requires `employer` role and at least a **pro** plan (`x-employer-plan: pro` + * or `enterprise`). Starter plan returns HTTP 402. The outreach is logged + * internally; no message is actually delivered to the candidate at this time. + * tags: [Employer] + * security: + * - bearerAuth: [] + * parameters: + * - in: header + * name: x-employer-plan + * required: true + * schema: + * type: string + * enum: [pro, enterprise] + * description: Must be `pro` or `enterprise`; `starter` returns 402. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ContactCandidateInput' + * responses: + * 201: + * description: Outreach recorded successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ContactCandidateResponse' + * 400: + * description: Invalid request body + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 402: + * description: Plan upgrade required (starter plan cannot use contact) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Employer role required or candidate profile is private + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: Candidate not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ export const contactCandidate = async (req: Request, res: Response) => { if (!isEmployer(req)) { return res.status(403).json({ message: 'Employer account required' }) diff --git a/src/controllers/module.controller.ts b/src/controllers/module.controller.ts index 8987bb58..9692a165 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -29,8 +29,11 @@ const completeModuleSchema = z.object({ * @openapi * /modules: * get: + * operationId: modulesList * summary: List modules with filters and pagination + * description: Authentication is optional. When a valid token is supplied, each module includes the caller's progress. * tags: [Modules] + * security: [] * parameters: * - in: query * name: page @@ -56,13 +59,17 @@ const completeModuleSchema = z.object({ * type: string * responses: * 200: - * description: List of modules retrieved successfully + * description: List of modules * content: * application/json: * schema: * $ref: '#/components/schemas/ModuleList' * 400: * description: Invalid query parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ export const listModules = async (req: Request, res: Response) => { try { @@ -166,23 +173,31 @@ export const listModules = async (req: Request, res: Response) => { * @openapi * /modules/{id}: * get: + * operationId: modulesGetById * summary: Get module details + * description: Authentication is optional. When a valid token is supplied, the response includes the caller's progress. * tags: [Modules] + * security: [] * parameters: * - in: path * name: id * required: true * schema: * type: string + * format: uuid * responses: * 200: - * description: Module details retrieved successfully + * description: Module details * content: * application/json: * schema: * $ref: '#/components/schemas/Module' * 404: * description: Module not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ export const getModuleById = async (req: Request, res: Response) => { try { @@ -249,7 +264,8 @@ export const getModuleById = async (req: Request, res: Response) => { * @openapi * /modules/{id}/start: * post: - * summary: Start a module + * operationId: modulesStart + * summary: Start tracking progress on a module * tags: [Modules] * security: * - bearerAuth: [] @@ -259,15 +275,41 @@ export const getModuleById = async (req: Request, res: Response) => { * required: true * schema: * type: string + * format: uuid * responses: * 201: * description: Module started successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * completionId: + * type: string + * format: uuid + * startedAt: + * type: string + * format: date-time * 400: * description: Module already started or completed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 404: * description: Module not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ export const startModule = async (req: Request, res: Response) => { try { @@ -329,7 +371,11 @@ export const startModule = async (req: Request, res: Response) => { * @openapi * /modules/{id}/complete: * post: - * summary: Complete a module with quiz answers + * operationId: modulesComplete + * summary: Submit quiz answers and complete a module + * description: > + * The module must have been started first via `POST /modules/{id}/start`. + * A score ≥ 70% qualifies for the XLM reward and triggers a push notification. * tags: [Modules] * security: * - bearerAuth: [] @@ -339,6 +385,7 @@ export const startModule = async (req: Request, res: Response) => { * required: true * schema: * type: string + * format: uuid * requestBody: * required: true * content: @@ -353,11 +400,23 @@ export const startModule = async (req: Request, res: Response) => { * schema: * $ref: '#/components/schemas/ModuleCompletionResponse' * 400: - * description: Invalid request or module already completed + * description: Invalid request body, module not started, or already completed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 404: * description: Module not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ export const completeModule = async (req: Request, res: Response) => { try { diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts index f631c188..9594d402 100644 --- a/src/controllers/notification.controller.ts +++ b/src/controllers/notification.controller.ts @@ -25,6 +25,7 @@ export class NotificationController { * @openapi * /notifications/devices: * post: + * operationId: notificationsRegisterDevice * summary: Register a device token for push notifications * tags: [Notifications] * security: @@ -34,24 +35,22 @@ export class NotificationController { * content: * application/json: * schema: - * type: object - * required: - * - token - * - platform - * properties: - * token: - * type: string - * description: Firebase device token - * platform: - * type: string - * enum: [ios, android, web] + * $ref: '#/components/schemas/RegisterDeviceInput' * responses: * 201: * description: Device token registered successfully * 400: * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async registerDevice(req: Request, res: Response): Promise { try { @@ -89,7 +88,8 @@ export class NotificationController { * @openapi * /notifications/preferences: * patch: - * summary: Update notification preferences + * operationId: notificationsUpdatePreferences + * summary: Update push notification preferences * tags: [Notifications] * security: * - bearerAuth: [] @@ -98,21 +98,22 @@ export class NotificationController { * content: * application/json: * schema: - * type: object - * properties: - * rewardReceipt: - * type: boolean - * quizPassFail: - * type: boolean - * streakReminders: - * type: boolean + * $ref: '#/components/schemas/NotificationPreferencesInput' * responses: * 200: * description: Preferences updated successfully * 400: - * description: Validation failed + * description: Validation failed — at least one preference field required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async updatePreferences(req: Request, res: Response): Promise { try { @@ -149,7 +150,8 @@ export class NotificationController { * @openapi * /notifications/delivery-status: * get: - * summary: Get notification delivery status logs for the authenticated user + * operationId: notificationsGetDeliveryStatus + * summary: Get notification delivery log for the authenticated user * tags: [Notifications] * security: * - bearerAuth: [] @@ -159,6 +161,7 @@ export class NotificationController { * schema: * type: integer * default: 20 + * maximum: 100 * - in: query * name: status * schema: @@ -167,8 +170,23 @@ export class NotificationController { * responses: * 200: * description: Delivery logs retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/NotificationLog' + * count: + * type: integer * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async getDeliveryStatus(req: Request, res: Response): Promise { try { diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts index c21083f2..2fab5173 100644 --- a/src/controllers/referral.controller.ts +++ b/src/controllers/referral.controller.ts @@ -12,17 +12,33 @@ export class ReferralController { * @openapi * /referrals/code: * post: - * summary: Generate a unique referral code for the authenticated user + * operationId: referralsGenerateCode + * summary: Generate (or retrieve existing) referral code for the authenticated user + * description: > + * If the user already has a code, returns 200 with the existing code. + * Otherwise creates a new unique 8-character hex code and returns 201. * tags: [Referrals] * security: * - bearerAuth: [] * responses: + * 200: + * description: Referral code already exists; returned as-is. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ReferralCodeResponse' * 201: - * description: Referral code generated - * 409: - * description: User already has a referral code + * description: Referral code generated successfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ReferralCodeResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ generateCode = asyncHandler(async (req: Request, res: Response): Promise => { const userId = (req as any).user?.id @@ -55,6 +71,7 @@ export class ReferralController { * @openapi * /referrals/apply: * post: + * operationId: referralsApplyCode * summary: Apply a referral code during signup or onboarding * tags: [Referrals] * security: @@ -64,18 +81,32 @@ export class ReferralController { * content: * application/json: * schema: - * type: object - * required: [code] - * properties: - * code: - * type: string + * $ref: '#/components/schemas/ApplyReferralInput' * responses: * 201: * description: Referral applied successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApplyReferralResponse' * 400: - * description: Invalid or self-referral code + * description: Missing code, self-referral, or code not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 409: * description: User has already used a referral code + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ applyCode = asyncHandler(async (req: Request, res: Response): Promise => { const userId = (req as any).user?.id @@ -120,15 +151,24 @@ export class ReferralController { * @openapi * /referrals/stats: * get: - * summary: Get referral stats for the authenticated user + * operationId: referralsGetStats + * summary: Get referral statistics for the authenticated user * tags: [Referrals] * security: * - bearerAuth: [] * responses: * 200: * description: Referral stats retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ReferralStats' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ getStats = asyncHandler(async (req: Request, res: Response): Promise => { const userId = (req as any).user?.id diff --git a/src/controllers/reward.controller.ts b/src/controllers/reward.controller.ts index fdaaae46..948bd065 100644 --- a/src/controllers/reward.controller.ts +++ b/src/controllers/reward.controller.ts @@ -14,7 +14,8 @@ export class RewardController { * @openapi * /rewards/balance: * get: - * summary: Get current user reward balance + * operationId: rewardsGetBalance + * summary: Get the authenticated user's reward balance * tags: [Rewards] * security: * - bearerAuth: [] @@ -27,6 +28,10 @@ export class RewardController { * $ref: '#/components/schemas/RewardBalance' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ getBalance = asyncHandler( async (req: Request, res: Response): Promise => { @@ -56,7 +61,8 @@ export class RewardController { * @openapi * /rewards/history: * get: - * summary: Get transaction history + * operationId: rewardsGetHistory + * summary: Get the authenticated user's transaction history * tags: [Rewards] * security: * - bearerAuth: [] @@ -86,11 +92,13 @@ export class RewardController { * schema: * type: integer * default: 20 + * maximum: 100 * - in: query * name: offset * schema: * type: integer * default: 0 + * minimum: 0 * responses: * 200: * description: Transaction history retrieved successfully @@ -100,6 +108,10 @@ export class RewardController { * $ref: '#/components/schemas/TransactionHistory' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ getHistory = asyncHandler( async (req: Request, res: Response): Promise => { @@ -199,7 +211,11 @@ export class RewardController { * @openapi * /rewards/withdraw: * post: - * summary: Process withdrawal request + * operationId: rewardsWithdraw + * summary: Submit a withdrawal request + * description: > + * Validates the Stellar wallet address (pattern `^G[A-Z0-9]{50,55}$`), + * checks that `amount > 0`, and verifies sufficient balance before processing. * tags: [Rewards] * security: * - bearerAuth: [] @@ -218,8 +234,16 @@ export class RewardController { * $ref: '#/components/schemas/WithdrawalResponse' * 400: * description: Invalid input or insufficient balance + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ withdraw = asyncHandler( async (req: Request, res: Response): Promise => { diff --git a/src/controllers/sync.controller.ts b/src/controllers/sync.controller.ts index 5c963741..676e9705 100644 --- a/src/controllers/sync.controller.ts +++ b/src/controllers/sync.controller.ts @@ -34,7 +34,12 @@ export class SyncController { * @openapi * /sync/progress: * post: + * operationId: syncProgress * summary: Upload batched offline progress events + * description: > + * Each event is deduplicated by `idempotencyKey`. Events with a stale + * `syncVersion` (lower than the latest already applied for that module) + * are silently skipped. * tags: [Sync] * security: * - bearerAuth: [] @@ -49,15 +54,26 @@ export class SyncController { * events: * type: array * items: - * type: object - * required: [idempotencyKey, deviceId, moduleId, progressPercent, clientTimestamp, syncVersion] + * $ref: '#/components/schemas/SyncProgressEvent' * responses: * 200: - * description: Sync results per item + * description: Per-event sync results + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SyncResponse' * 400: - * description: Invalid payload + * description: Invalid payload (e.g. empty events array) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ syncProgress = asyncHandler(async (req: Request, res: Response): Promise => { const userId = (req as any).user?.id @@ -128,7 +144,12 @@ export class SyncController { * @openapi * /sync/completions: * post: + * operationId: syncCompletions * summary: Reconcile offline quiz/completion attempts + * description: > + * Each event is deduplicated by `idempotencyKey`. If the user already has a + * completion with an equal or higher score, the event is skipped. A higher + * score updates the existing record. * tags: [Sync] * security: * - bearerAuth: [] @@ -143,15 +164,26 @@ export class SyncController { * events: * type: array * items: - * type: object - * required: [idempotencyKey, deviceId, moduleId, score, clientTimestamp, syncVersion] + * $ref: '#/components/schemas/SyncCompletionEvent' * responses: * 200: - * description: Per-item sync results + * description: Per-event sync results + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SyncResponse' * 400: - * description: Invalid payload + * description: Invalid payload (e.g. empty events array) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ syncCompletions = asyncHandler(async (req: Request, res: Response): Promise => { const userId = (req as any).user?.id diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index b813b11a..c8ada4f8 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -6,7 +6,8 @@ export class UserController { * @openapi * /users/me: * get: - * summary: Get current authenticated user profile + * operationId: usersGetMe + * summary: Get the authenticated user's profile * tags: [Users] * security: * - bearerAuth: [] @@ -19,8 +20,16 @@ export class UserController { * $ref: '#/components/schemas/User' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 404: * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async getCurrentUser (req: Request, res: Response): Promise { @@ -57,9 +66,10 @@ export class UserController { /** * @openapi - * /users/profile: - * put: - * summary: Update user profile + * /users/me: + * patch: + * operationId: usersUpdateProfile + * summary: Update the authenticated user's profile * tags: [Users] * security: * - bearerAuth: [] @@ -68,7 +78,7 @@ export class UserController { * content: * application/json: * schema: - * $ref: '#/components/schemas/UpdateUser' + * $ref: '#/components/schemas/UpdateUserInput' * responses: * 200: * description: Profile updated successfully @@ -78,8 +88,16 @@ export class UserController { * $ref: '#/components/schemas/User' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async updateProfile (req: Request, res: Response): Promise { @@ -110,6 +128,35 @@ export class UserController { } } + /** + * @openapi + * /users/{id}: + * get: + * operationId: usersGetById + * summary: Get a user's public profile by ID + * tags: [Users] + * security: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Public user info + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PublicUser' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ async getUserById (req: Request, res: Response): Promise { try { const { id } = req.params @@ -138,6 +185,46 @@ export class UserController { } } + /** + * @openapi + * /users/password: + * patch: + * operationId: usersChangePassword + * summary: Change the authenticated user's password + * description: > + * ⚠️ **Preview** — the underlying service method is not yet fully implemented. + * Calling this endpoint will return a 500 error until the service is completed. + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChangePasswordInput' + * responses: + * 200: + * description: Password changed successfully. + * 400: + * description: Current password is incorrect or validation failed. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Not yet implemented. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ async changePassword (req: Request, res: Response): Promise { try { const userId = (req as any).user?.id @@ -171,8 +258,13 @@ export class UserController { /** * @openapi * /users/wallet: - * put: - * summary: Update user Stellar wallet address + * patch: + * operationId: usersUpdateWallet + * summary: Update the authenticated user's Stellar wallet address + * description: > + * ⚠️ **Preview** — the underlying service method is not yet fully implemented. + * The wallet update is accepted but may not persist to the database until the + * service layer is completed. * tags: [Users] * security: * - bearerAuth: [] @@ -181,26 +273,32 @@ export class UserController { * content: * application/json: * schema: - * type: object - * required: - * - walletAddress - * properties: - * walletAddress: - * type: string - * example: GABC123456789012345678901234567890123456789012345678901234567890 + * $ref: '#/components/schemas/UpdateWalletInput' * responses: * 200: - * description: Wallet address updated successfully + * description: Wallet address updated successfully. * content: * application/json: * schema: * $ref: '#/components/schemas/User' * 400: - * description: Invalid Stellar wallet address + * description: Invalid Stellar wallet address format. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 401: * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async updateWalletAddress (req: Request, res: Response): Promise { diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts index eb1884f9..09c93025 100644 --- a/src/docs/schemas.ts +++ b/src/docs/schemas.ts @@ -1,147 +1,310 @@ +/** + * OpenAPI component schemas for the Learnault API. + * + * This file is picked up by swagger-jsdoc via the `apis` glob in swagger.ts. + * Do not add route operations here — keep those in the individual controller files. + */ + /** * @openapi * components: * schemas: - * User: + * + * # ── Shared / primitives ─────────────────────────────────────────────── + * + * ErrorResponse: * type: object + * description: Standard error envelope returned for all 4xx and 5xx responses. * properties: - * id: - * type: string - * format: uuid - * email: - * type: string - * format: email - * username: - * type: string - * firstName: - * type: string - * lastName: - * type: string - * bio: - * type: string - * avatar: - * type: string - * format: url - * walletAddress: - * type: string - * isActive: + * success: * type: boolean - * role: - * type: string - * enum: [LEARNER, EMPLOYER, ADMIN] - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time + * example: false + * error: + * type: object + * properties: + * message: + * type: string + * example: Resource not found + * code: + * type: integer + * example: 404 * - * UpdateUser: + * Pagination: * type: object + * description: Page-based pagination metadata. * properties: - * username: - * type: string - * firstName: - * type: string - * lastName: - * type: string - * bio: - * type: string - * avatar: - * type: string - * format: url + * page: + * type: integer + * example: 1 + * limit: + * type: integer + * example: 20 + * total: + * type: integer + * example: 100 + * totalPages: + * type: integer + * example: 5 + * hasNext: + * type: boolean + * example: true + * hasPrev: + * type: boolean + * example: false + * + */ + +/** + * @openapi + * components: + * schemas: + * + * # ── Auth ────────────────────────────────────────────────────────────── * * RegisterInput: * type: object - * required: - * - email - * - password - * - username + * required: [email, password, username] * properties: * email: * type: string * format: email + * example: alice@example.com * password: * type: string * format: password + * minLength: 8 + * example: P@ssword1 * username: * type: string + * minLength: 3 + * example: alice42 * role: * type: string - * enum: [LEARNER, EMPLOYER] + * enum: [learner, employer] + * default: learner * * LoginInput: * type: object - * required: - * - email - * - password + * required: [email, password] * properties: * email: * type: string * format: email + * example: alice@example.com * password: * type: string * format: password + * example: P@ssword1 + * + * AuthUser: + * type: object + * properties: + * id: + * type: string + * format: uuid + * email: + * type: string + * format: email + * username: + * type: string + * role: + * type: string + * enum: [learner, employer, admin] * * AuthResponse: * type: object * properties: * message: * type: string + * example: User registered successfully * token: * type: string + * description: JWT; pass as Authorization Bearer token. * user: - * $ref: '#/components/schemas/User' + * $ref: '#/components/schemas/AuthUser' * * VerifyEmailInput: * type: object - * required: - * - token + * required: [token] * properties: * token: * type: string + * description: 64-character hex token from the verification email. + * example: a1b2c3d4e5f6... * - * VerifyEmailResponse: + * ResendVerificationInput: * type: object + * required: [email] * properties: - * message: + * email: * type: string + * format: email + * example: alice@example.com * - * ResendVerificationInput: + * ForgotPasswordInput: * type: object - * required: - * - email + * required: [email] * properties: * email: * type: string * format: email + * example: alice@example.com * - * ResendVerificationResponse: + * ResetPasswordInput: * type: object + * required: [token, newPassword] * properties: - * message: + * token: + * type: string + * description: 64-character hex token from the password-reset email. + * example: d4e5f6a1b2c3... + * newPassword: * type: string + * format: password + * minLength: 8 + * example: NewP@ss1 * - * ForgotPasswordInput: + */ + +/** + * @openapi + * components: + * schemas: + * + * # ── Users ───────────────────────────────────────────────────────────── + * + * User: * type: object - * required: - * - email * properties: + * id: + * type: string + * format: uuid * email: * type: string * format: email + * username: + * type: string + * firstName: + * type: string + * nullable: true + * lastName: + * type: string + * nullable: true + * bio: + * type: string + * nullable: true + * avatar: + * type: string + * format: uri + * nullable: true + * walletAddress: + * type: string + * nullable: true + * example: GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH + * isActive: + * type: boolean + * role: + * type: string + * enum: [learner, employer, admin] + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time * - * ResetPasswordInput: + * PublicUser: * type: object - * required: - * - token - * - newPassword + * description: Publicly visible subset of a user profile. * properties: - * token: + * id: + * type: string + * format: uuid + * username: + * type: string + * firstName: + * type: string + * nullable: true + * lastName: + * type: string + * nullable: true + * avatar: + * type: string + * format: uri + * nullable: true + * role: + * type: string + * enum: [learner, employer, admin] + * createdAt: + * type: string + * format: date-time + * + * UpdateUserInput: + * type: object + * description: All fields are optional; send only what you want to change. + * properties: + * username: + * type: string + * minLength: 3 + * maxLength: 30 + * firstName: + * type: string + * maxLength: 50 + * lastName: + * type: string + * maxLength: 50 + * bio: + * type: string + * maxLength: 500 + * avatar: + * type: string + * format: uri + * + * ChangePasswordInput: + * type: object + * required: [currentPassword, newPassword] + * properties: + * currentPassword: * type: string + * format: password * newPassword: * type: string * format: password + * minLength: 8 + * description: > + * Must be at least 8 characters and contain uppercase, lowercase, + * a digit, and a special character (@$!%*?&). + * + * UpdateWalletInput: + * type: object + * required: [walletAddress] + * properties: + * walletAddress: + * type: string + * pattern: '^G[A-Z0-9]{55}$' + * example: GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH + * + */ + +/** + * @openapi + * components: + * schemas: + * + * # ── Modules ─────────────────────────────────────────────────────────── + * + * UserProgress: + * type: object + * nullable: true + * properties: + * completed: + * type: boolean + * score: + * type: number + * completedAt: + * type: string + * format: date-time + * nullable: true * * Module: * type: object @@ -159,6 +322,7 @@ * type: string * reward: * type: number + * description: XLM reward for passing the quiz (score >= 70%). * createdAt: * type: string * format: date-time @@ -168,16 +332,7 @@ * completionCount: * type: integer * userProgress: - * type: object - * nullable: true - * properties: - * completed: - * type: boolean - * score: - * type: number - * completedAt: - * type: string - * format: date-time + * $ref: '#/components/schemas/UserProgress' * * ModuleList: * type: object @@ -189,31 +344,15 @@ * pagination: * $ref: '#/components/schemas/Pagination' * - * Pagination: - * type: object - * properties: - * page: - * type: integer - * limit: - * type: integer - * total: - * type: integer - * totalPages: - * type: integer - * hasNext: - * type: boolean - * hasPrev: - * type: boolean - * * CompleteModuleInput: * type: object - * required: - * - quizAnswers + * required: [quizAnswers] * properties: * quizAnswers: * type: array * items: * type: object + * required: [questionId, answer] * properties: * questionId: * type: string @@ -227,64 +366,234 @@ * type: string * score: * type: number + * example: 80 * isEligibleForReward: * type: boolean * reward: * type: number + * description: XLM amount rewarded (0 if score < 70%). * rewardTransaction: * type: string + * format: uuid + * nullable: true * completedAt: * type: string * format: date-time * - * RewardBalance: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: object - * properties: - * balance: - * type: object - * properties: - * available: - * type: number - * pending: - * type: number - * lifetime: - * type: number - * updatedAt: - * type: string - * format: date-time + */ + +/** + * @openapi + * components: + * schemas: * - * Transaction: + * # ── Credentials ─────────────────────────────────────────────────────── + * + * CredentialSummary: * type: object + * description: Credential as returned in the list endpoint. * properties: * id: * type: string - * type: - * type: string - * status: + * format: uuid + * userId: * type: string - * amount: - * type: number + * format: uuid * moduleId: * type: string - * stellarTxHash: + * format: uuid + * moduleName: * type: string + * moduleCategory: + * type: string + * moduleDifficulty: + * type: string + * onChainId: + * type: string + * nullable: true + * issuedAt: + * type: string + * format: date-time + * shareableLink: + * type: string + * + * CredentialList: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/CredentialSummary' + * meta: + * type: object + * properties: + * page: + * type: integer + * limit: + * type: integer + * total: + * type: integer + * totalPages: + * type: integer + * hasNextPage: + * type: boolean + * hasPrevPage: + * type: boolean + * + * Credential: + * type: object + * description: Full credential detail, including module description and metadata. + * properties: + * id: + * type: string + * format: uuid + * userId: + * type: string + * format: uuid + * holderName: + * type: string + * moduleId: + * type: string + * format: uuid + * moduleName: + * type: string + * moduleDescription: + * type: string + * moduleCategory: + * type: string + * moduleDifficulty: + * type: string + * onChainId: + * type: string + * nullable: true + * issuedAt: + * type: string + * format: date-time + * shareableLink: + * type: string + * metadata: + * type: object + * properties: + * reward: + * type: number + * verificationUrl: + * type: string + * + * VerificationResponse: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * valid: + * type: boolean + * example: true + * credential: + * type: object + * properties: + * id: + * type: string + * holderName: + * type: string + * moduleName: + * type: string + * moduleCategory: + * type: string + * moduleDifficulty: + * type: string + * onChainId: + * type: string + * nullable: true + * issuedAt: + * type: string + * format: date-time + * verification: + * type: object + * properties: + * verifiedAt: + * type: string + * format: date-time + * status: + * type: string + * example: verified + * message: + * type: string + * + */ + +/** + * @openapi + * components: + * schemas: + * + * # ── Rewards ─────────────────────────────────────────────────────────── + * + * RewardBalance: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * balance: + * type: object + * properties: + * available: + * type: number + * example: 10.5 + * pending: + * type: number + * example: 2.0 + * lifetime: + * type: number + * example: 25.0 + * updatedAt: + * type: string + * format: date-time + * + * Transaction: + * type: object + * properties: + * id: + * type: string + * format: uuid + * type: + * type: string + * enum: [module_reward, streak_bonus, referral_reward, withdrawal] + * status: + * type: string + * enum: [pending, completed, failed] + * amount: + * type: number + * moduleId: + * type: string + * format: uuid + * nullable: true + * stellarTxHash: + * type: string + * nullable: true * createdAt: * type: string * format: date-time * completedAt: * type: string * format: date-time + * nullable: true * * TransactionHistory: * type: object * properties: * success: * type: boolean + * example: true * data: * type: object * properties: @@ -306,22 +615,27 @@ * * WithdrawalInput: * type: object - * required: - * - walletAddress - * - amount + * required: [walletAddress, amount] * properties: * walletAddress: * type: string + * pattern: '^G[A-Z0-9]{50,55}$' + * example: GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDE * amount: * type: number + * minimum: 0 + * exclusiveMinimum: true + * example: 5.0 * memo: * type: string + * nullable: true * * WithdrawalResponse: * type: object * properties: * success: * type: boolean + * example: true * message: * type: string * data: @@ -329,6 +643,7 @@ * properties: * transactionId: * type: string + * format: uuid * amount: * type: number * stellarTxHash: @@ -341,44 +656,240 @@ * completedAt: * type: string * format: date-time + * nullable: true * - * Credential: + */ + +/** + * @openapi + * components: + * schemas: + * + * # ── Referrals ───────────────────────────────────────────────────────── + * + * ReferralCodeResponse: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * data: + * type: object + * properties: + * code: + * type: string + * example: A1B2C3D4 + * + * ApplyReferralInput: + * type: object + * required: [code] + * properties: + * code: + * type: string + * example: A1B2C3D4 + * + * ApplyReferralResponse: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * data: + * type: object + * properties: + * referralId: + * type: string + * format: uuid + * + * ReferralStats: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * totalReferrals: + * type: integer + * example: 3 + * activeReferrals: + * type: integer + * description: Referrals where the referree has completed at least one module. + * example: 2 + * earnedBonuses: + * type: number + * description: Total XLM bonuses already paid out. + * example: 10.0 + * pendingBonuses: + * type: number + * description: Estimated pending bonus (unpaid referrals × 5 XLM per referral). + * example: 5.0 + * + * # ── Notifications ───────────────────────────────────────────────────── + * + * RegisterDeviceInput: + * type: object + * required: [token, platform] + * properties: + * token: + * type: string + * description: Firebase device token. + * platform: + * type: string + * enum: [ios, android, web] + * + * NotificationPreferencesInput: + * type: object + * description: At least one field must be provided. + * properties: + * rewardReceipt: + * type: boolean + * quizPassFail: + * type: boolean + * streakReminders: + * type: boolean + * + * NotificationLog: * type: object * properties: * id: * type: string - * userId: + * format: uuid + * type: * type: string - * holderName: + * title: * type: string - * moduleId: + * body: * type: string - * moduleName: + * status: * type: string - * moduleDescription: + * enum: [pending, success, failed, dead-letter] + * error: * type: string - * moduleCategory: + * nullable: true + * attemptCount: + * type: integer + * createdAt: * type: string - * moduleDifficulty: + * format: date-time + * + */ + +/** + * @openapi + * components: + * schemas: + * + * # ── Sync ────────────────────────────────────────────────────────────── + * + * SyncProgressEvent: + * type: object + * required: [idempotencyKey, deviceId, moduleId, progressPercent, clientTimestamp, syncVersion] + * properties: + * idempotencyKey: * type: string - * onChainId: + * description: Unique key to deduplicate events. + * deviceId: * type: string - * issuedAt: + * moduleId: + * type: string + * format: uuid + * progressPercent: + * type: number + * minimum: 0 + * maximum: 100 + * clientTimestamp: * type: string * format: date-time - * shareableLink: + * syncVersion: + * type: integer + * + * SyncCompletionEvent: + * type: object + * required: [idempotencyKey, deviceId, moduleId, score, clientTimestamp, syncVersion] + * properties: + * idempotencyKey: + * type: string + * deviceId: * type: string + * moduleId: + * type: string + * format: uuid + * score: + * type: number + * minimum: 0 + * maximum: 100 + * clientTimestamp: + * type: string + * format: date-time + * syncVersion: + * type: integer * - * CredentialList: + * SyncResult: + * type: object + * properties: + * idempotencyKey: + * type: string + * status: + * type: string + * enum: [applied, skipped, rejected] + * reason: + * type: string + * nullable: true + * description: Present when status is skipped or rejected. + * + * SyncResponse: * type: object * properties: * success: * type: boolean + * example: true * data: + * type: object + * properties: + * results: + * type: array + * items: + * $ref: '#/components/schemas/SyncResult' + * + * # ── Employer ────────────────────────────────────────────────────────── + * + * CandidateSummary: + * type: object + * description: Truncated candidate record returned by the search endpoint. + * properties: + * id: + * type: string + * format: uuid + * name: + * type: string + * location: + * type: string + * nullable: true + * skills: * type: array * items: - * $ref: '#/components/schemas/Credential' - * meta: + * type: string + * completions: + * type: integer + * averageScore: + * type: number + * verifiedCredentialCount: + * type: integer + * + * EmployerSearchResponse: + * type: object + * properties: + * candidates: + * type: array + * items: + * $ref: '#/components/schemas/CandidateSummary' + * pagination: * type: object * properties: * page: @@ -389,19 +900,121 @@ * type: integer * totalPages: * type: integer + * hasNext: + * type: boolean + * hasPrev: + * type: boolean + * filters: + * type: object + * properties: + * skills: + * type: array + * items: + * type: string + * location: + * type: string + * nullable: true + * credentials: + * type: string + * plan: + * type: string + * enum: [starter, pro, enterprise] * - * VerificationResponse: + * VerifiedCredentialDetail: * type: object * properties: - * success: + * id: + * type: string + * moduleId: + * type: string + * moduleTitle: + * type: string + * category: + * type: string + * difficulty: + * type: string + * issuedAt: + * type: string + * format: date-time + * onChainId: + * type: string + * nullable: true + * verified: * type: boolean - * data: + * + * CandidateProfile: + * type: object + * properties: + * id: + * type: string + * format: uuid + * name: + * type: string + * location: + * type: string + * joinedAt: + * type: string + * format: date-time + * skills: + * type: array + * items: + * type: string + * completions: + * type: integer + * averageScore: + * type: number + * verifiedCredentials: + * type: array + * items: + * $ref: '#/components/schemas/VerifiedCredentialDetail' + * privacy: * type: object * properties: - * valid: - * type: boolean - * credential: - * type: object - * verification: - * type: object + * profileVisibility: + * type: string + * example: public + * + * ContactCandidateInput: + * type: object + * required: [candidateId, subject, message] + * properties: + * candidateId: + * type: string + * format: uuid + * subject: + * type: string + * minLength: 3 + * maxLength: 120 + * message: + * type: string + * minLength: 10 + * maxLength: 3000 + * channel: + * type: string + * enum: [platform, email, both] + * default: platform + * + * ContactCandidateResponse: + * type: object + * properties: + * message: + * type: string + * outreach: + * type: object + * properties: + * id: + * type: string + * format: uuid + * candidateId: + * type: string + * format: uuid + * channel: + * type: string + * status: + * type: string + * example: recorded + * createdAt: + * type: string + * format: date-time + * */