openapi: 3.0.3 info: title: 'Dots App API Documentation' description: 'JSON API backend for the Dots App Flutter client: authentication (email OTP registration, login, forgot/reset password), account management, and clinical case submission.' version: 1.0.0 servers: - url: 'https://www.dots.mhn.services' tags: - name: Authentication description: "\nRegistration, login, and password recovery. None of these endpoints require an\nAuthorization header except Logout." - name: Account description: "\nManaging the authenticated user's own profile, password, and account. All of\nthese require `Authorization: Bearer {token}`." - name: Cases description: "\nClinical case submission and the shared case feed. All of these require\n`Authorization: Bearer {token}`. A case only requires at least one image\n(clinical or dermoscopic); every other field is optional free text/arrays -\nthere's no fixed option list yet, the Flutter app owns validation and\ndropdown values. Cases are stored with a `pending` status; there's no\nreview/approval workflow yet. Any authenticated user can view any case and\nits comments, and can comment/react on it - this is a shared feed, not\nprivate per-user data.\n\n## Case code\n\nEvery case carries a `code`, a short handle for that case:\n\n- Exactly five characters, always uppercase letters and digits.\n- Drawn from an alphabet that leaves out the characters people misread:\n there is never an `O`, `0`, `I` or `1` in a code. `K7F2Q` is a real shape;\n `K7F2O` can never occur.\n- Assigned by the server when the case is created. The client never sends\n it, and it is never reissued: editing a case, adding or removing images,\n rejection and resubmission all leave it unchanged.\n- Unique across every case, so a full code identifies exactly one case.\n\nThe code is on every case payload, on every endpoint, including the admin\nAPI. It is what the search box is for - see below.\n\n## Search\n\n`GET cases` and `GET cases/mine` both accept `?search=`. One parameter,\ntwo behaviours, decided by what was typed:\n\n| Search term | Matches |\n|---|---|\n| A full case code, e.g. `K7F2Q` | That one case, by exact code. Letter case is ignored, so `k7f2q` works too |\n| Anything else, e.g. `melanoma` | A fragment of `clinical.diagnosis`, `dermoscopic.diagnosis`, or `body_site` |\n\nNotes for testing:\n\n- A partial code does **not** match. Searching `K7F` returns nothing unless\n `K7F` happens to appear in a diagnosis or body site.\n- Search runs before pagination, so `meta.total` is the number of matches,\n not the size of the whole feed.\n- No match is an empty result, never a 404: `{\"data\": [], \"meta\": {\"skip\":\n 0, \"limit\": 15, \"total\": 0}}`.\n- Search on the feed still only sees approved cases. To find your own\n pending or rejected case by code, search `cases/mine`.\n\n## Pinned cases\n\nAn admin can pin a case to the top of the feed, from the admin panel or\nfrom `POST /api/v1/admin/cases/pin`. Every case payload carries three keys\nfor it, always present:\n\n| Key | Type | Meaning |\n|---|---|---|\n| `is_pinned` | bool | `true` while the case is pinned. Never null |\n| `pinned_at` | string, nullable | When it was pinned, ISO 8601. `null` when not pinned |\n| `pinned_by` | object, nullable | The admin who pinned it: `{id, full_name, avatar_url}`. `null` when not pinned |\n\nOrdering rules for `GET cases`, in order of precedence:\n\n1. Pinned cases first.\n2. Among pinned cases, most recently pinned first - by `pinned_at`, not by\n when the case was created.\n3. Everything else after, newest created first.\n\nNotes for testing:\n\n- More than one case can be pinned at a time. There is no limit and no\n \"only one pinned case\" rule.\n- Pinning an already-pinned case is allowed and refreshes `pinned_at`,\n which moves it to the front of the pinned block.\n- Pinning is not review. Pinning a `pending` or `rejected` case stores the\n pin, but the case still does not appear in the feed until it is approved;\n its submitter sees the pin fields in `cases/mine`.\n- Unpinning clears both `pinned_at` and `pinned_by` and drops the case back\n into the normal newest-first order. Nothing else about the case changes.\n- `cases/mine` is deliberately **not** reordered by pins: a submitter's own\n history stays newest-first, even for a case that is pinned in the feed.\n- Deleting the admin account that pinned a case leaves the case pinned with\n `pinned_by: null`. Render the badge from `is_pinned`, not from\n `pinned_by`.\n\n**Response shape.** Every endpoint below returns a case with exactly the same\nkeys, so one client model parses all of them. Keys are never omitted. A value\nmay be null when the field is genuinely unset (`age`, `pdf`,\n`rejection_reason`), but lists are always lists and counts are always\nintegers. `comments` is populated only by Get Case; on the list endpoints it\nis `[]` and `comments_count` is the authoritative number." - name: Quizzes description: "\nQuizzes as the mobile app sees them. All of these require\n`Authorization: Bearer {token}`.\n\nOnly published quizzes are visible. **Each person gets one attempt per\nquiz**: a second submit returns `409`, and the recorded attempt is then read\nback from the result endpoint. Correct answers, explanations, and references\nare never sent before submission.\n\n## The two axes: quiz format and question type\n\nA quiz has a **`format`**, and each question inside it has a **`type`**. They\nare independent, and both change how you read the response.\n\n`format` is one of:\n\n| format | Graded? | What comes back from submit |\n|---|---|---|\n| `standard` | Yes | `score` out of `total_questions`, plus per-question `answers` |\n| `clinical_case` | Yes | Same as standard, plus `diagnosis` and `management` revealed |\n| `poll` | No | Aggregate tallies. **No `score`, no `answers`** |\n\nA `clinical_case` quiz is a case stem: `clinical_image`, `dermoscopic_image`\nand `history` are shown up front, and `diagnosis` / `management` stay hidden\nuntil the attempt is submitted.\n\n`type` is one of `single_choice`, `multiple_choice`, `true_false`,\n`match_following`, `poll`. Note a `poll` **question** can appear inside a\n`standard` quiz; it simply scores nothing and its `is_correct` is `null`.\n\n## After submitting: the review\n\nSubmit and result both return the whole attempt, not just the marks. The\ntwo endpoints return **the same payload**, so one parser covers both: submit\ngives it to you once at `201`, result gives it back any time afterwards at\n`200`.\n\nThe top level carries the marks:\n\n| Key | Type | Meaning |\n|---|---|---|\n| `quiz_id` | int | The quiz that was taken |\n| `quiz_title` | string | Its title, so a result screen needs no second call |\n| `format` | string | `standard` or `clinical_case`. A `poll` quiz never reaches this shape |\n| `score` | int | Questions answered correctly |\n| `total_questions` | int | What the attempt was scored out of. Frozen at submission time, so later edits to the quiz do not rewrite an old attempt |\n| `percentage` | number | `score / total_questions * 100`, one decimal place. `0` when the quiz has no questions |\n| `correct_count` | int | Same as `score`, counted from the answers |\n| `incorrect_count` | int | Answers graded wrong. Unanswered questions are counted here too; poll questions never are |\n| `unanswered_count` | int | Questions the student skipped |\n| `submitted_at` | string | ISO 8601 timestamp of the attempt |\n| `diagnosis`, `management` | string, nullable | Revealed for a `clinical_case` quiz, `null` otherwise |\n| `answers` | list | One entry per question in the quiz, in the quiz's order |\n\nEach entry in `answers`:\n\n| Key | Type | Meaning |\n|---|---|---|\n| `question_id` | int | The question |\n| `question` | string | Its text |\n| `type` | string | `single_choice`, `multiple_choice`, `true_false`, `match_following`, `poll` |\n| `image` | string, nullable | The question's image URL, `null` when it has none |\n| `is_correct` | bool, **nullable** | `true`/`false`, or `null` for a poll question, which is recorded but never graded |\n| `is_answered` | bool | `false` when the student skipped this question |\n| `explanation` | string, nullable | Why the correct answer is correct. Only ever sent after submission |\n| `reference` | string, nullable | Source for the question |\n| `options` | list | Every option the question offered: `{id, text, is_correct, is_selected}`. Empty for `match_following` |\n| `selected_option_ids` | list of string | What the student picked. Empty when skipped, and empty for `match_following` |\n| `correct_option_ids` | list of string | The answer key. Empty for `match_following` and for a poll question |\n| `response` | object | The student's `{left_id: right_id}` map. `match_following` only, `[]` otherwise |\n| `correct_pairs` | object | The correct `{left_id: right_id}` map. `match_following` only, `[]` otherwise |\n| `left`, `right` | list | Both sides of a `match_following` question as `{id, text}`, in the authored order. Empty for every other type |\n\nThe quickest way to render a reviewed choice question is to ignore the id\nlists entirely and walk `options`: `is_correct` marks the right answer,\n`is_selected` marks what the student tapped, and a question is wrong when an\noption has `is_selected` without `is_correct`.\n\n### Scenarios worth testing on the review screen\n\n- **All correct**: `score == total_questions`, `percentage: 100`, every\n answer `is_correct: true`.\n- **Some wrong**: the wrong answer has one option with `is_selected: true,\n is_correct: false` and another with `is_correct: true, is_selected: false`.\n- **Skipped question**: `is_answered: false`, `selected_option_ids: []`,\n `is_correct: false`, and no option has `is_selected`. It still appears in\n `answers`, so the review shows the whole quiz.\n- **Multiple choice, partly right**: grading is all-or-nothing. Picking one\n of two correct options is `is_correct: false`, and `options` shows both\n correct ones so the screen can display what was missed.\n- **match_following, partly right**: also all-or-nothing. Compare `response`\n against `correct_pairs` per pair to shade the rows individually.\n- **Poll question inside a graded quiz**: `is_correct: null`, every option\n `is_correct: false`, and it contributes to neither `score` nor\n `incorrect_count`. Render it as \"recorded\", not as right or wrong.\n- **Empty quiz** (no questions attached): `score: 0`, `total_questions: 0`,\n `percentage: 0`, `answers: []`.\n- **Re-open later**: call the result endpoint again; the payload is\n byte-for-byte what submit returned.\n\n## Reading the payload\n\nKeys are never omitted, so one client model parses every question and every\nanswer. **Branch on `type` and `format`, never on which keys are present.**\n\n- A question always carries `options`, `left` and `right`. A\n `match_following` question fills `left`/`right` and leaves `options` empty;\n every other type does the reverse.\n- `right` is shuffled, seeded from the question id, so the pairing is not\n given away by row order but stays stable for the same student. Match\n `left[i].id` to `right[j].id`, never by position.\n- An answer always carries `selected_option_ids`, `correct_option_ids`,\n `response` and `correct_pairs`. The first two are lists and are used by\n every type except `match_following`; the last two are objects and are used\n only by `match_following`. The unused pair is empty.\n- An answer also carries the question's own `options`, `left` and `right`,\n in the same shape the quiz detail endpoint uses, so the review screen can\n render the whole quiz back from the result alone. Each option there adds\n `is_correct` and `is_selected`, which is the short way to render a\n reviewed question without walking the id lists.\n- Every question in the quiz appears in `answers`, in the quiz's own order,\n including questions the student skipped. A skipped one has `is_answered`\n set to false, an empty selection, and `is_correct` false.\n- `clinical_image`, `dermoscopic_image`, `history`, `diagnosis` and\n `management` are always present, and are `null` unless the format is\n `clinical_case`.\n\n## Types to watch, for a statically typed client\n\n- **`percentage` is a number, not always a decimal.** JSON encoding drops a\n trailing `.0`, so a whole value serializes as `0` or `100` while a\n fractional one serializes as `33.3`. Read it as a general number and\n convert (in Dart, `(json['percentage'] as num).toDouble()`), or a whole\n percentage will fail a strict decimal cast.\n- **`is_correct` has three states**: `true`, `false`, or `null` for a poll\n question, which is recorded but never graded. It is nullable, not a plain\n boolean.\n- **`response` and `correct_pairs` are objects** (`{\"p1\": \"p1\"}`) for\n `match_following` and empty lists `[]` for every other type, so their type\n depends on `type`.\n- Everything else holds to the contract in the introduction: lists are always\n lists, counts are always integers." - name: Learning description: "\nLearning articles as the mobile app sees them. All of these require\n`Authorization: Bearer {token}`.\n\nOnly published articles are visible. Each person may like an article once;\nliking again removes the like." - name: 'Admin - Authentication' description: "\nAdmin accounts are created directly in the database; there is no sign-up\nendpoint. The token returned here authorizes every other `/api/v1/admin/*`\nendpoint via `Authorization: Bearer {token}`." - name: 'Admin - Dashboard' description: '' - name: 'Admin - Users' description: "\nEvery registered user, and the switch that activates or deactivates an\naccount. Requires an admin bearer token. Password hashes are never returned." - name: 'Admin - Cases' description: "\nReview queue for submitted cases. Requires an admin bearer token.\n\nA case carries clinical photos, dermoscopic photos, or both. `type=clinical`\nand `type=dermoscopic` match any case holding at least one photo of that\nkind, so a case holding both appears under either filter and reports its own\n`case_type` as `all`. `microscopic` is accepted as an alias for\n`dermoscopic`.\n\nEvery case here also carries its `code` (the five-character handle the app\nshows and searches on) and its pin state (`is_pinned`, `pinned_at`). The\nreview queue itself is always newest first - pinning changes the order of\nthe **app feed**, not of this list." - name: 'Admin - Questions' description: "\nThe reusable Question Bank. Requires an admin bearer token.\n\nA question moves through `draft` -> `pending_review` -> `approved` or\n`rejected`, mirroring the case review workflow. Only `approved` questions\ncan be attached to a quiz (see Admin - Quizzes)." - name: 'Admin - Quizzes' description: "\nAuthoring and monitoring of quizzes. Requires an admin bearer token.\n\nA quiz is composed of existing, approved Question Bank entries (see\nAdmin - Questions), attached via `question_ids`. `format` is `standard`\n(a plain graded quiz), `poll` (ungraded, aggregate results only), or\n`clinical_case` (a case stem shown before its questions, with diagnosis\nand management revealed to the student after submission)." - name: 'Admin - Learning' description: "\nLearning articles the app shows to users, who can like them. Requires an\nadmin bearer token.\n\nCreate and update take `multipart/form-data` because of the cover image." - name: Notifications description: "\nThe authenticated user's in-app notification feed (a comment, a like or\ndislike, a case being approved or declined, and admin broadcasts like a\npinned post, a new quiz, or a new article). Every notification also goes\nout as a push notification when the user has a saved device token; this\nfeed is the in-app record of the same events, and is what drives the\nnotifications KPI/badge in the app." - name: Users description: "\nLooking up another user's profile. Requires `Authorization: Bearer {token}`." components: securitySchemes: default: type: http scheme: bearer description: 'Obtain a token from **Register -> Verify OTP**, **Login**, or **Reset Password** - each returns a `token` field. Send it as `Authorization: Bearer {token}`.' security: - default: [] paths: /api/v1/auth/register: post: summary: Register operationId: register description: "Creates a user (unverified) and emails a 6-digit OTP. `role` defaults to\n`student` server-side. All fields except full_name, email, password, and\nphone_number are optional. Registering again with an email that hasn't been\nverified yet updates that pending record and resends the OTP, rather than\nfailing.\n\nNothing else from this response is needed by the client - `verify-otp` and\n`resend-otp` both look the account up by email, and the full profile isn't\navailable until the account is actually verified." parameters: [] responses: 201: description: 'Registered, pending verification' content: application/json: schema: type: object example: email: jane@example.com message: 'Registered successfully. Please verify the OTP sent to your email.' properties: email: type: string example: jane@example.com message: type: string example: 'Registered successfully. Please verify the OTP sent to your email.' 422: description: 'Email already registered and verified' content: application/json: schema: type: object example: message: 'The email has already been taken.' errors: email: - 'The email has already been taken.' properties: message: type: string example: 'The email has already been taken.' errors: type: object properties: email: type: array example: - 'The email has already been taken.' items: type: string tags: - Authentication requestBody: required: true content: multipart/form-data: schema: type: object properties: full_name: type: string description: "The user's full name." example: 'Dr Jane Doe' email: type: string description: "The user's email address. An OTP is sent here to verify. Must be a valid email address." example: jane@example.com password: type: string description: 'At least 8 characters. Must be at least 8 characters.' example: password123 phone_number: type: string description: "The user's phone number." example: '03001234567' designation: type: string description: 'Professional designation/title. Free text, no fixed list.' example: 'Consultant Dermatologist' bio: type: string description: 'Optional short free-text bio shown on the profile.' example: 'Dermatologist with a special interest in dermoscopy and skin cancer screening.' province: type: string description: 'Free text, no fixed list.' example: Punjab city: type: string description: 'Free text, no fixed list.' example: Lahore pmdc_number: type: string description: 'PMDC registration number, if any.' example: PMDC-12345 fellowship_number: type: string description: 'Fellowship number, if any.' example: FCPS-6789 institutional_number: type: string description: 'Institutional/employee number, if any.' example: INST-001 avatar: type: string format: binary description: 'Profile picture. Must be an image. Must not be greater than 4096 kilobytes.' nullable: true fcm_token: type: string description: "The device's Firebase Cloud Messaging token, saved against the user for push notifications." example: dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg== nullable: true required: - full_name - email - password - phone_number security: [] /api/v1/auth/register/verify-otp: post: summary: 'Verify Registration OTP' operationId: verifyRegistrationOTP description: "Verifies the OTP and marks the account verified. The client should send the\nuser to the login screen next - this does not issue a token or auto-login." parameters: [] responses: 200: description: Verified content: application/json: schema: type: object example: email: jane@example.com message: 'Email verified successfully. Please log in.' properties: email: type: string example: jane@example.com message: type: string example: 'Email verified successfully. Please log in.' 422: description: 'Invalid or expired OTP' content: application/json: schema: type: object example: message: 'The provided OTP is invalid or has expired.' errors: otp: - 'The provided OTP is invalid or has expired.' properties: message: type: string example: 'The provided OTP is invalid or has expired.' errors: type: object properties: otp: type: array example: - 'The provided OTP is invalid or has expired.' items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: 'The email used to register. Must be a valid email address.' example: jane@example.com otp: type: string description: 'The 6-digit code emailed to the user. Must be 6 digits.' example: '482913' required: - email - otp security: [] /api/v1/auth/register/resend-otp: post: summary: 'Resend Registration OTP' operationId: resendRegistrationOTP description: "Invalidates the previous registration OTP and sends a new one. Only works\nfor accounts that have not yet been verified." parameters: [] responses: 200: description: 'OTP resent' content: application/json: schema: type: object example: message: 'A new OTP has been sent to your email.' properties: message: type: string example: 'A new OTP has been sent to your email.' 422: description: 'Already verified' content: application/json: schema: type: object example: message: 'This email is already verified.' errors: email: - 'This email is already verified.' properties: message: type: string example: 'This email is already verified.' errors: type: object properties: email: type: array example: - 'This email is already verified.' items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: 'The email used to register. Must be a valid email address.' example: jane@example.com required: - email security: [] /api/v1/auth/login: post: summary: Login operationId: login description: "Authenticates a verified user and issues a new Sanctum token. Fails if the\naccount has not completed OTP verification yet." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: email: jane@example.com token: 6|To5i3l8yWR3Mb3voEv0mX0W4NK4iLQCxsmuARXfDbe3d9eab properties: email: type: string example: jane@example.com token: type: string example: 6|To5i3l8yWR3Mb3voEv0mX0W4NK4iLQCxsmuARXfDbe3d9eab 422: description: '' content: application/json: schema: oneOf: - description: 'Wrong email or password' type: object example: message: 'These credentials do not match our records.' errors: email: - 'These credentials do not match our records.' properties: message: type: string example: 'These credentials do not match our records.' errors: type: object properties: email: type: array example: - 'These credentials do not match our records.' items: type: string - description: 'Account not yet verified' type: object example: message: 'Please verify your email before logging in.' errors: email: - 'Please verify your email before logging in.' properties: message: type: string example: 'Please verify your email before logging in.' errors: type: object properties: email: type: array example: - 'Please verify your email before logging in.' items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: "The user's email address. Must be a valid email address." example: jane@example.com password: type: string description: "The user's password." example: password123 fcm_token: type: string description: "The device's Firebase Cloud Messaging token, saved against the user for push notifications." example: dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg== nullable: true required: - email - password security: [] /api/v1/auth/forgot-password: post: summary: 'Forgot Password' operationId: forgotPassword description: "Emails a 6-digit OTP for an existing account. Fails with a validation\nerror if the email is not registered. This is step 1 of 3 in the reset\nflow: Forgot Password -> Verify Forgot Password OTP -> Reset Password." parameters: [] responses: 200: description: 'OTP sent' content: application/json: schema: type: object example: message: 'An OTP has been sent to your email.' properties: message: type: string example: 'An OTP has been sent to your email.' 422: description: 'Email not registered' content: application/json: schema: type: object example: message: "We can't find a user with that email address." errors: email: - "We can't find a user with that email address." properties: message: type: string example: "We can't find a user with that email address." errors: type: object properties: email: type: array example: - "We can't find a user with that email address." items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: 'A registered email address. Must be a valid email address. Must match an existing stored value.' example: jane@example.com required: - email security: [] /api/v1/auth/forgot-password/verify-otp: post: summary: 'Verify Forgot Password OTP' operationId: verifyForgotPasswordOTP description: "Verifies the password-reset OTP and returns a short-lived `reset_token`\n(60 minutes, single-use) that must be passed to Reset Password. This is\nstep 2 of 3 in the reset flow." parameters: [] responses: 200: description: 'OTP verified' content: application/json: schema: type: object example: message: 'OTP verified. Use the reset token to set a new password.' reset_token: SZyXkavPnBBJTtfAKD7L89NRvmbhHfopzoDbNt8982c1B16sRHdFJu8EOzNpz8c5 properties: message: type: string example: 'OTP verified. Use the reset token to set a new password.' reset_token: type: string example: SZyXkavPnBBJTtfAKD7L89NRvmbhHfopzoDbNt8982c1B16sRHdFJu8EOzNpz8c5 422: description: 'Invalid or expired OTP' content: application/json: schema: type: object example: message: 'The provided OTP is invalid or has expired.' errors: otp: - 'The provided OTP is invalid or has expired.' properties: message: type: string example: 'The provided OTP is invalid or has expired.' errors: type: object properties: otp: type: array example: - 'The provided OTP is invalid or has expired.' items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: 'A registered email address. Must be a valid email address. Must match an existing stored value.' example: jane@example.com otp: type: string description: 'The 6-digit code emailed to the user. Must be 6 digits.' example: '482913' required: - email - otp security: [] /api/v1/auth/forgot-password/resend-otp: post: summary: 'Resend Forgot Password OTP' operationId: resendForgotPasswordOTP description: 'Invalidates the previous password-reset OTP and sends a new one.' parameters: [] responses: 200: description: 'OTP resent' content: application/json: schema: type: object example: message: 'A new OTP has been sent to your email.' properties: message: type: string example: 'A new OTP has been sent to your email.' 422: description: 'Email not registered' content: application/json: schema: type: object example: message: "We can't find a user with that email address." errors: email: - "We can't find a user with that email address." properties: message: type: string example: "We can't find a user with that email address." errors: type: object properties: email: type: array example: - "We can't find a user with that email address." items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: 'A registered email address. Must be a valid email address. Must match an existing stored value.' example: jane@example.com required: - email security: [] /api/v1/auth/reset-password: post: summary: 'Reset Password' operationId: resetPassword description: "Sets a new password using the `reset_token` from Verify Forgot Password OTP.\nThis is step 3 of 3 in the reset flow. Revokes all previously issued tokens\nand returns a fresh one (auto-login) - store the new token." parameters: [] responses: 200: description: 'Password reset' content: application/json: schema: type: object example: email: jane@example.com message: 'Password reset successfully.' token: 5|DUmbGpsWeDowDywPGdLTLsL53pceVqfWodvWwltNfaf1850e properties: email: type: string example: jane@example.com message: type: string example: 'Password reset successfully.' token: type: string example: 5|DUmbGpsWeDowDywPGdLTLsL53pceVqfWodvWwltNfaf1850e 422: description: 'Invalid, expired, or already-used reset token' content: application/json: schema: type: object example: message: 'The provided reset token is invalid or has expired.' errors: reset_token: - 'The provided reset token is invalid or has expired.' properties: message: type: string example: 'The provided reset token is invalid or has expired.' errors: type: object properties: reset_token: type: array example: - 'The provided reset token is invalid or has expired.' items: type: string tags: - Authentication requestBody: required: true content: application/json: schema: type: object properties: email: type: string description: 'A registered email address. Must be a valid email address. Must match an existing stored value.' example: jane@example.com reset_token: type: string description: 'The token returned by Verify Forgot Password OTP.' example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 new_password: type: string description: 'At least 8 characters. Must be at least 8 characters.' example: newPassword123 required: - email - reset_token - new_password security: [] /api/v1/auth/logout: post: summary: Logout operationId: logout description: "Revokes only the token used to authenticate this request (the current\ndevice/session). Other devices stay logged in." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: message: 'Logged out successfully.' properties: message: type: string example: 'Logged out successfully.' tags: - Authentication /api/v1/profile: get: summary: 'Get Profile' operationId: getProfile description: "Returns the authenticated user's profile." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: data: id: 2 full_name: 'Dr Jane Doe' email: jane@example.com phone_number: '03007654321' designation: Consultant bio: 'Dermatologist with a special interest in dermoscopy.' province: Punjab city: Lahore pmdc_number: PMDC-1234 fellowship_number: FEL-5678 institutional_number: INST-9012 role: student avatar_url: null is_verified: true created_at: '2026-07-27T10:08:49.000000Z' properties: data: type: object properties: id: type: integer example: 2 full_name: type: string example: 'Dr Jane Doe' email: type: string example: jane@example.com phone_number: type: string example: '03007654321' designation: type: string example: Consultant bio: type: string example: 'Dermatologist with a special interest in dermoscopy.' province: type: string example: Punjab city: type: string example: Lahore pmdc_number: type: string example: PMDC-1234 fellowship_number: type: string example: FEL-5678 institutional_number: type: string example: INST-9012 role: type: string example: student avatar_url: type: string example: null nullable: true is_verified: type: boolean example: true created_at: type: string example: '2026-07-27T10:08:49.000000Z' tags: - Account put: summary: 'Update Profile' operationId: updateProfile description: "Partially updates the authenticated user's profile - only send the fields\nyou want to change. The email cannot be changed here. To change the avatar,\nuse Update Profile Picture instead." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: data: id: 2 full_name: 'New Name' email: jane@example.com phone_number: '03009998888' designation: Consultant bio: 'Dermatologist with a special interest in dermoscopy.' province: Punjab city: Karachi pmdc_number: PMDC-1234 fellowship_number: FEL-5678 institutional_number: INST-9012 role: student avatar_url: null is_verified: true created_at: '2026-07-27T10:08:49.000000Z' message: 'Profile updated successfully.' properties: data: type: object properties: id: type: integer example: 2 full_name: type: string example: 'New Name' email: type: string example: jane@example.com phone_number: type: string example: '03009998888' designation: type: string example: Consultant bio: type: string example: 'Dermatologist with a special interest in dermoscopy.' province: type: string example: Punjab city: type: string example: Karachi pmdc_number: type: string example: PMDC-1234 fellowship_number: type: string example: FEL-5678 institutional_number: type: string example: INST-9012 role: type: string example: student avatar_url: type: string example: null nullable: true is_verified: type: boolean example: true created_at: type: string example: '2026-07-27T10:08:49.000000Z' message: type: string example: 'Profile updated successfully.' tags: - Account requestBody: required: false content: application/json: schema: type: object properties: full_name: type: string description: 'Only send fields you want to change.' example: 'Dr Jane A. Doe' phone_number: type: string description: 'Free text.' example: '03009998888' designation: type: string description: 'Free text, no fixed list.' example: 'Consultant Dermatologist' bio: type: string description: 'Short free-text bio shown on the profile. Send an empty string to clear it back to null.' example: 'Dermatologist with a special interest in dermoscopy and skin cancer screening.' province: type: string description: 'Free text, no fixed list.' example: Sindh city: type: string description: 'Free text, no fixed list.' example: Karachi pmdc_number: type: string description: 'PMDC registration number, if any.' example: PMDC-12345 fellowship_number: type: string description: 'Fellowship number, if any.' example: FCPS-6789 institutional_number: type: string description: 'Institutional/employee number, if any.' example: INST-001 /api/v1/profile/avatar: post: summary: 'Update Profile Picture' operationId: updateProfilePicture description: "Replaces the authenticated user's avatar. Deletes the previous file, if any." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: avatar_url: 'http://dots-app.test/storage/avatars/example.png' message: 'Profile picture updated successfully.' properties: avatar_url: type: string example: 'http://dots-app.test/storage/avatars/example.png' message: type: string example: 'Profile picture updated successfully.' tags: - Account requestBody: required: true content: multipart/form-data: schema: type: object properties: avatar: type: string format: binary description: 'The new profile picture. Replaces and deletes the previous one. Must be an image. Must not be greater than 4096 kilobytes.' required: - avatar /api/v1/change-password: post: summary: 'Change Password' operationId: changePassword description: "Changes the authenticated user's password. Revokes ALL existing tokens\n(including the one used for this request) and returns a fresh one - the\napp must store the new token." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: message: 'Password changed successfully.' token: 14|MxL5u7YFFFDzlMkt3weFmTMGZYN57LWhBek5XYDef754b4ea properties: message: type: string example: 'Password changed successfully.' token: type: string example: 14|MxL5u7YFFFDzlMkt3weFmTMGZYN57LWhBek5XYDef754b4ea 422: description: 'Wrong current password' content: application/json: schema: type: object example: message: 'The provided password is incorrect.' errors: current_password: - 'The provided password is incorrect.' properties: message: type: string example: 'The provided password is incorrect.' errors: type: object properties: current_password: type: array example: - 'The provided password is incorrect.' items: type: string tags: - Account requestBody: required: true content: application/json: schema: type: object properties: current_password: type: string description: "The user's current password." example: password123 new_password: type: string description: 'At least 8 characters, must differ from the current password. The value and current_password must be different. Must be at least 8 characters.' example: newPassword123 required: - current_password - new_password /api/v1/account: delete: summary: 'Delete Account' operationId: deleteAccount description: "Permanently deletes the authenticated user's account after confirming\nthe current password. Also deletes the avatar file and revokes all\ntokens. This is destructive and cannot be undone." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: message: 'Account deleted successfully.' properties: message: type: string example: 'Account deleted successfully.' 422: description: 'Wrong password' content: application/json: schema: type: object example: message: 'The provided password is incorrect.' errors: current_password: - 'The provided password is incorrect.' properties: message: type: string example: 'The provided password is incorrect.' errors: type: object properties: current_password: type: array example: - 'The provided password is incorrect.' items: type: string tags: - Account requestBody: required: true content: application/json: schema: type: object properties: current_password: type: string description: "The user's current password, to confirm this destructive action." example: password123 required: - current_password /api/v1/cases/mine: get: summary: 'List My Cases (History)' operationId: listMyCasesHistory description: "List of only the authenticated user's own cases, newest first. This is\nthe one place a submitter sees their `pending` and `rejected` cases, so\na rejected case here carries the admin's `rejection_reason`; editing it\nresubmits it and clears the reason.\n\nPins do **not** reorder this list: a case of yours that an admin pinned\nstill sits in date order here, with `is_pinned: true` on it.\n\nAccepts the same `search` param as the feed, so a submitter can find\ntheir own case by its code - including one still awaiting review, which\nthe feed search cannot see. Paginated with `skip`/`limit` query params - `skip` defaults to 0, `limit` defaults\nto 15 (max 100). The client is responsible for advancing `skip` on\nsubsequent requests (e.g. `skip=15` for the next page after a `limit=15`\nfirst page) and for stopping once `skip + limit >= meta.total`." parameters: - in: query name: skip description: 'Number of cases to skip. Defaults to 0.' example: 0 required: false schema: type: integer description: 'Number of cases to skip. Defaults to 0.' example: 0 - in: query name: limit description: 'Max cases to return (capped at 100). Defaults to 15.' example: 15 required: false schema: type: integer description: 'Max cases to return (capped at 100). Defaults to 15.' example: 15 - in: query name: search description: 'A case code, or text to match against diagnosis or body site.' example: K7F2Q required: false schema: type: string description: 'A case code, or text to match against diagnosis or body site.' example: K7F2Q responses: 200: description: '' content: application/json: schema: oneOf: - description: 'Success - own cases, including pending and rejected' type: object example: data: - id: 1 code: K7F2Q user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: pending is_pinned: false pinned_at: null pinned_by: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: melanoma histopathology: null images: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: [] comments_count: 2 comments: [] created_at: '2026-07-28T06:41:56.000000Z' meta: skip: 0 limit: 15 total: 1 properties: data: type: array example: - id: 1 code: K7F2Q user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: pending is_pinned: false pinned_at: null pinned_by: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: melanoma histopathology: null images: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: [] comments_count: 2 comments: [] created_at: '2026-07-28T06:41:56.000000Z' items: type: object properties: id: type: integer example: 1 code: type: string example: K7F2Q user: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' avatar_url: type: string example: null nullable: true status: type: string example: pending is_pinned: type: boolean example: false pinned_at: type: string example: null nullable: true pinned_by: type: string example: null nullable: true age: type: integer example: 52 gender: type: string example: male fitzpatrick_skin_type: type: string example: III body_site: type: string example: trunk pdf: type: object properties: url: type: string example: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: type: string example: report.pdf size: type: integer example: 148213 clinical: type: object properties: diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' items: type: object properties: id: { type: integer, example: 1 } url: { type: string, example: 'http://dots-app.test/storage/cases/1/clinical/example.png' } dermoscopic: type: object properties: lesion_type: type: string example: melanocytic features: type: array example: - 'pigment network' - streaks items: type: string vascular_pattern: type: array example: - dotted items: type: string colours_present: type: array example: - black - brown items: type: string scale: type: string example: fine pattern: type: string example: reticular image_metadata: type: array example: - polarized items: type: string diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: [] comments_count: type: integer example: 2 comments: type: array example: [] created_at: type: string example: '2026-07-28T06:41:56.000000Z' meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 1 - description: 'a rejected case of your own, with the reason' type: object example: data: - id: 4 code: P8VNC user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: rejected is_pinned: false pinned_at: null pinned_by: null rejection_reason: 'Photos are out of focus.' age: 61 gender: male fitzpatrick_skin_type: IV body_site: 'lower limb' pdf: null clinical: diagnosis: nevus histopathology: null images: - id: 30 url: 'https://www.dots.mhn.services/storage/cases/4/clinical/blurry.png' dermoscopic: lesion_type: null features: [] vascular_pattern: [] colours_present: [] scale: null pattern: null image_metadata: [] diagnosis: null histopathology: null images: [] comments_count: 0 comments: [] created_at: '2026-09-02T08:12:00.000000Z' meta: skip: 0 limit: 15 total: 1 properties: data: type: array example: - id: 4 code: P8VNC user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: rejected is_pinned: false pinned_at: null pinned_by: null rejection_reason: 'Photos are out of focus.' age: 61 gender: male fitzpatrick_skin_type: IV body_site: 'lower limb' pdf: null clinical: diagnosis: nevus histopathology: null images: - id: 30 url: 'https://www.dots.mhn.services/storage/cases/4/clinical/blurry.png' dermoscopic: lesion_type: null features: [] vascular_pattern: [] colours_present: [] scale: null pattern: null image_metadata: [] diagnosis: null histopathology: null images: [] comments_count: 0 comments: [] created_at: '2026-09-02T08:12:00.000000Z' items: type: object properties: id: type: integer example: 4 code: type: string example: P8VNC user: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' avatar_url: type: string example: null nullable: true status: type: string example: rejected is_pinned: type: boolean example: false pinned_at: type: string example: null nullable: true pinned_by: type: string example: null nullable: true rejection_reason: type: string example: 'Photos are out of focus.' age: type: integer example: 61 gender: type: string example: male fitzpatrick_skin_type: type: string example: IV body_site: type: string example: 'lower limb' pdf: type: string example: null nullable: true clinical: type: object properties: diagnosis: type: string example: nevus histopathology: type: string example: null nullable: true images: type: array example: - id: 30 url: 'https://www.dots.mhn.services/storage/cases/4/clinical/blurry.png' items: type: object properties: id: { type: integer, example: 30 } url: { type: string, example: 'https://www.dots.mhn.services/storage/cases/4/clinical/blurry.png' } dermoscopic: type: object properties: lesion_type: type: string example: null nullable: true features: type: array example: [] vascular_pattern: type: array example: [] colours_present: type: array example: [] scale: type: string example: null nullable: true pattern: type: string example: null nullable: true image_metadata: type: array example: [] diagnosis: type: string example: null nullable: true histopathology: type: string example: null nullable: true images: type: array example: [] comments_count: type: integer example: 0 comments: type: array example: [] created_at: type: string example: '2026-09-02T08:12:00.000000Z' meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 1 - description: 'you have not submitted anything yet' type: object example: data: [] meta: skip: 0 limit: 15 total: 0 properties: data: type: array example: [] meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 0 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. tags: - Cases /api/v1/cases: get: summary: 'List Cases (Feed)' operationId: listCasesFeed description: "Feed of approved cases from every user. Cases an admin pinned come\nfirst, most recently pinned first; the rest follow newest first. Cases\nawaiting review, and cases an admin rejected, are not in the feed; a\nsubmitter still sees their own in `cases/mine`.\n\nPass `search` to filter the feed. A five-character case code matches\nthat one case exactly; any other term is matched as a fragment of the\nclinical diagnosis, the dermoscopic diagnosis, or the body site.\n\nPaginated with `skip`/`limit` query params - `skip` defaults to 0,\n`limit` defaults to 15 (max 100).\nThe client is responsible for advancing `skip` on subsequent requests\n(e.g. `skip=15` for the next page after a `limit=15` first page) and for\nstopping once `skip + limit >= meta.total`." parameters: - in: query name: skip description: 'Number of cases to skip. Defaults to 0.' example: 0 required: false schema: type: integer description: 'Number of cases to skip. Defaults to 0.' example: 0 - in: query name: limit description: 'Max cases to return (capped at 100). Defaults to 15.' example: 15 required: false schema: type: integer description: 'Max cases to return (capped at 100). Defaults to 15.' example: 15 - in: query name: search description: 'A case code, or text to match against diagnosis or body site.' example: K7F2Q required: false schema: type: string description: 'A case code, or text to match against diagnosis or body site.' example: K7F2Q responses: 200: description: '' content: application/json: schema: oneOf: - description: 'Success - a pinned case leads the feed' type: object example: data: - id: 8 code: M4XTB user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: approved is_pinned: true pinned_at: '2026-09-05T09:15:00.000000Z' pinned_by: id: 1 full_name: 'Dots Admin' avatar_url: null rejection_reason: null age: 34 gender: female fitzpatrick_skin_type: II body_site: face pdf: null clinical: diagnosis: 'basal cell carcinoma' histopathology: null images: - id: 12 url: 'https://www.dots.mhn.services/storage/cases/8/clinical/lesion.png' dermoscopic: lesion_type: non-melanocytic features: - 'arborizing vessels' vascular_pattern: - arborizing colours_present: - pink scale: null pattern: null image_metadata: [] diagnosis: null histopathology: null images: [] comments_count: 5 comments: [] created_at: '2026-08-14T11:02:31.000000Z' - id: 12 code: K7F2Q user: id: 5 full_name: 'Dr John Roe' avatar_url: null status: approved is_pinned: false pinned_at: null pinned_by: null rejection_reason: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'https://www.dots.mhn.services/storage/cases/12/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: melanoma histopathology: null images: - id: 18 url: 'https://www.dots.mhn.services/storage/cases/12/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: - id: 19 url: 'https://www.dots.mhn.services/storage/cases/12/dermoscopic/example.png' comments_count: 2 comments: [] created_at: '2026-09-01T06:41:56.000000Z' meta: skip: 0 limit: 15 total: 2 properties: data: type: array example: - id: 8 code: M4XTB user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: approved is_pinned: true pinned_at: '2026-09-05T09:15:00.000000Z' pinned_by: id: 1 full_name: 'Dots Admin' avatar_url: null rejection_reason: null age: 34 gender: female fitzpatrick_skin_type: II body_site: face pdf: null clinical: diagnosis: 'basal cell carcinoma' histopathology: null images: - id: 12 url: 'https://www.dots.mhn.services/storage/cases/8/clinical/lesion.png' dermoscopic: lesion_type: non-melanocytic features: - 'arborizing vessels' vascular_pattern: - arborizing colours_present: - pink scale: null pattern: null image_metadata: [] diagnosis: null histopathology: null images: [] comments_count: 5 comments: [] created_at: '2026-08-14T11:02:31.000000Z' - id: 12 code: K7F2Q user: id: 5 full_name: 'Dr John Roe' avatar_url: null status: approved is_pinned: false pinned_at: null pinned_by: null rejection_reason: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'https://www.dots.mhn.services/storage/cases/12/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: melanoma histopathology: null images: - id: 18 url: 'https://www.dots.mhn.services/storage/cases/12/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: - id: 19 url: 'https://www.dots.mhn.services/storage/cases/12/dermoscopic/example.png' comments_count: 2 comments: [] created_at: '2026-09-01T06:41:56.000000Z' items: type: object properties: id: type: integer example: 8 code: type: string example: M4XTB user: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' avatar_url: type: string example: null nullable: true status: type: string example: approved is_pinned: type: boolean example: true pinned_at: type: string example: '2026-09-05T09:15:00.000000Z' pinned_by: type: object properties: id: type: integer example: 1 full_name: type: string example: 'Dots Admin' avatar_url: type: string example: null nullable: true rejection_reason: type: string example: null nullable: true age: type: integer example: 34 gender: type: string example: female fitzpatrick_skin_type: type: string example: II body_site: type: string example: face pdf: type: string example: null nullable: true clinical: type: object properties: diagnosis: type: string example: 'basal cell carcinoma' histopathology: type: string example: null nullable: true images: type: array example: - id: 12 url: 'https://www.dots.mhn.services/storage/cases/8/clinical/lesion.png' items: type: object properties: id: { type: integer, example: 12 } url: { type: string, example: 'https://www.dots.mhn.services/storage/cases/8/clinical/lesion.png' } dermoscopic: type: object properties: lesion_type: type: string example: non-melanocytic features: type: array example: - 'arborizing vessels' items: type: string vascular_pattern: type: array example: - arborizing items: type: string colours_present: type: array example: - pink items: type: string scale: type: string example: null nullable: true pattern: type: string example: null nullable: true image_metadata: type: array example: [] diagnosis: type: string example: null nullable: true histopathology: type: string example: null nullable: true images: type: array example: [] comments_count: type: integer example: 5 comments: type: array example: [] created_at: type: string example: '2026-08-14T11:02:31.000000Z' meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 2 - description: 'search by case code - one exact match' type: object example: data: - id: 12 code: K7F2Q user: id: 5 full_name: 'Dr John Roe' avatar_url: null status: approved is_pinned: false pinned_at: null pinned_by: null rejection_reason: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: null clinical: diagnosis: melanoma histopathology: null images: - id: 18 url: 'https://www.dots.mhn.services/storage/cases/12/clinical/example.png' dermoscopic: lesion_type: melanocytic features: [] vascular_pattern: [] colours_present: [] scale: null pattern: null image_metadata: [] diagnosis: null histopathology: null images: [] comments_count: 2 comments: [] created_at: '2026-09-01T06:41:56.000000Z' meta: skip: 0 limit: 15 total: 1 properties: data: type: array example: - id: 12 code: K7F2Q user: id: 5 full_name: 'Dr John Roe' avatar_url: null status: approved is_pinned: false pinned_at: null pinned_by: null rejection_reason: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: null clinical: diagnosis: melanoma histopathology: null images: - id: 18 url: 'https://www.dots.mhn.services/storage/cases/12/clinical/example.png' dermoscopic: lesion_type: melanocytic features: [] vascular_pattern: [] colours_present: [] scale: null pattern: null image_metadata: [] diagnosis: null histopathology: null images: [] comments_count: 2 comments: [] created_at: '2026-09-01T06:41:56.000000Z' items: type: object properties: id: type: integer example: 12 code: type: string example: K7F2Q user: type: object properties: id: type: integer example: 5 full_name: type: string example: 'Dr John Roe' avatar_url: type: string example: null nullable: true status: type: string example: approved is_pinned: type: boolean example: false pinned_at: type: string example: null nullable: true pinned_by: type: string example: null nullable: true rejection_reason: type: string example: null nullable: true age: type: integer example: 52 gender: type: string example: male fitzpatrick_skin_type: type: string example: III body_site: type: string example: trunk pdf: type: string example: null nullable: true clinical: type: object properties: diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 18 url: 'https://www.dots.mhn.services/storage/cases/12/clinical/example.png' items: type: object properties: id: { type: integer, example: 18 } url: { type: string, example: 'https://www.dots.mhn.services/storage/cases/12/clinical/example.png' } dermoscopic: type: object properties: lesion_type: type: string example: melanocytic features: type: array example: [] vascular_pattern: type: array example: [] colours_present: type: array example: [] scale: type: string example: null nullable: true pattern: type: string example: null nullable: true image_metadata: type: array example: [] diagnosis: type: string example: null nullable: true histopathology: type: string example: null nullable: true images: type: array example: [] comments_count: type: integer example: 2 comments: type: array example: [] created_at: type: string example: '2026-09-01T06:41:56.000000Z' meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 1 - description: 'search matched nothing - empty list, not a 404' type: object example: data: [] meta: skip: 0 limit: 15 total: 0 properties: data: type: array example: [] meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 0 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. tags: - Cases post: summary: 'Submit Case' operationId: submitCase description: "Submits a new case for review. At least one of `clinical_images[]` or\n`dermoscopic_images[]` is required (both accept multiple files);\neverything else is optional. `dermoscopic_features[]`, `vascular_pattern[]`,\n`colours_present[]`, and `image_metadata[]` are multi-select - repeat the\nkey for each value.\n\nA single supporting PDF can be attached as `pdf` (up to 20 MB). It comes\nback on every case response as a `pdf` object with `url`, `name`, and\n`size`, or as `null` when the case has no PDF.\n\nThe response carries the server-assigned `code` for the new case. Show\nit to the submitter after a successful submission - it is how they, or\nanyone they give it to, find the case again through search. The client\nnever generates or sends a code, and a code sent in the request body is\nignored.\n\nA new case is always `status: \"pending\"`, `is_pinned: false`, and has an\nempty `comments` list with `comments_count: 0`." parameters: [] responses: 201: description: 'Case submitted' content: application/json: schema: type: object example: data: id: 1 code: K7F2Q user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: pending is_pinned: false pinned_at: null pinned_by: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: melanoma histopathology: null images: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: - id: 2 url: 'http://dots-app.test/storage/cases/1/dermoscopic/example.png' comments_count: 0 comments: [] created_at: '2026-07-28T06:41:56.000000Z' message: 'Case submitted for review.' properties: data: type: object properties: id: type: integer example: 1 code: type: string example: K7F2Q user: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' avatar_url: type: string example: null nullable: true status: type: string example: pending is_pinned: type: boolean example: false pinned_at: type: string example: null nullable: true pinned_by: type: string example: null nullable: true age: type: integer example: 52 gender: type: string example: male fitzpatrick_skin_type: type: string example: III body_site: type: string example: trunk pdf: type: object properties: url: type: string example: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: type: string example: report.pdf size: type: integer example: 148213 clinical: type: object properties: diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' items: type: object properties: id: type: integer example: 1 url: type: string example: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: type: object properties: lesion_type: type: string example: melanocytic features: type: array example: - 'pigment network' - streaks items: type: string vascular_pattern: type: array example: - dotted items: type: string colours_present: type: array example: - black - brown items: type: string scale: type: string example: fine pattern: type: string example: reticular image_metadata: type: array example: - polarized items: type: string diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 2 url: 'http://dots-app.test/storage/cases/1/dermoscopic/example.png' items: type: object properties: id: type: integer example: 2 url: type: string example: 'http://dots-app.test/storage/cases/1/dermoscopic/example.png' comments_count: type: integer example: 0 comments: type: array example: [] created_at: type: string example: '2026-07-28T06:41:56.000000Z' message: type: string example: 'Case submitted for review.' 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. 422: description: 'No images attached' content: application/json: schema: type: object example: message: 'Upload at least one clinical or dermoscopic image.' errors: clinical_images: - 'Upload at least one clinical or dermoscopic image.' dermoscopic_images: - 'Upload at least one clinical or dermoscopic image.' properties: message: type: string example: 'Upload at least one clinical or dermoscopic image.' errors: type: object properties: clinical_images: type: array example: - 'Upload at least one clinical or dermoscopic image.' items: type: string dermoscopic_images: type: array example: - 'Upload at least one clinical or dermoscopic image.' items: type: string tags: - Cases requestBody: required: false content: multipart/form-data: schema: type: object properties: clinical_images: type: array description: 'One or more clinical photo files. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.' items: type: string format: binary dermoscopic_images: type: array description: 'One or more dermoscopic photo files. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.' items: type: string format: binary pdf: type: string format: binary description: 'Optional supporting PDF, e.g. a report or histopathology sheet. One file, up to 20 MB. Must be a file. Must not be greater than 20480 kilobytes.' nullable: true age: type: string description: 'Patient age in years.' example: 52 gender: type: string description: 'Patient gender.' example: male fitzpatrick_skin_type: type: string description: 'Fitzpatrick skin type.' example: III body_site: type: string description: 'Anatomical site of the lesion.' example: trunk clinical_diagnosis: type: string description: 'Clinical (visual) diagnosis.' example: melanoma clinical_histopathology: type: string description: 'Clinical histopathology findings, if biopsied.' example: 'Superficial spreading melanoma, Breslow depth 0.8mm' lesion_type: type: string description: 'Lesion classification.' example: melanocytic dermoscopic_features: type: string description: 'Multi-select. Repeat the key for each value.' example: - 'pigment network' - streaks vascular_pattern: type: string description: 'Multi-select. Repeat the key for each value.' example: - dotted colours_present: type: string description: 'Multi-select. Repeat the key for each value.' example: - black - brown scale: type: string description: 'FotoFinder-schema dermoscopy field.' example: fine pattern: type: string description: 'FotoFinder-schema dermoscopy field.' example: reticular image_metadata: type: string description: 'Multi-select. Repeat the key for each value.' example: - polarized dermoscopic_diagnosis: type: string description: 'Dermoscopic diagnosis.' example: melanoma dermoscopic_histopathology: type: string description: 'Dermoscopic histopathology findings, if biopsied.' example: 'Not biopsied' '/api/v1/cases/{id}': get: summary: 'Get Case' operationId: getCase description: "Shows a single case with its images and comments (each comment includes\nthe commenter's name/photo, like/dislike counts, and the authenticated\nuser's own reaction, if any, as `my_reaction`).\n\nThis is the only endpoint that fills `comments`; the list endpoints\nleave it `[]`. The case is addressed by numeric `id`, not by `code` -\nto open a case from a code, search the feed for the code and use the\n`id` you get back.\n\nAny authenticated user can read any approved case here, so the pin\nfields are visible to everyone and a viewer who is not the submitter\nstill sees `is_pinned` and `pinned_by`." parameters: [] responses: 200: description: 'Success - a pinned case with its comments' content: application/json: schema: type: object example: data: id: 1 code: K7F2Q user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: approved is_pinned: true pinned_at: '2026-09-05T09:15:00.000000Z' pinned_by: id: 1 full_name: 'Dots Admin' avatar_url: null age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: melanoma histopathology: null images: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: - id: 2 url: 'http://dots-app.test/storage/cases/1/dermoscopic/example.png' comments_count: 1 comments: - id: 5 body: 'Great case, thanks for sharing.' user: id: 4 full_name: 'Dr John Roe' avatar_url: null likes_count: 2 dislikes_count: 0 my_reaction: like created_at: '2026-07-29T09:00:00.000000Z' created_at: '2026-07-28T06:41:56.000000Z' properties: data: type: object properties: id: type: integer example: 1 code: type: string example: K7F2Q user: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' avatar_url: type: string example: null nullable: true status: type: string example: approved is_pinned: type: boolean example: true pinned_at: type: string example: '2026-09-05T09:15:00.000000Z' pinned_by: type: object properties: id: type: integer example: 1 full_name: type: string example: 'Dots Admin' avatar_url: type: string example: null nullable: true age: type: integer example: 52 gender: type: string example: male fitzpatrick_skin_type: type: string example: III body_site: type: string example: trunk pdf: type: object properties: url: type: string example: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: type: string example: report.pdf size: type: integer example: 148213 clinical: type: object properties: diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' items: type: object properties: id: type: integer example: 1 url: type: string example: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: type: object properties: lesion_type: type: string example: melanocytic features: type: array example: - 'pigment network' - streaks items: type: string vascular_pattern: type: array example: - dotted items: type: string colours_present: type: array example: - black - brown items: type: string scale: type: string example: fine pattern: type: string example: reticular image_metadata: type: array example: - polarized items: type: string diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 2 url: 'http://dots-app.test/storage/cases/1/dermoscopic/example.png' items: type: object properties: id: type: integer example: 2 url: type: string example: 'http://dots-app.test/storage/cases/1/dermoscopic/example.png' comments_count: type: integer example: 1 comments: type: array example: - id: 5 body: 'Great case, thanks for sharing.' user: id: 4 full_name: 'Dr John Roe' avatar_url: null likes_count: 2 dislikes_count: 0 my_reaction: like created_at: '2026-07-29T09:00:00.000000Z' items: type: object properties: id: type: integer example: 5 body: type: string example: 'Great case, thanks for sharing.' user: type: object properties: id: type: integer example: 4 full_name: type: string example: 'Dr John Roe' avatar_url: type: string example: null nullable: true likes_count: type: integer example: 2 dislikes_count: type: integer example: 0 my_reaction: type: string example: like created_at: type: string example: '2026-07-29T09:00:00.000000Z' created_at: type: string example: '2026-07-28T06:41:56.000000Z' 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. 404: description: 'No case with that id' content: application/json: schema: type: object example: message: 'No query results for model [App\Models\ClinicalCase] 999' properties: message: type: string example: 'No query results for model [App\Models\ClinicalCase] 999' tags: - Cases put: summary: 'Update Case' operationId: updateCase description: "Partially updates a case you own - only send the fields you want to\nchange. Add new images with `new_clinical_images[]` /\n`new_dermoscopic_images[]`, and remove existing ones by ID with\n`remove_image_ids[]`. Send `pdf` to attach or replace the case's PDF, or\n`remove_pdf=true` to delete it.\n\nEditing a case an admin rejected resubmits it: its status returns to\n`pending` and the previous `rejection_reason` is cleared.\n\nAn edit never changes the case's `code`, and never changes its pin: a\npinned case stays pinned with the same `pinned_at` through an edit, and\nthrough a rejection and resubmission." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: data: id: 1 code: K7F2Q user: id: 3 full_name: 'Dr Jane Doe' avatar_url: null status: pending is_pinned: false pinned_at: null pinned_by: null age: 53 gender: male fitzpatrick_skin_type: III body_site: trunk pdf: url: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: report.pdf size: 148213 clinical: diagnosis: 'melanoma, revised' histopathology: null images: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: lesion_type: melanocytic features: - 'pigment network' - streaks vascular_pattern: - dotted colours_present: - black - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: [] comments_count: 0 comments: [] created_at: '2026-07-28T06:41:56.000000Z' message: 'Case updated successfully.' properties: data: type: object properties: id: type: integer example: 1 code: type: string example: K7F2Q user: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' avatar_url: type: string example: null nullable: true status: type: string example: pending is_pinned: type: boolean example: false pinned_at: type: string example: null nullable: true pinned_by: type: string example: null nullable: true age: type: integer example: 53 gender: type: string example: male fitzpatrick_skin_type: type: string example: III body_site: type: string example: trunk pdf: type: object properties: url: type: string example: 'http://dots-app.test/storage/cases/1/pdf/report.pdf' name: type: string example: report.pdf size: type: integer example: 148213 clinical: type: object properties: diagnosis: type: string example: 'melanoma, revised' histopathology: type: string example: null nullable: true images: type: array example: - id: 1 url: 'http://dots-app.test/storage/cases/1/clinical/example.png' items: type: object properties: id: type: integer example: 1 url: type: string example: 'http://dots-app.test/storage/cases/1/clinical/example.png' dermoscopic: type: object properties: lesion_type: type: string example: melanocytic features: type: array example: - 'pigment network' - streaks items: type: string vascular_pattern: type: array example: - dotted items: type: string colours_present: type: array example: - black - brown items: type: string scale: type: string example: fine pattern: type: string example: reticular image_metadata: type: array example: - polarized items: type: string diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: [] comments_count: type: integer example: 0 comments: type: array example: [] created_at: type: string example: '2026-07-28T06:41:56.000000Z' message: type: string example: 'Case updated successfully.' 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. 403: description: 'Not the case owner' content: application/json: schema: type: object example: message: 'This action is unauthorized.' properties: message: type: string example: 'This action is unauthorized.' 404: description: 'No case with that id' content: application/json: schema: type: object example: message: 'No query results for model [App\Models\ClinicalCase] 999' properties: message: type: string example: 'No query results for model [App\Models\ClinicalCase] 999' tags: - Cases requestBody: required: false content: multipart/form-data: schema: type: object properties: new_clinical_images: type: array description: 'One or more new clinical photo files to add. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.' items: type: string format: binary new_dermoscopic_images: type: array description: 'One or more new dermoscopic photo files to add. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.' items: type: string format: binary remove_image_ids: type: array description: 'ID of an existing image (clinical or dermoscopic) to delete. Repeat the key for each ID. Must match an existing stored value.' example: - 3 items: type: integer pdf: type: string format: binary description: 'A PDF to attach, replacing the current one if there is one. One file, up to 20 MB. Must be a file. Must not be greater than 20480 kilobytes.' remove_pdf: type: boolean description: 'Send true to detach and delete the current PDF. Ignored when a new `pdf` is sent.' example: false age: type: string description: 'Patient age in years.' example: 53 gender: type: string description: 'Patient gender.' example: male fitzpatrick_skin_type: type: string description: 'Fitzpatrick skin type.' example: III body_site: type: string description: 'Anatomical site of the lesion.' example: trunk clinical_diagnosis: type: string description: 'Clinical (visual) diagnosis.' example: 'melanoma, revised' clinical_histopathology: type: string description: 'Clinical histopathology findings, if biopsied.' example: 'Superficial spreading melanoma, Breslow depth 0.8mm' lesion_type: type: string description: 'Lesion classification.' example: melanocytic dermoscopic_features: type: string description: 'Multi-select. Repeat the key for each value.' example: - 'pigment network' - streaks vascular_pattern: type: string description: 'Multi-select. Repeat the key for each value.' example: - dotted colours_present: type: string description: 'Multi-select. Repeat the key for each value.' example: - black - brown scale: type: string description: 'FotoFinder-schema dermoscopy field.' example: fine pattern: type: string description: 'FotoFinder-schema dermoscopy field.' example: reticular image_metadata: type: string description: 'Multi-select. Repeat the key for each value.' example: - polarized dermoscopic_diagnosis: type: string description: 'Dermoscopic diagnosis.' example: melanoma dermoscopic_histopathology: type: string description: 'Dermoscopic histopathology findings, if biopsied.' example: 'Not biopsied' delete: summary: 'Delete Case' operationId: deleteCase description: "Permanently deletes a case you own, along with its images and PDF (files\nand records) and its comments. This is destructive and cannot be undone." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: message: 'Case deleted successfully.' properties: message: type: string example: 'Case deleted successfully.' 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. 403: description: 'Not the case owner' content: application/json: schema: type: object example: message: 'This action is unauthorized.' properties: message: type: string example: 'This action is unauthorized.' 404: description: 'No case with that id' content: application/json: schema: type: object example: message: 'No query results for model [App\Models\ClinicalCase] 999' properties: message: type: string example: 'No query results for model [App\Models\ClinicalCase] 999' tags: - Cases parameters: - in: path name: id description: 'The ID of the case.' example: 1 required: true schema: type: integer - in: path name: case description: 'The case ID.' example: 1 required: true schema: type: integer '/api/v1/cases/{case_id}/comments': post: summary: 'Add Comment' operationId: addComment description: 'Adds a comment to a case. Any authenticated user may comment on any case.' parameters: [] responses: 201: description: 'Comment added' content: application/json: schema: type: object example: data: id: 5 body: 'Great case, thanks for sharing.' user: id: 4 full_name: 'Dr John Roe' avatar_url: null likes_count: 0 dislikes_count: 0 my_reaction: null created_at: '2026-07-29T09:00:00.000000Z' message: 'Comment added.' properties: data: type: object properties: id: type: integer example: 5 body: type: string example: 'Great case, thanks for sharing.' user: type: object properties: id: type: integer example: 4 full_name: type: string example: 'Dr John Roe' avatar_url: type: string example: null nullable: true likes_count: type: integer example: 0 dislikes_count: type: integer example: 0 my_reaction: type: string example: null nullable: true created_at: type: string example: '2026-07-29T09:00:00.000000Z' message: type: string example: 'Comment added.' tags: - Cases requestBody: required: true content: application/json: schema: type: object properties: body: type: string description: 'The comment text.' example: 'Great case, thanks for sharing.' required: - body parameters: - in: path name: case_id description: 'The ID of the case.' example: 1 required: true schema: type: integer - in: path name: case description: 'The case ID.' example: 1 required: true schema: type: integer '/api/v1/comments/{comment_id}/react': post: summary: 'Like/Dislike Comment' operationId: likeDislikeComment description: "Sets the authenticated user's reaction on a comment to `like` or\n`dislike`. Sending the same `type` again removes the reaction (toggle\noff); sending the other type switches it. A user can only have one\nreaction per comment." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: 'Reaction set' type: object example: data: likes_count: 1 dislikes_count: 0 my_reaction: like message: 'Reaction saved.' properties: data: type: object properties: likes_count: type: integer example: 1 dislikes_count: type: integer example: 0 my_reaction: type: string example: like message: type: string example: 'Reaction saved.' - description: 'Reaction removed (same type sent again)' type: object example: data: likes_count: 0 dislikes_count: 0 my_reaction: null message: 'Reaction removed.' properties: data: type: object properties: likes_count: type: integer example: 0 dislikes_count: type: integer example: 0 my_reaction: type: string example: null nullable: true message: type: string example: 'Reaction removed.' tags: - Cases requestBody: required: true content: application/json: schema: type: object properties: type: type: string description: '`like` or `dislike`.' example: like required: - type parameters: - in: path name: comment_id description: 'The ID of the comment.' example: 1 required: true schema: type: integer - in: path name: comment description: 'The comment ID.' example: 5 required: true schema: type: integer /api/v1/quizzes: get: summary: 'List Quizzes' operationId: listQuizzes description: "Published quizzes, newest first. `has_submitted` tells the app whether\nthis user has already taken each one - a quiz where it is `true` cannot\nbe submitted again, only its result read.\n\n`format` is here as well as on the detail endpoint, so the list can be\nrendered (and the right result parser chosen) without fetching each quiz." parameters: - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Quizzes per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Quizzes per page, capped at 100. Defaults to 20.' example: 20 responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Quizzes retrieved successfully.' data: - id: 1 title: 'Dermoscopy Fundamentals' description: 'Covers every question type.' format: standard total_questions: 5 has_submitted: false created_at: '2026-09-04T06:45:07.000000Z' - id: 2 title: 'Pigmented Lesion on the Back' description: 'A 52-year-old man.' format: clinical_case total_questions: 1 has_submitted: false created_at: '2026-09-04T06:45:09.000000Z' - id: 3 title: 'Confidence Survey' description: 'Anonymous poll.' format: poll total_questions: 1 has_submitted: true created_at: '2026-09-04T06:45:09.000000Z' pagination: current_page: 1 per_page: 20 total: 3 last_page: 1 properties: success: type: boolean example: true message: type: string example: 'Quizzes retrieved successfully.' data: type: array example: - id: 1 title: 'Dermoscopy Fundamentals' description: 'Covers every question type.' format: standard total_questions: 5 has_submitted: false created_at: '2026-09-04T06:45:07.000000Z' - id: 2 title: 'Pigmented Lesion on the Back' description: 'A 52-year-old man.' format: clinical_case total_questions: 1 has_submitted: false created_at: '2026-09-04T06:45:09.000000Z' - id: 3 title: 'Confidence Survey' description: 'Anonymous poll.' format: poll total_questions: 1 has_submitted: true created_at: '2026-09-04T06:45:09.000000Z' items: type: object properties: id: type: integer example: 1 title: type: string example: 'Dermoscopy Fundamentals' description: type: string example: 'Covers every question type.' format: type: string example: standard total_questions: type: integer example: 5 has_submitted: type: boolean example: false created_at: type: string example: '2026-09-04T06:45:07.000000Z' pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 3 last_page: type: integer example: 1 tags: - Quizzes '/api/v1/quizzes/{id}': get: summary: 'Get Quiz' operationId: getQuiz description: "A published quiz with its questions and options, ready to be answered.\nAnswer each question by sending back the `id` of the chosen option.\n\nThe correct answers are not included. Fetch the result endpoint after\nsubmitting to see which answers were right." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: 'standard quiz - one of every question type' type: object example: success: true message: 'Quiz retrieved successfully.' data: id: 1 title: 'Dermoscopy Fundamentals' description: 'Covers every question type.' format: standard clinical_image: null dermoscopic_image: null history: null total_questions: 5 questions: - id: 101 question: 'Which feature most suggests melanoma?' type: single_choice image: null options: - id: o1 text: 'Blue-white veil' - id: o2 text: 'Milia-like cysts' - id: o3 text: 'Central white patch' left: [] right: [] - id: 102 question: 'Which features suggest melanoma? Select all that apply.' type: multiple_choice image: 'https://dots.mhn.services/storage/questions/102/lesion.jpg' options: - id: o1 text: 'Atypical pigment network' - id: o2 text: 'Irregular streaks' - id: o3 text: 'Comedo-like openings' left: [] right: [] - id: 103 question: 'Dermoscopy improves melanoma detection versus naked-eye examination.' type: true_false image: null options: - id: o1 text: 'True' - id: o2 text: 'False' left: [] right: [] - id: 104 question: 'Match each dermoscopic feature to its diagnosis.' type: match_following image: null options: [] left: - id: p1 text: 'Milia-like cysts' - id: p2 text: 'Blue-white veil' - id: p3 text: 'Central white patch' right: - id: p2 text: Melanoma - id: p3 text: Dermatofibroma - id: p1 text: 'Seborrheic keratosis' - id: 105 question: 'How confident are you reading dermoscopy?' type: poll image: null options: - id: o1 text: 'Not confident' - id: o2 text: Somewhat - id: o3 text: 'Very confident' left: [] right: [] properties: success: type: boolean example: true message: type: string example: 'Quiz retrieved successfully.' data: type: object properties: id: type: integer example: 1 title: type: string example: 'Dermoscopy Fundamentals' description: type: string example: 'Covers every question type.' format: type: string example: standard clinical_image: type: string example: null nullable: true dermoscopic_image: type: string example: null nullable: true history: type: string example: null nullable: true total_questions: type: integer example: 5 questions: type: array example: - id: 101 question: 'Which feature most suggests melanoma?' type: single_choice image: null options: - id: o1 text: 'Blue-white veil' - id: o2 text: 'Milia-like cysts' - id: o3 text: 'Central white patch' left: [] right: [] - id: 102 question: 'Which features suggest melanoma? Select all that apply.' type: multiple_choice image: 'https://dots.mhn.services/storage/questions/102/lesion.jpg' options: - id: o1 text: 'Atypical pigment network' - id: o2 text: 'Irregular streaks' - id: o3 text: 'Comedo-like openings' left: [] right: [] - id: 103 question: 'Dermoscopy improves melanoma detection versus naked-eye examination.' type: true_false image: null options: - id: o1 text: 'True' - id: o2 text: 'False' left: [] right: [] - id: 104 question: 'Match each dermoscopic feature to its diagnosis.' type: match_following image: null options: [] left: - id: p1 text: 'Milia-like cysts' - id: p2 text: 'Blue-white veil' - id: p3 text: 'Central white patch' right: - id: p2 text: Melanoma - id: p3 text: Dermatofibroma - id: p1 text: 'Seborrheic keratosis' - id: 105 question: 'How confident are you reading dermoscopy?' type: poll image: null options: - id: o1 text: 'Not confident' - id: o2 text: Somewhat - id: o3 text: 'Very confident' left: [] right: [] items: type: object properties: id: type: integer example: 101 question: type: string example: 'Which feature most suggests melanoma?' type: type: string example: single_choice image: type: string example: null nullable: true options: type: array example: - id: o1 text: 'Blue-white veil' - id: o2 text: 'Milia-like cysts' - id: o3 text: 'Central white patch' items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: 'Blue-white veil' } left: type: array example: [] right: type: array example: [] - description: 'clinical_case quiz - stem shown, diagnosis withheld' type: object example: success: true message: 'Quiz retrieved successfully.' data: id: 2 title: 'Pigmented Lesion on the Back' description: 'A 52-year-old man.' format: clinical_case clinical_image: 'https://dots.mhn.services/storage/quizzes/2/clinical.jpg' dermoscopic_image: 'https://dots.mhn.services/storage/quizzes/2/dermoscopic.jpg' history: '52-year-old man, enlarging pigmented lesion on the back over 6 months.' total_questions: 1 questions: - id: 201 question: 'What is the most likely diagnosis?' type: single_choice image: null options: - id: o1 text: Melanoma - id: o2 text: 'Seborrheic keratosis' left: [] right: [] properties: success: type: boolean example: true message: type: string example: 'Quiz retrieved successfully.' data: type: object properties: id: type: integer example: 2 title: type: string example: 'Pigmented Lesion on the Back' description: type: string example: 'A 52-year-old man.' format: type: string example: clinical_case clinical_image: type: string example: 'https://dots.mhn.services/storage/quizzes/2/clinical.jpg' dermoscopic_image: type: string example: 'https://dots.mhn.services/storage/quizzes/2/dermoscopic.jpg' history: type: string example: '52-year-old man, enlarging pigmented lesion on the back over 6 months.' total_questions: type: integer example: 1 questions: type: array example: - id: 201 question: 'What is the most likely diagnosis?' type: single_choice image: null options: - id: o1 text: Melanoma - id: o2 text: 'Seborrheic keratosis' left: [] right: [] items: type: object properties: id: type: integer example: 201 question: type: string example: 'What is the most likely diagnosis?' type: type: string example: single_choice image: type: string example: null nullable: true options: type: array example: - id: o1 text: Melanoma - id: o2 text: 'Seborrheic keratosis' items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: Melanoma } left: type: array example: [] right: type: array example: [] - description: 'poll quiz' type: object example: success: true message: 'Quiz retrieved successfully.' data: id: 3 title: 'Confidence Survey' description: 'Anonymous poll.' format: poll clinical_image: null dermoscopic_image: null history: null total_questions: 1 questions: - id: 301 question: 'How confident are you reading dermoscopy?' type: poll image: null options: - id: o1 text: 'Not confident' - id: o2 text: Somewhat - id: o3 text: 'Very confident' left: [] right: [] properties: success: type: boolean example: true message: type: string example: 'Quiz retrieved successfully.' data: type: object properties: id: type: integer example: 3 title: type: string example: 'Confidence Survey' description: type: string example: 'Anonymous poll.' format: type: string example: poll clinical_image: type: string example: null nullable: true dermoscopic_image: type: string example: null nullable: true history: type: string example: null nullable: true total_questions: type: integer example: 1 questions: type: array example: - id: 301 question: 'How confident are you reading dermoscopy?' type: poll image: null options: - id: o1 text: 'Not confident' - id: o2 text: Somewhat - id: o3 text: 'Very confident' left: [] right: [] items: type: object properties: id: type: integer example: 301 question: type: string example: 'How confident are you reading dermoscopy?' type: type: string example: poll image: type: string example: null nullable: true options: type: array example: - id: o1 text: 'Not confident' - id: o2 text: Somewhat - id: o3 text: 'Very confident' items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: 'Not confident' } left: type: array example: [] right: type: array example: [] 404: description: 'Not published' content: application/json: schema: type: object example: success: false message: 'Quiz not found.' properties: success: type: boolean example: false message: type: string example: 'Quiz not found.' tags: - Quizzes parameters: - in: path name: id description: 'The ID of the quiz.' example: 1 required: true schema: type: integer - in: path name: quiz description: 'The quiz ID.' example: 301 required: true schema: type: integer '/api/v1/quizzes/{quiz_id}/submit': post: summary: 'Submit Quiz' operationId: submitQuiz description: "Grades and records this user's single attempt. The response carries the\nscore and the full review of the attempt: every question in the quiz, in\nthe quiz's own order, with every option it offered, each option flagged\nas the correct one (`is_correct`) and as the student's own pick\n(`is_selected`), plus the explanation and reference.\n\nA question left out of `answers` counts as unanswered and scores zero.\n**One attempt per quiz** - submitting again returns `409`.\n\nHow to answer each question type, using the ids from the quiz detail\nresponse:\n\n| type | Send |\n|---|---|\n| `single_choice`, `true_false`, `poll` | `option_ids: [\"o1\"]` (or `option_id: \"o1\"`) |\n| `multiple_choice` | `option_ids: [\"o1\", \"o2\"]` - every correct option, no extras |\n| `match_following` | `response: {\"p1\": \"p1\", \"p2\": \"p2\"}` - one entry per pair, left id to right id |\n\nExample request body for a quiz holding one of every type:\n\n```json\n{\n \"answers\": [\n { \"question_id\": 101, \"option_ids\": [\"o1\"] },\n { \"question_id\": 102, \"option_ids\": [\"o1\", \"o2\"] },\n { \"question_id\": 103, \"option_ids\": [\"o1\"] },\n { \"question_id\": 104, \"response\": { \"p1\": \"p1\", \"p2\": \"p2\", \"p3\": \"p3\" } },\n { \"question_id\": 105, \"option_ids\": [\"o3\"] }\n ]\n}\n```\n\nThe response shape depends on the quiz's `format`: a `standard` or\n`clinical_case` quiz returns a personal score, a `poll` returns aggregate\ntallies instead. Both scenarios are shown below." parameters: [] responses: 201: description: '' content: application/json: schema: oneOf: - description: 'standard quiz - graded, with the full review' type: object example: success: true message: 'Quiz submitted successfully.' data: quiz_id: 1 quiz_title: 'Dermoscopy Fundamentals' format: standard score: 3 total_questions: 5 percentage: 60 correct_count: 3 incorrect_count: 1 unanswered_count: 0 submitted_at: '2026-09-04T06:45:09.000000Z' diagnosis: null management: null answers: - question_id: 101 question: 'Which feature most suggests melanoma?' type: single_choice image: null is_correct: true is_answered: true explanation: 'Blue-white veil is the classic finding.' reference: 'Braun RP, et al.' options: - id: o1 text: 'Blue-white veil' is_correct: true is_selected: true - id: o2 text: 'Milia-like cysts' is_correct: false is_selected: false - id: o3 text: 'Central white patch' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] - question_id: 102 question: 'Which features suggest melanoma? Select all that apply.' type: multiple_choice image: 'https://dots.mhn.services/storage/questions/102/lesion.jpg' is_correct: false is_answered: true explanation: 'Comedo-like openings point to seborrheic keratosis.' reference: null options: - id: o1 text: 'Atypical pigment network' is_correct: true is_selected: true - id: o2 text: 'Irregular streaks' is_correct: true is_selected: false - id: o3 text: 'Comedo-like openings' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 - o2 response: [] correct_pairs: [] left: [] right: [] - question_id: 103 question: 'Dermoscopy improves melanoma detection versus naked-eye examination.' type: true_false image: null is_correct: true is_answered: true explanation: 'Supported by meta-analysis.' reference: null options: - id: o1 text: 'True' is_correct: true is_selected: true - id: o2 text: 'False' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] - question_id: 104 question: 'Match each dermoscopic feature to its diagnosis.' type: match_following image: null is_correct: true is_answered: true explanation: null reference: null options: [] selected_option_ids: [] correct_option_ids: [] response: p1: p1 p2: p2 p3: p3 correct_pairs: p1: p1 p2: p2 p3: p3 left: - id: p1 text: 'Milia-like cysts' - id: p2 text: 'Blue-white veil' - id: p3 text: 'Central white patch' right: - id: p1 text: 'Seborrheic keratosis' - id: p2 text: Melanoma - id: p3 text: Dermatofibroma - question_id: 105 question: 'How confident are you reading dermoscopy?' type: poll image: null is_correct: null is_answered: true explanation: null reference: null options: - id: o1 text: 'Not confident' is_correct: false is_selected: false - id: o2 text: Somewhat is_correct: false is_selected: false - id: o3 text: 'Very confident' is_correct: false is_selected: true selected_option_ids: - o3 correct_option_ids: [] response: [] correct_pairs: [] left: [] right: [] properties: success: type: boolean example: true message: type: string example: 'Quiz submitted successfully.' data: type: object properties: quiz_id: type: integer example: 1 quiz_title: type: string example: 'Dermoscopy Fundamentals' format: type: string example: standard score: type: integer example: 3 total_questions: type: integer example: 5 percentage: type: integer example: 60 correct_count: type: integer example: 3 incorrect_count: type: integer example: 1 unanswered_count: type: integer example: 0 submitted_at: type: string example: '2026-09-04T06:45:09.000000Z' diagnosis: type: string example: null nullable: true management: type: string example: null nullable: true answers: type: array example: - question_id: 101 question: 'Which feature most suggests melanoma?' type: single_choice image: null is_correct: true is_answered: true explanation: 'Blue-white veil is the classic finding.' reference: 'Braun RP, et al.' options: - id: o1 text: 'Blue-white veil' is_correct: true is_selected: true - id: o2 text: 'Milia-like cysts' is_correct: false is_selected: false - id: o3 text: 'Central white patch' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] - question_id: 102 question: 'Which features suggest melanoma? Select all that apply.' type: multiple_choice image: 'https://dots.mhn.services/storage/questions/102/lesion.jpg' is_correct: false is_answered: true explanation: 'Comedo-like openings point to seborrheic keratosis.' reference: null options: - id: o1 text: 'Atypical pigment network' is_correct: true is_selected: true - id: o2 text: 'Irregular streaks' is_correct: true is_selected: false - id: o3 text: 'Comedo-like openings' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 - o2 response: [] correct_pairs: [] left: [] right: [] - question_id: 103 question: 'Dermoscopy improves melanoma detection versus naked-eye examination.' type: true_false image: null is_correct: true is_answered: true explanation: 'Supported by meta-analysis.' reference: null options: - id: o1 text: 'True' is_correct: true is_selected: true - id: o2 text: 'False' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] - question_id: 104 question: 'Match each dermoscopic feature to its diagnosis.' type: match_following image: null is_correct: true is_answered: true explanation: null reference: null options: [] selected_option_ids: [] correct_option_ids: [] response: p1: p1 p2: p2 p3: p3 correct_pairs: p1: p1 p2: p2 p3: p3 left: - id: p1 text: 'Milia-like cysts' - id: p2 text: 'Blue-white veil' - id: p3 text: 'Central white patch' right: - id: p1 text: 'Seborrheic keratosis' - id: p2 text: Melanoma - id: p3 text: Dermatofibroma - question_id: 105 question: 'How confident are you reading dermoscopy?' type: poll image: null is_correct: null is_answered: true explanation: null reference: null options: - id: o1 text: 'Not confident' is_correct: false is_selected: false - id: o2 text: Somewhat is_correct: false is_selected: false - id: o3 text: 'Very confident' is_correct: false is_selected: true selected_option_ids: - o3 correct_option_ids: [] response: [] correct_pairs: [] left: [] right: [] items: type: object properties: question_id: type: integer example: 101 question: type: string example: 'Which feature most suggests melanoma?' type: type: string example: single_choice image: type: string example: null nullable: true is_correct: type: boolean example: true is_answered: type: boolean example: true explanation: type: string example: 'Blue-white veil is the classic finding.' reference: type: string example: 'Braun RP, et al.' options: type: array example: - id: o1 text: 'Blue-white veil' is_correct: true is_selected: true - id: o2 text: 'Milia-like cysts' is_correct: false is_selected: false - id: o3 text: 'Central white patch' is_correct: false is_selected: false items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: 'Blue-white veil' } is_correct: { type: boolean, example: true } is_selected: { type: boolean, example: true } selected_option_ids: type: array example: - o1 items: type: string correct_option_ids: type: array example: - o1 items: type: string response: type: array example: [] correct_pairs: type: array example: [] left: type: array example: [] right: type: array example: [] - description: 'clinical_case quiz - diagnosis and management revealed' type: object example: success: true message: 'Quiz submitted successfully.' data: quiz_id: 2 quiz_title: 'Pigmented Lesion on the Back' format: clinical_case score: 1 total_questions: 1 percentage: 100 correct_count: 1 incorrect_count: 0 unanswered_count: 0 submitted_at: '2026-09-04T06:45:09.000000Z' diagnosis: 'Superficial spreading melanoma, Breslow depth 0.8mm.' management: 'Wide local excision with 1cm margins; sentinel node discussion.' answers: - question_id: 201 question: 'What is the most likely diagnosis?' type: single_choice image: null is_correct: true is_answered: true explanation: 'The asymmetric network and blue-white veil point to melanoma.' reference: null options: - id: o1 text: Melanoma is_correct: true is_selected: true - id: o2 text: 'Seborrheic keratosis' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] properties: success: type: boolean example: true message: type: string example: 'Quiz submitted successfully.' data: type: object properties: quiz_id: type: integer example: 2 quiz_title: type: string example: 'Pigmented Lesion on the Back' format: type: string example: clinical_case score: type: integer example: 1 total_questions: type: integer example: 1 percentage: type: integer example: 100 correct_count: type: integer example: 1 incorrect_count: type: integer example: 0 unanswered_count: type: integer example: 0 submitted_at: type: string example: '2026-09-04T06:45:09.000000Z' diagnosis: type: string example: 'Superficial spreading melanoma, Breslow depth 0.8mm.' management: type: string example: 'Wide local excision with 1cm margins; sentinel node discussion.' answers: type: array example: - question_id: 201 question: 'What is the most likely diagnosis?' type: single_choice image: null is_correct: true is_answered: true explanation: 'The asymmetric network and blue-white veil point to melanoma.' reference: null options: - id: o1 text: Melanoma is_correct: true is_selected: true - id: o2 text: 'Seborrheic keratosis' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] items: type: object properties: question_id: type: integer example: 201 question: type: string example: 'What is the most likely diagnosis?' type: type: string example: single_choice image: type: string example: null nullable: true is_correct: type: boolean example: true is_answered: type: boolean example: true explanation: type: string example: 'The asymmetric network and blue-white veil point to melanoma.' reference: type: string example: null nullable: true options: type: array example: - id: o1 text: Melanoma is_correct: true is_selected: true - id: o2 text: 'Seborrheic keratosis' is_correct: false is_selected: false items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: Melanoma } is_correct: { type: boolean, example: true } is_selected: { type: boolean, example: true } selected_option_ids: type: array example: - o1 items: type: string correct_option_ids: type: array example: - o1 items: type: string response: type: array example: [] correct_pairs: type: array example: [] left: type: array example: [] right: type: array example: [] - description: 'poll quiz - tallies, no score and no answers' type: object example: success: true message: 'Poll submitted successfully.' data: quiz_id: 3 quiz_title: 'Confidence Survey' total_responses: 3 questions: - question_id: 301 question: 'How confident are you reading dermoscopy?' total_responses: 3 options: - id: o1 text: 'Not confident' votes: 1 percentage: 33.3 - id: o2 text: Somewhat votes: 2 percentage: 66.7 - id: o3 text: 'Very confident' votes: 0 percentage: 0 properties: success: type: boolean example: true message: type: string example: 'Poll submitted successfully.' data: type: object properties: quiz_id: type: integer example: 3 quiz_title: type: string example: 'Confidence Survey' total_responses: type: integer example: 3 questions: type: array example: - question_id: 301 question: 'How confident are you reading dermoscopy?' total_responses: 3 options: - id: o1 text: 'Not confident' votes: 1 percentage: 33.3 - id: o2 text: Somewhat votes: 2 percentage: 66.7 - id: o3 text: 'Very confident' votes: 0 percentage: 0 items: type: object properties: question_id: type: integer example: 301 question: type: string example: 'How confident are you reading dermoscopy?' total_responses: type: integer example: 3 options: type: array example: - id: o1 text: 'Not confident' votes: 1 percentage: 33.3 - id: o2 text: Somewhat votes: 2 percentage: 66.7 - id: o3 text: 'Very confident' votes: 0 percentage: 0 items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: 'Not confident' } votes: { type: integer, example: 1 } percentage: { type: number, example: 33.3 } 401: description: 'Missing or expired token' content: application/json: schema: type: object example: success: false message: Unauthenticated. properties: success: type: boolean example: false message: type: string example: Unauthenticated. 404: description: 'Quiz is a draft, or no quiz with that id' content: application/json: schema: type: object example: success: false message: 'Quiz not found.' properties: success: type: boolean example: false message: type: string example: 'Quiz not found.' 409: description: 'Already taken - one attempt per quiz' content: application/json: schema: type: object example: success: false message: 'You have already submitted this quiz.' properties: success: type: boolean example: false message: type: string example: 'You have already submitted this quiz.' 422: description: 'answers missing from the request body' content: application/json: schema: type: object example: success: false message: 'The answers field is required.' errors: answers: - 'The answers field is required.' properties: success: type: boolean example: false message: type: string example: 'The answers field is required.' errors: type: object properties: answers: type: array example: - 'The answers field is required.' items: type: string tags: - Quizzes requestBody: required: true content: application/json: schema: type: object properties: answers: type: array description: 'One entry per question answered.' example: - [] items: type: object properties: question_id: type: integer description: 'The question being answered.' example: 900 option_id: type: string description: 'The id of the chosen option, from the quiz detail response. Used for single_choice, true_false, and poll. This field is required when none of answers.*.option_ids and answers.*.response are present.' example: o1 option_ids: type: object description: 'Use instead of `option_id` for multiple_choice (several correct options).' example: null properties: { } response: type: object description: 'match_following only: `{"left_id": "right_id"}` for every pair.' example: p1: p1 p2: p2 properties: { } required: - question_id required: - answers parameters: - in: path name: quiz_id description: 'The ID of the quiz.' example: 1 required: true schema: type: integer - in: path name: quiz description: 'The quiz ID.' example: 1 required: true schema: type: integer '/api/v1/quizzes/{quiz_id}/result': get: summary: 'Get Quiz Result' operationId: getQuizResult description: "This user's own result for a quiz they have already taken: the score\nplus the full review of the attempt - every question, every option,\nwhich option was correct, and which one the student picked.\n\nA question the student skipped is still listed, with `is_answered` set\nto false and an empty selection, so the review always covers the whole\nquiz.\n\nReturns exactly the same payload the submit endpoint returned, so a\nclient can reuse one parser for both. As there, the shape follows the\nquiz's `format`: a score with `answers` for `standard` and\n`clinical_case`, aggregate tallies for `poll`.\n\nAnother user's result is never visible here; this is only ever the\nauthenticated user's own attempt." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: 'standard or clinical_case quiz' type: object example: success: true message: 'Quiz result retrieved successfully.' data: quiz_id: 1 quiz_title: 'Dermoscopy Fundamentals' format: standard score: 3 total_questions: 5 percentage: 60 correct_count: 3 incorrect_count: 2 unanswered_count: 1 submitted_at: '2026-09-04T06:45:09.000000Z' diagnosis: null management: null answers: - question_id: 101 question: 'Which feature most suggests melanoma?' type: single_choice image: null is_correct: true is_answered: true explanation: 'Blue-white veil is the classic finding.' reference: 'Braun RP, et al.' options: - id: o1 text: 'Blue-white veil' is_correct: true is_selected: true - id: o2 text: 'Milia-like cysts' is_correct: false is_selected: false - id: o3 text: 'Central white patch' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] - question_id: 106 question: 'Which vessel pattern suggests basal cell carcinoma?' type: single_choice image: null is_correct: false is_answered: false explanation: 'Arborizing vessels are the classic finding.' reference: null options: - id: o1 text: Arborizing is_correct: true is_selected: false - id: o2 text: Dotted is_correct: false is_selected: false selected_option_ids: [] correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] properties: success: type: boolean example: true message: type: string example: 'Quiz result retrieved successfully.' data: type: object properties: quiz_id: type: integer example: 1 quiz_title: type: string example: 'Dermoscopy Fundamentals' format: type: string example: standard score: type: integer example: 3 total_questions: type: integer example: 5 percentage: type: integer example: 60 correct_count: type: integer example: 3 incorrect_count: type: integer example: 2 unanswered_count: type: integer example: 1 submitted_at: type: string example: '2026-09-04T06:45:09.000000Z' diagnosis: type: string example: null nullable: true management: type: string example: null nullable: true answers: type: array example: - question_id: 101 question: 'Which feature most suggests melanoma?' type: single_choice image: null is_correct: true is_answered: true explanation: 'Blue-white veil is the classic finding.' reference: 'Braun RP, et al.' options: - id: o1 text: 'Blue-white veil' is_correct: true is_selected: true - id: o2 text: 'Milia-like cysts' is_correct: false is_selected: false - id: o3 text: 'Central white patch' is_correct: false is_selected: false selected_option_ids: - o1 correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] - question_id: 106 question: 'Which vessel pattern suggests basal cell carcinoma?' type: single_choice image: null is_correct: false is_answered: false explanation: 'Arborizing vessels are the classic finding.' reference: null options: - id: o1 text: Arborizing is_correct: true is_selected: false - id: o2 text: Dotted is_correct: false is_selected: false selected_option_ids: [] correct_option_ids: - o1 response: [] correct_pairs: [] left: [] right: [] items: type: object properties: question_id: type: integer example: 101 question: type: string example: 'Which feature most suggests melanoma?' type: type: string example: single_choice image: type: string example: null nullable: true is_correct: type: boolean example: true is_answered: type: boolean example: true explanation: type: string example: 'Blue-white veil is the classic finding.' reference: type: string example: 'Braun RP, et al.' options: type: array example: - id: o1 text: 'Blue-white veil' is_correct: true is_selected: true - id: o2 text: 'Milia-like cysts' is_correct: false is_selected: false - id: o3 text: 'Central white patch' is_correct: false is_selected: false items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: 'Blue-white veil' } is_correct: { type: boolean, example: true } is_selected: { type: boolean, example: true } selected_option_ids: type: array example: - o1 items: type: string correct_option_ids: type: array example: - o1 items: type: string response: type: array example: [] correct_pairs: type: array example: [] left: type: array example: [] right: type: array example: [] - description: 'poll quiz' type: object example: success: true message: 'Poll results retrieved successfully.' data: quiz_id: 3 quiz_title: 'Confidence Survey' total_responses: 3 questions: - question_id: 301 question: 'How confident are you reading dermoscopy?' total_responses: 3 options: - id: o1 text: 'Not confident' votes: 1 percentage: 33.3 - id: o2 text: Somewhat votes: 2 percentage: 66.7 - id: o3 text: 'Very confident' votes: 0 percentage: 0 properties: success: type: boolean example: true message: type: string example: 'Poll results retrieved successfully.' data: type: object properties: quiz_id: type: integer example: 3 quiz_title: type: string example: 'Confidence Survey' total_responses: type: integer example: 3 questions: type: array example: - question_id: 301 question: 'How confident are you reading dermoscopy?' total_responses: 3 options: - id: o1 text: 'Not confident' votes: 1 percentage: 33.3 - id: o2 text: Somewhat votes: 2 percentage: 66.7 - id: o3 text: 'Very confident' votes: 0 percentage: 0 items: type: object properties: question_id: type: integer example: 301 question: type: string example: 'How confident are you reading dermoscopy?' total_responses: type: integer example: 3 options: type: array example: - id: o1 text: 'Not confident' votes: 1 percentage: 33.3 - id: o2 text: Somewhat votes: 2 percentage: 66.7 - id: o3 text: 'Very confident' votes: 0 percentage: 0 items: type: object properties: id: { type: string, example: o1 } text: { type: string, example: 'Not confident' } votes: { type: integer, example: 1 } percentage: { type: number, example: 33.3 } 401: description: 'Missing or expired token' content: application/json: schema: type: object example: success: false message: Unauthenticated. properties: success: type: boolean example: false message: type: string example: Unauthenticated. 404: description: '' content: application/json: schema: oneOf: - description: 'Not taken yet' type: object example: success: false message: 'You have not submitted this quiz yet.' properties: success: type: boolean example: false message: type: string example: 'You have not submitted this quiz yet.' - description: 'No quiz with that id' type: object example: success: false message: 'Resource not found.' properties: success: type: boolean example: false message: type: string example: 'Resource not found.' tags: - Quizzes parameters: - in: path name: quiz_id description: 'The ID of the quiz.' example: 1 required: true schema: type: integer - in: path name: quiz description: 'The quiz ID.' example: 1 required: true schema: type: integer /api/v1/learning: get: summary: 'List Learning Content' operationId: listLearningContent description: "Published articles, newest first. The full `content` body is omitted here\nto keep the list small; fetch a single article to read it." parameters: - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Items per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Items per page, capped at 100. Defaults to 20.' example: 20 responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Learning content retrieved successfully.' data: - id: 701 image: 'https://dots.mhn.services/storage/learning/cover.jpg' title: 'Introduction to Clinical Diagnosis' description: 'Basic information about clinical diagnosis.' total_likes: 125 has_liked: false created_at: '2026-08-31T12:30:00.000000Z' pagination: current_page: 1 per_page: 20 total: 40 last_page: 2 properties: success: type: boolean example: true message: type: string example: 'Learning content retrieved successfully.' data: type: array example: - id: 701 image: 'https://dots.mhn.services/storage/learning/cover.jpg' title: 'Introduction to Clinical Diagnosis' description: 'Basic information about clinical diagnosis.' total_likes: 125 has_liked: false created_at: '2026-08-31T12:30:00.000000Z' items: type: object properties: id: type: integer example: 701 image: type: string example: 'https://dots.mhn.services/storage/learning/cover.jpg' title: type: string example: 'Introduction to Clinical Diagnosis' description: type: string example: 'Basic information about clinical diagnosis.' total_likes: type: integer example: 125 has_liked: type: boolean example: false created_at: type: string example: '2026-08-31T12:30:00.000000Z' pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 40 last_page: type: integer example: 2 tags: - Learning '/api/v1/learning/{id}': get: summary: 'Get Learning Content' operationId: getLearningContent description: 'A single published article, including its full `content` body.' parameters: [] responses: 404: description: 'Not published' content: application/json: schema: type: object example: success: false message: 'Learning content not found.' properties: success: type: boolean example: false message: type: string example: 'Learning content not found.' tags: - Learning parameters: - in: path name: id description: 'The ID of the learning.' example: 1 required: true schema: type: integer - in: path name: learning description: 'The learning content ID.' example: 701 required: true schema: type: integer '/api/v1/learning/{learning_id}/like': post: summary: 'Like Learning Content' operationId: likeLearningContent description: "Likes an article, or removes this user's existing like. One like per\nperson, so calling this twice leaves the article unliked." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: Liked type: object example: success: true message: 'Learning content liked.' data: learning_id: 701 has_liked: true total_likes: 126 properties: success: type: boolean example: true message: type: string example: 'Learning content liked.' data: type: object properties: learning_id: type: integer example: 701 has_liked: type: boolean example: true total_likes: type: integer example: 126 - description: 'Like removed' type: object example: success: true message: 'Like removed.' data: learning_id: 701 has_liked: false total_likes: 125 properties: success: type: boolean example: true message: type: string example: 'Like removed.' data: type: object properties: learning_id: type: integer example: 701 has_liked: type: boolean example: false total_likes: type: integer example: 125 tags: - Learning parameters: - in: path name: learning_id description: 'The ID of the learning.' example: 1 required: true schema: type: integer - in: path name: learning description: 'The learning content ID.' example: 701 required: true schema: type: integer /api/v1/admin/login: post: summary: 'Admin Login' operationId: adminLogin description: "Authenticates an admin by username and password and issues a Sanctum\ntoken. Deactivated admins are rejected with the same generic message as\nbad credentials, so the endpoint does not disclose which accounts exist." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Admin login successful.' token: 12|To5i3l8yWR3Mb3voEv0mX0W4NK4iLQCxsmuARXfDbe3d9eab admin: id: 1 username: admin name: Admin properties: success: type: boolean example: true message: type: string example: 'Admin login successful.' token: type: string example: 12|To5i3l8yWR3Mb3voEv0mX0W4NK4iLQCxsmuARXfDbe3d9eab admin: type: object properties: id: type: integer example: 1 username: type: string example: admin name: type: string example: Admin 401: description: 'Wrong username or password' content: application/json: schema: type: object example: success: false message: 'Invalid username or password.' properties: success: type: boolean example: false message: type: string example: 'Invalid username or password.' tags: - 'Admin - Authentication' requestBody: required: true content: application/json: schema: type: object properties: username: type: string description: 'The admin account username.' example: admin password: type: string description: 'The admin account password.' example: admin_password required: - username - password security: [] /api/v1/admin/dashboard: get: summary: 'Dashboard Analytics' operationId: dashboardAnalytics description: "Platform totals, counted from live database records each time.\n\n`total_users` counts every account, admins included; filter the users\nlist by role to break that down. `total_published` counts published\nquizzes only, while `total_submissions` counts attempts across all\nquizzes. `total_content` counts learning articles in both states." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Dashboard data retrieved successfully.' data: users: total_users: 1250 cases: total_cases: 450 pending: 120 approved: 280 rejected: 50 quizzes: total_published: 25 total_submissions: 1840 learning: total_content: 40 properties: success: type: boolean example: true message: type: string example: 'Dashboard data retrieved successfully.' data: type: object properties: users: type: object properties: total_users: type: integer example: 1250 cases: type: object properties: total_cases: type: integer example: 450 pending: type: integer example: 120 approved: type: integer example: 280 rejected: type: integer example: 50 quizzes: type: object properties: total_published: type: integer example: 25 total_submissions: type: integer example: 1840 learning: type: object properties: total_content: type: integer example: 40 tags: - 'Admin - Dashboard' /api/v1/admin/users: get: summary: 'Users List' operationId: usersList description: "Paginated list of registered users with the details captured at\nregistration. Optional filters narrow the list; omit them all to get\neveryone, newest first." parameters: - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Users per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Users per page, capped at 100. Defaults to 20.' example: 20 - in: query name: search description: 'Matches name, email, phone, or PMDC number.' example: ahmed required: false schema: type: string description: 'Matches name, email, phone, or PMDC number.' example: ahmed - in: query name: role description: 'Filter by role, e.g. `student`, `doctor`, `admin`.' example: student required: false schema: type: string description: 'Filter by role, e.g. `student`, `doctor`, `admin`.' example: student - in: query name: status description: 'Filter by `active` or `inactive`.' example: active required: false schema: type: string description: 'Filter by `active` or `inactive`.' example: active responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Users retrieved successfully.' data: - id: 101 profile_image: 'https://dots.mhn.services/storage/avatars/101.jpg' name: 'Dr. Ahmed Khan' designation: Consultant bio: 'Dermatologist with a special interest in dermoscopy.' email: ahmed@example.com phone: +92XXXXXXXXXX province: Punjab city: Lahore pmdc_number: PMDC12345 fellowship_number: FEL12345 institutional_number: INS12345 role: student status: active created_at: '2026-08-31T10:30:00.000000Z' pagination: current_page: 1 per_page: 20 total: 1250 last_page: 63 properties: success: type: boolean example: true message: type: string example: 'Users retrieved successfully.' data: type: array example: - id: 101 profile_image: 'https://dots.mhn.services/storage/avatars/101.jpg' name: 'Dr. Ahmed Khan' designation: Consultant bio: 'Dermatologist with a special interest in dermoscopy.' email: ahmed@example.com phone: +92XXXXXXXXXX province: Punjab city: Lahore pmdc_number: PMDC12345 fellowship_number: FEL12345 institutional_number: INS12345 role: student status: active created_at: '2026-08-31T10:30:00.000000Z' items: type: object properties: id: type: integer example: 101 profile_image: type: string example: 'https://dots.mhn.services/storage/avatars/101.jpg' name: type: string example: 'Dr. Ahmed Khan' designation: type: string example: Consultant bio: type: string example: 'Dermatologist with a special interest in dermoscopy.' email: type: string example: ahmed@example.com phone: type: string example: +92XXXXXXXXXX province: type: string example: Punjab city: type: string example: Lahore pmdc_number: type: string example: PMDC12345 fellowship_number: type: string example: FEL12345 institutional_number: type: string example: INS12345 role: type: string example: student status: type: string example: active created_at: type: string example: '2026-08-31T10:30:00.000000Z' pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 1250 last_page: type: integer example: 63 tags: - 'Admin - Users' /api/v1/admin/users/status: post: summary: 'Update User Status' operationId: updateUserStatus description: "Activates or deactivates a user without deleting the account.\nDeactivating revokes the user's access tokens immediately, so the app\nstops working for them until they are reactivated.\n\nAn admin cannot deactivate their own account." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'User status updated successfully.' data: user_id: 101 status: inactive properties: success: type: boolean example: true message: type: string example: 'User status updated successfully.' data: type: object properties: user_id: type: integer example: 101 status: type: string example: inactive 422: description: 'Deactivating your own account' content: application/json: schema: type: object example: success: false message: 'You cannot deactivate your own account.' errors: user_id: - 'You cannot deactivate your own account.' properties: success: type: boolean example: false message: type: string example: 'You cannot deactivate your own account.' errors: type: object properties: user_id: type: array example: - 'You cannot deactivate your own account.' items: type: string tags: - 'Admin - Users' requestBody: required: true content: application/json: schema: type: object properties: user_id: type: string description: 'The user to update. Must match an existing stored value.' example: 101 status: type: string description: 'Either `active` or `inactive`.' example: inactive enum: - active - inactive required: - user_id - status /api/v1/admin/cases: get: summary: 'Cases List' operationId: casesList description: "Paginated review queue, newest first, filterable by photo type and\nreview status. Pass `all` or omit a filter to leave it unrestricted.\n\n`search` matches a full case code (letter case ignored), or a fragment\nof the clinical diagnosis, the dermoscopic diagnosis, the body site, or\nthe submitter's name. Unlike the app feed, this searches cases in every\nstatus, so a pending or rejected case is findable by its code here.\n\nCases have no title or description: the app does not collect either, so\nneither is returned. Review a case from its photos and its clinical and\ndermoscopic detail blocks." parameters: - in: query name: type description: '`all`, `clinical`, or `dermoscopic`.' example: dermoscopic required: false schema: type: string description: '`all`, `clinical`, or `dermoscopic`.' example: dermoscopic - in: query name: status description: '`all`, `pending`, `approved`, or `rejected`.' example: pending required: false schema: type: string description: '`all`, `pending`, `approved`, or `rejected`.' example: pending - in: query name: search description: 'Matches the case code, diagnosis, body site, or submitter name.' example: melanoma required: false schema: type: string description: 'Matches the case code, diagnosis, body site, or submitter name.' example: melanoma - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Cases per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Cases per page, capped at 100. Defaults to 20.' example: 20 responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Cases retrieved successfully.' filters: type: dermoscopic status: pending data: - id: 501 code: K7F2Q user_id: 101 user_name: 'Dr. Ahmed Khan' user_profile_image: 'https://dots.mhn.services/storage/avatars/101.jpg' case_type: dermoscopic status: pending rejection_reason: null reviewed_at: null is_pinned: true pinned_at: '2026-09-05T09:15:00.000000Z' images: - 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' pdf: url: 'https://dots.mhn.services/storage/cases/501/pdf/histopath.pdf' name: histopath.pdf size: 148213 patient: age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk clinical: diagnosis: null histopathology: null images: [] dermoscopic: lesion_type: melanocytic features: - 'pigment network' vascular_pattern: - dotted colours_present: - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: - id: 9 url: 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' comments_count: 2 created_at: '2026-08-31T10:30:00.000000Z' pagination: current_page: 1 per_page: 20 total: 120 last_page: 6 properties: success: type: boolean example: true message: type: string example: 'Cases retrieved successfully.' filters: type: object properties: type: type: string example: dermoscopic status: type: string example: pending data: type: array example: - id: 501 code: K7F2Q user_id: 101 user_name: 'Dr. Ahmed Khan' user_profile_image: 'https://dots.mhn.services/storage/avatars/101.jpg' case_type: dermoscopic status: pending rejection_reason: null reviewed_at: null is_pinned: true pinned_at: '2026-09-05T09:15:00.000000Z' images: - 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' pdf: url: 'https://dots.mhn.services/storage/cases/501/pdf/histopath.pdf' name: histopath.pdf size: 148213 patient: age: 52 gender: male fitzpatrick_skin_type: III body_site: trunk clinical: diagnosis: null histopathology: null images: [] dermoscopic: lesion_type: melanocytic features: - 'pigment network' vascular_pattern: - dotted colours_present: - brown scale: fine pattern: reticular image_metadata: - polarized diagnosis: melanoma histopathology: null images: - id: 9 url: 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' comments_count: 2 created_at: '2026-08-31T10:30:00.000000Z' items: type: object properties: id: type: integer example: 501 code: type: string example: K7F2Q user_id: type: integer example: 101 user_name: type: string example: 'Dr. Ahmed Khan' user_profile_image: type: string example: 'https://dots.mhn.services/storage/avatars/101.jpg' case_type: type: string example: dermoscopic status: type: string example: pending rejection_reason: type: string example: null nullable: true reviewed_at: type: string example: null nullable: true is_pinned: type: boolean example: true pinned_at: type: string example: '2026-09-05T09:15:00.000000Z' images: type: array example: - 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' items: type: string pdf: type: object properties: url: type: string example: 'https://dots.mhn.services/storage/cases/501/pdf/histopath.pdf' name: type: string example: histopath.pdf size: type: integer example: 148213 patient: type: object properties: age: type: integer example: 52 gender: type: string example: male fitzpatrick_skin_type: type: string example: III body_site: type: string example: trunk clinical: type: object properties: diagnosis: type: string example: null nullable: true histopathology: type: string example: null nullable: true images: type: array example: [] dermoscopic: type: object properties: lesion_type: type: string example: melanocytic features: type: array example: - 'pigment network' items: type: string vascular_pattern: type: array example: - dotted items: type: string colours_present: type: array example: - brown items: type: string scale: type: string example: fine pattern: type: string example: reticular image_metadata: type: array example: - polarized items: type: string diagnosis: type: string example: melanoma histopathology: type: string example: null nullable: true images: type: array example: - id: 9 url: 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' items: type: object properties: id: type: integer example: 9 url: type: string example: 'https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg' comments_count: type: integer example: 2 created_at: type: string example: '2026-08-31T10:30:00.000000Z' pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 120 last_page: type: integer example: 6 tags: - 'Admin - Cases' /api/v1/admin/cases/status: post: summary: 'Approve or Reject Case' operationId: approveOrRejectCase description: "Sets a case to `approved` or `rejected`, recording who reviewed it and\nwhen. A rejection may carry a `reason`, which the submitter sees on their\nown case so they can correct it and resubmit. Approving clears any\nprevious rejection reason.\n\nOnly approved cases appear in the app's shared feed." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: Approved type: object example: success: true message: 'Case approved successfully.' data: case_id: 501 status: approved properties: success: type: boolean example: true message: type: string example: 'Case approved successfully.' data: type: object properties: case_id: type: integer example: 501 status: type: string example: approved - description: Rejected type: object example: success: true message: 'Case rejected successfully.' data: case_id: 501 status: rejected properties: success: type: boolean example: true message: type: string example: 'Case rejected successfully.' data: type: object properties: case_id: type: integer example: 501 status: type: string example: rejected tags: - 'Admin - Cases' requestBody: required: true content: application/json: schema: type: object properties: case_id: type: string description: 'The case to review. Must match an existing stored value.' example: 501 status: type: string description: 'One of `approved`, `rejected`, or `pending`.' example: approved enum: - approved - rejected - pending reason: type: string description: 'Optional explanation, shown to the submitter when rejecting.' example: 'Insufficient case information.' required: - case_id - status /api/v1/admin/cases/pin: post: summary: 'Pin or Unpin Case' operationId: pinOrUnpinCase description: "Pins a case to the top of the app's shared feed, or removes the pin.\nPinned cases come back first from `GET /api/v1/cases`, most recently\npinned first, each flagged with `is_pinned` and carrying the admin who\npinned it, so the app can label it as pinned by an admin.\n\nAny number of cases can be pinned at once. Pinning does not review a\ncase: only approved cases appear in the feed, so pinning one still\nawaiting review has no visible effect until it is approved.\n\n### Request\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `case_id` | integer | yes | Must be an existing case id, in any review status |\n| `pinned` | boolean | yes | `true` pins, `false` unpins. `1`/`0` and `\"true\"`/`\"false\"` are accepted |\n\n### Behaviour to test\n\n- Pinning is idempotent-ish: pinning an already-pinned case succeeds and\n **refreshes** `pinned_at`, which moves it ahead of other pinned cases\n in the feed. Unpinning a case that is not pinned also succeeds and\n simply leaves it unpinned.\n- Unpinning clears `pinned_at` and `pinned_by` together. A case is\n pinned if and only if `pinned_at` is set.\n- Any number of cases can be pinned at the same time.\n- The case's review `status` is untouched. Pinning a `pending` case\n stores the pin but the case still will not show in the app feed until\n it is approved.\n- The pin records the admin from the bearer token, and that admin comes\n back as `pinned_by` here and on every app-facing case payload." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: Pinned type: object example: success: true message: 'Case pinned successfully.' data: case_id: 501 is_pinned: true pinned_at: '2026-09-05T09:15:00.000000Z' pinned_by: id: 1 full_name: 'Dots Admin' avatar_url: null properties: success: type: boolean example: true message: type: string example: 'Case pinned successfully.' data: type: object properties: case_id: type: integer example: 501 is_pinned: type: boolean example: true pinned_at: type: string example: '2026-09-05T09:15:00.000000Z' pinned_by: type: object properties: id: type: integer example: 1 full_name: type: string example: 'Dots Admin' avatar_url: type: string example: null nullable: true - description: Unpinned type: object example: success: true message: 'Case unpinned successfully.' data: case_id: 501 is_pinned: false pinned_at: null pinned_by: null properties: success: type: boolean example: true message: type: string example: 'Case unpinned successfully.' data: type: object properties: case_id: type: integer example: 501 is_pinned: type: boolean example: false pinned_at: type: string example: null nullable: true pinned_by: type: string example: null nullable: true - description: 'Pinned again - pinned_at is refreshed' type: object example: success: true message: 'Case pinned successfully.' data: case_id: 501 is_pinned: true pinned_at: '2026-09-05T14:02:44.000000Z' pinned_by: id: 1 full_name: 'Dots Admin' avatar_url: null properties: success: type: boolean example: true message: type: string example: 'Case pinned successfully.' data: type: object properties: case_id: type: integer example: 501 is_pinned: type: boolean example: true pinned_at: type: string example: '2026-09-05T14:02:44.000000Z' pinned_by: type: object properties: id: type: integer example: 1 full_name: type: string example: 'Dots Admin' avatar_url: type: string example: null nullable: true 401: description: 'Missing or expired token' content: application/json: schema: type: object example: success: false message: Unauthenticated. properties: success: type: boolean example: false message: type: string example: Unauthenticated. 403: description: 'Token belongs to a non-admin account' content: application/json: schema: type: object example: success: false message: 'This action requires an admin account.' properties: success: type: boolean example: false message: type: string example: 'This action requires an admin account.' 422: description: '' content: application/json: schema: oneOf: - description: 'No case with that id' type: object example: success: false message: 'The selected case id is invalid.' errors: case_id: - 'The selected case id is invalid.' properties: success: type: boolean example: false message: type: string example: 'The selected case id is invalid.' errors: type: object properties: case_id: type: array example: - 'The selected case id is invalid.' items: type: string - description: 'pinned left out' type: object example: success: false message: 'The pinned field is required.' errors: pinned: - 'The pinned field is required.' properties: success: type: boolean example: false message: type: string example: 'The pinned field is required.' errors: type: object properties: pinned: type: array example: - 'The pinned field is required.' items: type: string - description: 'pinned is not a boolean' type: object example: success: false message: 'The pinned field must be true or false.' errors: pinned: - 'The pinned field must be true or false.' properties: success: type: boolean example: false message: type: string example: 'The pinned field must be true or false.' errors: type: object properties: pinned: type: array example: - 'The pinned field must be true or false.' items: type: string tags: - 'Admin - Cases' requestBody: required: true content: application/json: schema: type: object properties: case_id: type: string description: 'The case to pin or unpin. Must match an existing stored value.' example: 501 pinned: type: boolean description: '`true` pins the case to the top of the feed, `false` removes the pin.' example: true required: - case_id - pinned /api/v1/admin/questions: get: summary: 'Question List' operationId: questionList description: 'Paginated, filterable list of bank questions.' parameters: - in: query name: category description: 'Exact match.' example: Dermoscopy required: false schema: type: string description: 'Exact match.' example: Dermoscopy - in: query name: subcategory description: 'Exact match.' example: 'Pigmented lesions' required: false schema: type: string description: 'Exact match.' example: 'Pigmented lesions' - in: query name: type description: '`single_choice`, `multiple_choice`, `true_false`, `match_following`, or `poll`.' example: single_choice required: false schema: type: string description: '`single_choice`, `multiple_choice`, `true_false`, `match_following`, or `poll`.' example: single_choice - in: query name: difficulty description: '1 to 3.' example: 2 required: false schema: type: integer description: '1 to 3.' example: 2 - in: query name: status description: '`all`, `draft`, `pending_review`, `approved`, or `rejected`.' example: pending_review required: false schema: type: string description: '`all`, `draft`, `pending_review`, `approved`, or `rejected`.' example: pending_review - in: query name: tag description: 'Matches a single tag.' example: dermoscopy required: false schema: type: string description: 'Matches a single tag.' example: dermoscopy - in: query name: search description: 'Matches the question text.' example: seborrheic required: false schema: type: string description: 'Matches the question text.' example: seborrheic - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Questions per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Questions per page, capped at 100. Defaults to 20.' example: 20 responses: { } tags: - 'Admin - Questions' post: summary: 'Create Question' operationId: createQuestion description: "Adds a question to the bank at `status = draft`. Submit it for review\nseparately once it is ready." parameters: [] responses: { } tags: - 'Admin - Questions' requestBody: required: true content: multipart/form-data: schema: type: object properties: category: type: string description: 'Organizes the question in the bank.' example: Dermoscopy subcategory: type: string description: 'Optional, narrower than category.' example: 'Pigmented lesions' question: type: string description: 'The question text.' example: 'Which dermoscopic feature is most suggestive of seborrheic keratosis?' type: type: string description: '`single_choice`, `multiple_choice`, `true_false`, `match_following`, or `poll`.' example: single_choice enum: - single_choice - multiple_choice - true_false - match_following - poll image: type: string format: binary description: 'Optional image, up to 8 MB. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes.' nullable: true explanation: type: string description: 'Shown to the student after they answer.' example: 'Milia-like cysts are the classic dermoscopic clue for seborrheic keratosis.' reference: type: string description: 'A citation or source, shown alongside the explanation.' example: 'Braun RP, et al. Dermoscopy of pigmented skin lesions.' tags: type: object description: 'Array of free-text tags.' example: - dermoscopy - seborrheic-keratosis properties: { } difficulty: type: integer description: '1 (easy) to 3 (hard). Must be between 1 and 3.' example: 2 nullable: true options: type: object description: 'At least two option texts. Required for every type except match_following. Must have at least 2 items.' example: - 'Blue-white veil' - 'Milia-like cysts' - 'Irregular streaks' - Regression properties: { } correct_answer: type: string description: 'The option text (or id) that is correct. Ignored for poll.' example: 'Milia-like cysts' correct_answers: type: object description: 'Use instead of correct_answer for Multiple Correct Answers. Must have at least 1 items.' example: null properties: { } pairs: type: array description: 'match_following only: at least two `{left, right}` pairs. Must have at least 2 items.' example: null items: type: object properties: left: type: string description: 'This field is required when pairs is present.' example: null right: type: string description: 'This field is required when pairs is present.' example: null required: - category - question - type - options - correct_answer /api/v1/admin/questions/status: post: summary: 'Review Question' operationId: reviewQuestion description: "Moves a question to `pending_review`, `approved`, `rejected`, or back\nto `draft`, recording who reviewed it and when. Approving clears any\nprevious rejection reason. Only `approved` questions can be attached\nto a quiz." parameters: [] responses: 200: description: Approved content: application/json: schema: type: object example: success: true message: 'Question approved successfully.' data: question_id: 901 status: approved properties: success: type: boolean example: true message: type: string example: 'Question approved successfully.' data: type: object properties: question_id: type: integer example: 901 status: type: string example: approved tags: - 'Admin - Questions' requestBody: required: true content: application/json: schema: type: object properties: question_id: type: string description: 'The question to review. Must match an existing stored value.' example: 901 status: type: string description: 'One of `draft`, `pending_review`, `approved`, or `rejected`.' example: approved enum: - draft - pending_review - approved - rejected reason: type: string description: 'Optional explanation, shown to the author when rejecting.' example: 'Reference is missing.' required: - question_id - status /api/v1/admin/questions/approve-selected: post: summary: 'Approve Selected Questions' operationId: approveSelectedQuestions description: 'Bulk-approves every listed question in one call.' parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: '2 questions approved.' data: approved: 2 properties: success: type: boolean example: true message: type: string example: '2 questions approved.' data: type: object properties: approved: type: integer example: 2 tags: - 'Admin - Questions' requestBody: required: true content: application/json: schema: type: object properties: question_ids: type: array description: 'The questions to approve.' example: - 901 - 902 items: type: integer required: - question_ids '/api/v1/admin/questions/{id}': get: summary: 'Get Question' operationId: getQuestion description: 'A single bank question, including its answer key.' parameters: [] responses: { } tags: - 'Admin - Questions' post: summary: 'Update Question' operationId: updateQuestion description: "Partially updates a question's content. Does not change its review\nstatus - use the status endpoint for that." parameters: [] responses: { } tags: - 'Admin - Questions' requestBody: required: false content: multipart/form-data: schema: type: object properties: category: type: string description: 'Organizes the question in the bank.' example: Dermoscopy subcategory: type: string description: '' example: null question: type: string description: 'The question text.' example: 'Which dermoscopic feature is most suggestive of seborrheic keratosis?' type: type: string description: '`single_choice`, `multiple_choice`, `true_false`, `match_following`, or `poll`.' example: single_choice enum: - single_choice - multiple_choice - true_false - match_following - poll image: type: string format: binary description: 'Replacement image, up to 8 MB. Omit to keep the current one. Must be an image. Must not be greater than 8192 kilobytes.' nullable: true explanation: type: string description: '' example: null reference: type: string description: '' example: null tags: type: object description: '' example: null properties: { } difficulty: type: integer description: 'Must be between 1 and 3.' example: 2 nullable: true options: type: object description: 'Send to replace the option set. Must have at least 2 items.' example: null properties: { } correct_answer: type: string description: '' example: null correct_answers: type: object description: 'Must have at least 1 items.' example: null properties: { } pairs: type: array description: 'Must have at least 2 items.' example: null items: type: object properties: left: type: string description: 'This field is required when pairs is present.' example: null right: type: string description: 'This field is required when pairs is present.' example: null delete: summary: 'Delete Question' operationId: deleteQuestion description: "Permanently deletes a question and detaches it from every quiz it was\nattached to. Quizzes themselves are not deleted." parameters: [] responses: { } tags: - 'Admin - Questions' parameters: - in: path name: id description: 'The ID of the question.' example: 1 required: true schema: type: integer - in: path name: question description: 'The question ID.' example: 901 required: true schema: type: integer /api/v1/admin/quizzes: get: summary: 'Quiz List' operationId: quizList description: 'Paginated list of quizzes with their question and submission counts.' parameters: - in: query name: status description: '`all`, `draft`, or `published`.' example: published required: false schema: type: string description: '`all`, `draft`, or `published`.' example: published - in: query name: format description: '`all`, `standard`, `poll`, or `clinical_case`.' example: standard required: false schema: type: string description: '`all`, `standard`, `poll`, or `clinical_case`.' example: standard - in: query name: search description: 'Matches the quiz title.' example: knowledge required: false schema: type: string description: 'Matches the quiz title.' example: knowledge - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Quizzes per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Quizzes per page, capped at 100. Defaults to 20.' example: 20 responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Quizzes retrieved successfully.' data: - id: 301 title: 'Medical Knowledge Quiz' description: 'Test your medical knowledge.' format: standard total_questions: 20 status: published total_submissions: 145 created_at: '2026-08-31T11:00:00.000000Z' pagination: current_page: 1 per_page: 20 total: 25 last_page: 2 properties: success: type: boolean example: true message: type: string example: 'Quizzes retrieved successfully.' data: type: array example: - id: 301 title: 'Medical Knowledge Quiz' description: 'Test your medical knowledge.' format: standard total_questions: 20 status: published total_submissions: 145 created_at: '2026-08-31T11:00:00.000000Z' items: type: object properties: id: type: integer example: 301 title: type: string example: 'Medical Knowledge Quiz' description: type: string example: 'Test your medical knowledge.' format: type: string example: standard total_questions: type: integer example: 20 status: type: string example: published total_submissions: type: integer example: 145 created_at: type: string example: '2026-08-31T11:00:00.000000Z' pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 25 last_page: type: integer example: 2 tags: - 'Admin - Quizzes' post: summary: 'Create Quiz' operationId: createQuiz description: "Creates a quiz and attaches the given, already-approved bank questions\nin order." parameters: [] responses: { } tags: - 'Admin - Quizzes' requestBody: required: true content: multipart/form-data: schema: type: object properties: title: type: string description: 'The quiz title.' example: 'Medical Knowledge Quiz' description: type: string description: 'Optional summary shown to students.' example: 'Test your medical knowledge.' format: type: string description: '`standard`, `poll`, or `clinical_case`. Defaults to `standard`.' example: standard enum: - standard - poll - clinical_case status: type: string description: '`draft` or `published`. Defaults to `draft`.' example: published enum: - draft - published question_ids: type: array description: 'Must match an existing stored value.' example: - 16 items: type: integer clinical_image: type: string format: binary description: 'Required when format is clinical_case. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes.' nullable: true dermoscopic_image: type: string format: binary description: 'Optional, clinical_case only. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes.' nullable: true history: type: string description: 'clinical_case only. Shown to the student before the questions.' example: '52-year-old male, 6-month history of an enlarging pigmented lesion.' diagnosis: type: string description: 'clinical_case only. Revealed after submission.' example: 'Superficial spreading melanoma.' management: type: string description: 'clinical_case only. Revealed after submission.' example: 'Urgent excision with 1cm margins and staging workup.' required: - title '/api/v1/admin/quizzes/{id}': get: summary: 'Get Quiz' operationId: getQuiz description: "A single quiz with its attached questions, including the answer key.\nUse this to populate an edit form." parameters: [] responses: { } tags: - 'Admin - Quizzes' post: summary: 'Update Quiz' operationId: updateQuiz description: "Partially updates a quiz. Omit `question_ids` to change only the\ntitle, description, or status, which is how publishing and\nunpublishing works. Send `question_ids` to replace the attached set." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Quiz updated successfully.' data: id: 301 title: 'Medical Knowledge Quiz' status: published properties: success: type: boolean example: true message: type: string example: 'Quiz updated successfully.' data: type: object properties: id: type: integer example: 301 title: type: string example: 'Medical Knowledge Quiz' status: type: string example: published tags: - 'Admin - Quizzes' requestBody: required: false content: multipart/form-data: schema: type: object properties: title: type: string description: 'The quiz title.' example: 'Medical Knowledge Quiz' description: type: string description: '' example: null format: type: string description: '' example: standard enum: - standard - poll - clinical_case status: type: string description: '`draft` or `published`.' example: published enum: - draft - published question_ids: type: array description: 'Must match an existing stored value.' example: - 16 items: type: integer clinical_image: type: string format: binary description: 'Must be an image. Must not be greater than 8192 kilobytes.' nullable: true dermoscopic_image: type: string format: binary description: 'Must be an image. Must not be greater than 8192 kilobytes.' nullable: true history: type: string description: '' example: null diagnosis: type: string description: '' example: null management: type: string description: '' example: null delete: summary: 'Delete Quiz' operationId: deleteQuiz description: "Permanently deletes a quiz and every submission recorded against it.\nAttached bank questions are only detached, never deleted. This cannot\nbe undone." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Quiz deleted successfully.' properties: success: type: boolean example: true message: type: string example: 'Quiz deleted successfully.' tags: - 'Admin - Quizzes' parameters: - in: path name: id description: 'The ID of the quiz.' example: 1 required: true schema: type: integer - in: path name: quiz description: 'The quiz ID.' example: 301 required: true schema: type: integer '/api/v1/admin/quizzes/{quiz_id}/submissions': get: summary: 'Quiz Submissions' operationId: quizSubmissions description: "Who has taken a quiz, and what they scored. `score` counts correct\nanswers out of `total_questions`. Not meaningful for a `poll` quiz." parameters: - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Submissions per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Submissions per page, capped at 100. Defaults to 20.' example: 20 responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Quiz submissions retrieved successfully.' data: quiz_id: 301 quiz_title: 'Medical Knowledge Quiz' total_submissions: 145 submissions: - user_id: 101 user_name: 'Dr. Ahmed Khan' submitted_at: '2026-08-31T12:00:00.000000Z' score: 18 total_questions: 20 pagination: current_page: 1 per_page: 20 total: 145 last_page: 8 properties: success: type: boolean example: true message: type: string example: 'Quiz submissions retrieved successfully.' data: type: object properties: quiz_id: type: integer example: 301 quiz_title: type: string example: 'Medical Knowledge Quiz' total_submissions: type: integer example: 145 submissions: type: array example: - user_id: 101 user_name: 'Dr. Ahmed Khan' submitted_at: '2026-08-31T12:00:00.000000Z' score: 18 total_questions: 20 items: type: object properties: user_id: type: integer example: 101 user_name: type: string example: 'Dr. Ahmed Khan' submitted_at: type: string example: '2026-08-31T12:00:00.000000Z' score: type: integer example: 18 total_questions: type: integer example: 20 pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 145 last_page: type: integer example: 8 tags: - 'Admin - Quizzes' parameters: - in: path name: quiz_id description: 'The ID of the quiz.' example: 1 required: true schema: type: integer - in: path name: quiz description: 'The quiz ID.' example: 301 required: true schema: type: integer /api/v1/admin/learning: get: summary: 'Learning List' operationId: learningList description: 'Paginated list of learning articles with their like counts.' parameters: - in: query name: status description: '`all`, `draft`, or `published`.' example: published required: false schema: type: string description: '`all`, `draft`, or `published`.' example: published - in: query name: search description: 'Matches the title.' example: diagnosis required: false schema: type: string description: 'Matches the title.' example: diagnosis - in: query name: page description: 'Page number. Defaults to 1.' example: 1 required: false schema: type: integer description: 'Page number. Defaults to 1.' example: 1 - in: query name: limit description: 'Items per page, capped at 100. Defaults to 20.' example: 20 required: false schema: type: integer description: 'Items per page, capped at 100. Defaults to 20.' example: 20 responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Learning content retrieved successfully.' data: - id: 701 image: 'https://dots.mhn.services/storage/learning/cover.jpg' title: 'Introduction to Clinical Diagnosis' description: 'Basic information about clinical diagnosis.' content: 'Complete learning content here.' status: published total_likes: 125 created_at: '2026-08-31T12:30:00.000000Z' pagination: current_page: 1 per_page: 20 total: 40 last_page: 2 properties: success: type: boolean example: true message: type: string example: 'Learning content retrieved successfully.' data: type: array example: - id: 701 image: 'https://dots.mhn.services/storage/learning/cover.jpg' title: 'Introduction to Clinical Diagnosis' description: 'Basic information about clinical diagnosis.' content: 'Complete learning content here.' status: published total_likes: 125 created_at: '2026-08-31T12:30:00.000000Z' items: type: object properties: id: type: integer example: 701 image: type: string example: 'https://dots.mhn.services/storage/learning/cover.jpg' title: type: string example: 'Introduction to Clinical Diagnosis' description: type: string example: 'Basic information about clinical diagnosis.' content: type: string example: 'Complete learning content here.' status: type: string example: published total_likes: type: integer example: 125 created_at: type: string example: '2026-08-31T12:30:00.000000Z' pagination: type: object properties: current_page: type: integer example: 1 per_page: type: integer example: 20 total: type: integer example: 40 last_page: type: integer example: 2 tags: - 'Admin - Learning' post: summary: 'Create Learning Content' operationId: createLearningContent description: 'Send as `multipart/form-data`; the cover image is required.' parameters: [] responses: 201: description: Success content: application/json: schema: type: object example: success: true message: 'Learning content created successfully.' data: id: 701 image: 'https://dots.mhn.services/storage/learning/cover.jpg' title: 'Introduction to Clinical Diagnosis' description: 'Basic information about clinical diagnosis.' content: 'Complete learning content here.' status: published total_likes: 0 created_at: '2026-08-31T12:30:00.000000Z' properties: success: type: boolean example: true message: type: string example: 'Learning content created successfully.' data: type: object properties: id: type: integer example: 701 image: type: string example: 'https://dots.mhn.services/storage/learning/cover.jpg' title: type: string example: 'Introduction to Clinical Diagnosis' description: type: string example: 'Basic information about clinical diagnosis.' content: type: string example: 'Complete learning content here.' status: type: string example: published total_likes: type: integer example: 0 created_at: type: string example: '2026-08-31T12:30:00.000000Z' tags: - 'Admin - Learning' requestBody: required: true content: multipart/form-data: schema: type: object properties: title: type: string description: 'The article title.' example: 'Introduction to Clinical Diagnosis' description: type: string description: 'Short summary shown in the list.' example: 'Basic information about clinical diagnosis.' content: type: string description: 'The full article body.' example: 'Complete learning content here.' status: type: string description: '`draft` or `published`. Defaults to `draft`.' example: published enum: - draft - published image: type: string format: binary description: 'Cover image, up to 8 MB. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes.' required: - title - image '/api/v1/admin/learning/{id}': get: summary: 'Get Learning Content' operationId: getLearningContent description: 'A single article, for populating an edit form.' parameters: [] responses: { } tags: - 'Admin - Learning' post: summary: 'Update Learning Content' operationId: updateLearningContent description: "Partially updates an article. Send as `multipart/form-data` when\nreplacing the image; the old file is deleted. Omit `image` to keep it." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Learning content updated successfully.' data: id: 701 title: 'Updated Learning Title' description: 'Updated description' status: published properties: success: type: boolean example: true message: type: string example: 'Learning content updated successfully.' data: type: object properties: id: type: integer example: 701 title: type: string example: 'Updated Learning Title' description: type: string example: 'Updated description' status: type: string example: published tags: - 'Admin - Learning' requestBody: required: false content: multipart/form-data: schema: type: object properties: title: type: string description: 'The article title.' example: 'Updated Learning Title' description: type: string description: 'Short summary shown in the list.' example: 'Updated description' content: type: string description: 'The full article body.' example: 'Updated learning content' status: type: string description: '`draft` or `published`.' example: published enum: - draft - published image: type: string format: binary description: 'Replacement cover image, up to 8 MB. Omit to keep the current one. Must be an image. Must not be greater than 8192 kilobytes.' delete: summary: 'Delete Learning Content' operationId: deleteLearningContent description: 'Permanently deletes an article, its cover image file, and its likes.' parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: success: true message: 'Learning content deleted successfully.' properties: success: type: boolean example: true message: type: string example: 'Learning content deleted successfully.' tags: - 'Admin - Learning' parameters: - in: path name: id description: 'The ID of the learning.' example: 1 required: true schema: type: integer - in: path name: learning description: 'The learning content ID.' example: 701 required: true schema: type: integer /api/v1/notifications: get: summary: 'List Notifications' operationId: listNotifications description: "Paginated with `skip`/`limit` query params - `skip` defaults to 0,\n`limit` defaults to 15 (max 100). Newest first.\n\n`counts` reports the totals for the user's whole feed (not just the\ncurrent page), so the app can render a KPI/badge without a separate\nrequest." parameters: - in: query name: skip description: 'Number of notifications to skip. Defaults to 0.' example: 0 required: false schema: type: integer description: 'Number of notifications to skip. Defaults to 0.' example: 0 - in: query name: limit description: 'Max notifications to return (capped at 100). Defaults to 15.' example: 15 required: false schema: type: integer description: 'Max notifications to return (capped at 100). Defaults to 15.' example: 15 responses: 200: description: Success content: application/json: schema: type: object example: data: - id: 9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31 type: comment_added title: 'New Comment' body: 'Dr John Roe commented on your post.' data: case_id: 12 comment_id: 5 commenter_id: 4 read: false read_at: null created_at: '2026-09-17T06:00:00.000000Z' meta: skip: 0 limit: 15 total: 1 counts: total: 1 unread: 1 read: 0 properties: data: type: array example: - id: 9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31 type: comment_added title: 'New Comment' body: 'Dr John Roe commented on your post.' data: case_id: 12 comment_id: 5 commenter_id: 4 read: false read_at: null created_at: '2026-09-17T06:00:00.000000Z' items: type: object properties: id: type: string example: 9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31 type: type: string example: comment_added title: type: string example: 'New Comment' body: type: string example: 'Dr John Roe commented on your post.' data: type: object properties: case_id: type: integer example: 12 comment_id: type: integer example: 5 commenter_id: type: integer example: 4 read: type: boolean example: false read_at: type: string example: null nullable: true created_at: type: string example: '2026-09-17T06:00:00.000000Z' meta: type: object properties: skip: type: integer example: 0 limit: type: integer example: 15 total: type: integer example: 1 counts: type: object properties: total: type: integer example: 1 unread: type: integer example: 1 read: type: integer example: 0 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. tags: - Notifications /api/v1/notifications/read-all: post: summary: 'Mark All Notifications as Read' operationId: markAllNotificationsAsRead description: '' parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: message: 'All notifications marked as read.' properties: message: type: string example: 'All notifications marked as read.' 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. tags: - Notifications '/api/v1/notifications/{notification}/read': post: summary: 'Mark Notification as Read' operationId: markNotificationAsRead description: '' parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: message: 'Notification marked as read.' properties: message: type: string example: 'Notification marked as read.' 401: description: 'Missing or expired token' content: application/json: schema: type: object example: message: Unauthenticated. properties: message: type: string example: Unauthenticated. 404: description: 'No notification with that id for this user' content: application/json: schema: type: object example: message: 'No query results for model [Illuminate\Notifications\DatabaseNotification].' properties: message: type: string example: 'No query results for model [Illuminate\Notifications\DatabaseNotification].' tags: - Notifications parameters: - in: path name: notification description: 'The notification ID.' example: 9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31 required: true schema: type: string '/api/v1/users/{id}': get: summary: 'View User Profile' operationId: viewUserProfile description: "Public profile of any user, for when someone taps a name or avatar in the\ncase feed or a comment thread.\n\nThis is narrower than Get Profile: email, phone number, and the\nPMDC/fellowship/institutional numbers are private to the account owner\nand are never returned here. `cases_count` counts only the user's\napproved cases, matching what the feed shows." parameters: [] responses: 200: description: Success content: application/json: schema: type: object example: data: id: 3 full_name: 'Dr Jane Doe' designation: Consultant bio: 'Dermatologist with a special interest in dermoscopy.' province: Punjab city: Lahore role: student avatar_url: null cases_count: 4 created_at: '2026-07-27T10:08:49.000000Z' properties: data: type: object properties: id: type: integer example: 3 full_name: type: string example: 'Dr Jane Doe' designation: type: string example: Consultant bio: type: string example: 'Dermatologist with a special interest in dermoscopy.' province: type: string example: Punjab city: type: string example: Lahore role: type: string example: student avatar_url: type: string example: null nullable: true cases_count: type: integer example: 4 created_at: type: string example: '2026-07-27T10:08:49.000000Z' 404: description: 'No such user' content: application/json: schema: type: object example: message: 'Resource not found.' properties: message: type: string example: 'Resource not found.' tags: - Users parameters: - in: path name: id description: 'The ID of the user.' example: 1 required: true schema: type: integer - in: path name: user description: 'The user ID.' example: 3 required: true schema: type: integer