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