Hospital Management System API

v1.0.0 · 555 endpoints

Utangulizi

Hii ndiyo API moja inayotumiwa na kila kitu kwenye mfumo huu: konsoli ya hospitali, mlango wa mgonjwa, na app yoyote ya simu au software ya desktop utakayojenga. Hakuna API ya pili iliyofichwa mahali pengine.

Kila endpoint kwenye ukurasa huu imesomwa moja kwa moja kutoka kwenye msimbo wa seva (backend/internal/router/router.go na backend/internal/handlers/). Hakuna kitu kilichoandikwa kwa mkono hapa, kwa hiyo kinachoandikwa ndicho seva inachofanya. Ikiwa endpoint imebadilika, ukurasa huu unabadilika inapotengenezwa upya.

Kanuni tatu za kukumbuka:

  1. Kila jibu lina muundo mmoja: {success, message, data, meta}.
  2. Karibu kila njia inahitaji tokeni na ruhusa mahususi. Kificha kitufe kwenye app si ulinzi — seva ndiyo inayozuia.
  3. Ujumbe wa makosa umeandikwa kwa lugha ya binadamu. Uonyeshe kama ulivyo badala ya kubuni ujumbe wako mwenyewe.

Introduction

This is the single API behind everything in this system: the hospital console, the patient portal, and any mobile app or desktop program you build. There is no second, hidden API somewhere else.

Every endpoint on this page was read straight out of the server's own source (backend/internal/router/router.go and backend/internal/handlers/). Nothing here is hand-maintained, so what it says is what the server does.

Three rules to carry with you:

  1. Every reply has one shape: {success, message, data, meta}.
  2. Almost every route needs a token and a specific permission. Hiding a button in your app is manners; the server is the thing that protects the record.
  3. Error messages are written for a human to read. Show them as they are rather than inventing your own wording.

Anwani za seva

Trafiki yote ya umma hupitia api-gateway. Njia hazibadilishwi njiani — unachokiona hapa (/api/v1/...) ndicho unachotuma.

Base URLs

All public traffic goes through the api-gateway. Paths are not rewritten on the way — what you see here (/api/v1/...) is what you send.

EnvironmentBase URLNote
Local stackhttp://localhost:4000api-gateway, the only public port
Backend directhttp://localhost:8080Development and the liveness probe only
Deployed hospitalhttps://<host>The console's nginx proxies /api/ to the gateway
Emulator (Android)http://10.0.2.2:4000localhost inside the emulator is the emulator
Usiweke anwani ndani ya app kwa nguvu. Hospitali tofauti = seva tofauti. Iulize mtumiaji anwani mara ya kwanza, ihifadhi, na iruhusu kubadilishwa kwenye mipangilio. Kisha thibitisha kwa GET /api/v1/settings/system — inajibu bila tokeni na inarudisha jina la hospitali na nembo, kwa hiyo mtumiaji anaona mara moja kama ameandika anwani sahihi. Do not bake the host into the app. A different hospital is a different server. Ask for the address on first run, store it, and let it be changed in settings. Then confirm it with GET /api/v1/settings/system — it answers without a token and returns the hospital's name and logo, so the user can see at once whether they typed the right address.

Kuingia na tokeni

Mtiririko ni huu: login → (labda 2FA) → tokeni → refresh → logout.

  1. POST /api/v1/auth/login ukiwa na identifier (jina la mtumiaji, barua pepe au simu) na password.
  2. Ikiwa akaunti ina 2FA, jibu litakuwa requires_2fa: true pamoja na session_token. Hakuna tokeni bado. Tuma msimbo kwa POST /api/v1/auth/verify-2fa.
  3. Utapokea data.tokens: access_token, refresh_token, expires_in. Tuma Authorization: Bearer <access_token> kwenye kila ombi linalofuata.
  4. Ukipata 401, tumia POST /api/v1/auth/refresh mara moja (soma onyo hapa chini), kisha rudia ombi. Refresh ikishindwa, mrudishe mtumiaji kwenye skrini ya kuingia.
  5. POST /api/v1/auth/logout inafunga kikao upande wa seva. Futa tokeni zako pia.

Jibu la login pia linabeba kila kitu app yako inahitaji kujichora: permissions (orodha kamili), roles, facility (tawi la mtumiaji), branches kama anasimamia matawi yote, na country kwa ajili ya namba za simu. Chora menyu yako kutoka permissions, si kutoka orodha uliyoandika kwenye app.

Sign-in and tokens

The flow is: login → (maybe 2FA) → tokens → refresh → logout.

  1. POST /api/v1/auth/login with an identifier (username, email or phone) and password.
  2. If the account has two-factor on, the reply is requires_2fa: true with a session_token. No tokens are issued yet. Answer with the code at POST /api/v1/auth/verify-2fa.
  3. You receive data.tokens: access_token, refresh_token, expires_in. Send Authorization: Bearer <access_token> on every request after that.
  4. On a 401, call POST /api/v1/auth/refresh once (see the warning below) and retry. If the refresh fails, send the user back to sign-in.
  5. POST /api/v1/auth/logout closes the session server-side. Clear your stored tokens too.

The login reply also carries everything your app needs to draw itself: the full permissions list, roles, the user's facility, branches when the account spans them, and country for phone-number formatting. Draw your menu from permissions, not from a list you wrote into the app.

Refresh moja tu kwa wakati. Dashibodi hufungua maombi tisa kwa pamoja; tokeni ikiisha muda, yote tisa yanarudi 401 kwa sekunde moja. Ukianzisha refresh tisa, seva inayozungusha refresh token itabatilisha nane — na mtumiaji anatolewa nje kwa tatizo dogo lililoweza kuvumiliwa. Weka refresh moja na yaliyobaki yaisubiri. One refresh at a time. A dashboard opens nine requests at once; when the access token has just expired, all nine come back 401 within milliseconds. Nine refreshes spend the refresh token nine times, and a server that rotates it invalidates the other eight — signing the operator out over a survivable expiry. Hold one refresh promise and let the rest wait on it.
# 1. sign in
curl -X POST http://localhost:4000/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"identifier":"admin","password":"secret"}'

# 2. use the access token
curl http://localhost:4000/api/v1/patients?page=1&per_page=20 \
  -H 'Authorization: Bearer eyJhbGciOi...'

# 3. refresh when a call comes back 401
curl -X POST http://localhost:4000/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"eyJhbGciOi..."}'
const BASE = 'https://hospital.example.org';
let tokens = null;
let refreshing = null;               // ONE in flight, shared by every caller

async function call(path, init = {}, retry = true) {
  const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
  if (tokens) headers.Authorization = 'Bearer ' + tokens.access_token;

  const res = await fetch(BASE + '/api/v1' + path, { ...init, headers });
  const body = await res.json().catch(() => ({}));

  if (res.status === 401 && retry && tokens?.refresh_token) {
    refreshing = refreshing || refresh();
    const ok = await refreshing;
    refreshing = null;
    if (ok) return call(path, init, false);
  }
  if (!res.ok || body.success === false) {
    throw Object.assign(new Error(body.message || 'Request failed'), { status: res.status });
  }
  return body;                        // { success, message, data, meta? }
}

async function refresh() {
  const res = await fetch(BASE + '/api/v1/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refresh_token: tokens.refresh_token }),
  });
  if (!res.ok) { tokens = null; return false; }
  tokens = (await res.json()).data.tokens;
  return true;
}

export async function login(identifier, password) {
  const body = await call('/auth/login', {
    method: 'POST', body: JSON.stringify({ identifier, password }),
  });
  if (body.data.requires_2fa) return { needsCode: true, sessionToken: body.data.session_token };
  tokens = body.data.tokens;
  return { needsCode: false, session: body.data };   // permissions live here
}
import 'dart:convert';
import 'package:http/http.dart' as http;

class Hms {
  Hms(this.base);
  final String base;                 // e.g. https://hospital.example.org
  Map<String, dynamic>? tokens;
  Future<bool>? _refreshing;

  Uri _u(String path, [Map<String, String>? q]) =>
      Uri.parse('$base/api/v1$path').replace(queryParameters: q);

  Future<Map<String, dynamic>> call(String path, {
    String method = 'GET', Object? body, Map<String, String>? query, bool retry = true,
  }) async {
    final headers = {'Content-Type': 'application/json'};
    if (tokens != null) headers['Authorization'] = 'Bearer ${tokens!['access_token']}';

    final req = http.Request(method, _u(path, query))..headers.addAll(headers);
    if (body != null) req.body = jsonEncode(body);
    final res = await http.Response.fromStream(await req.send());
    final decoded = res.body.isEmpty ? {} : jsonDecode(res.body) as Map<String, dynamic>;

    if (res.statusCode == 401 && retry && tokens != null) {
      _refreshing ??= _refresh();
      final ok = await _refreshing!;
      _refreshing = null;
      if (ok) return call(path, method: method, body: body, query: query, retry: false);
    }
    if (res.statusCode >= 400 || decoded['success'] == false) {
      throw HmsError(decoded['message'] ?? 'Request failed', res.statusCode);
    }
    return decoded;
  }

  Future<bool> _refresh() async {
    final res = await http.post(_u('/auth/refresh'),
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode({'refresh_token': tokens!['refresh_token']}));
    if (res.statusCode >= 400) { tokens = null; return false; }
    tokens = jsonDecode(res.body)['data']['tokens'] as Map<String, dynamic>;
    return true;
  }
}

class HmsError implements Exception {
  HmsError(this.message, this.status);
  final String message; final int status;
  bool get isForbidden => status == 403;
  @override String toString() => message;   // show the server's own wording
}
// Kotlin + OkHttp. The Authenticator handles 401 for every call in one place,
// and OkHttp serialises them for you, so nine parallel 401s spend one refresh.
val client = OkHttpClient.Builder()
    .addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .apply { store.access?.let { header("Authorization", "Bearer $it") } }
            .build()
        chain.proceed(request)
    }
    .authenticator { _, response ->
        if (response.request.header("Authorization") == null) return@authenticator null
        val refreshed = synchronized(store) { store.refreshOnce() } ?: return@authenticator null
        response.request.newBuilder().header("Authorization", "Bearer $refreshed").build()
    }
    .build()

data class Envelope<T>(val success: Boolean, val message: String, val data: T?, val meta: PageMeta?)
data class PageMeta(val current_page: Int, val per_page: Int, val last_page: Int, val total: Int)

interface Api {
    @POST("api/v1/auth/login")
    suspend fun login(@Body body: LoginRequest): Envelope<Session>

    @GET("api/v1/patients")
    suspend fun patients(
        @Query("search") search: String? = null,
        @Query("page") page: Int = 1,
        @Query("per_page") perPage: Int = 50,
    ): Envelope<List<Patient>>
}
struct Envelope<T: Decodable>: Decodable {
    let success: Bool; let message: String; let data: T?
}

actor Hms {
    private let base: URL
    private var tokens: Tokens?
    private var refreshTask: Task<Bool, Never>?      // one refresh, shared

    init(base: URL) { self.base = base }

    func call<T: Decodable>(_ path: String, method: String = "GET",
                            body: Encodable? = nil, retry: Bool = true) async throws -> T {
        var req = URLRequest(url: base.appendingPathComponent("api/v1" + path))
        req.httpMethod = method
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        if let token = tokens?.accessToken {
            req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        if let body { req.httpBody = try JSONEncoder().encode(AnyEncodable(body)) }

        let (data, response) = try await URLSession.shared.data(for: req)
        let status = (response as? HTTPURLResponse)?.statusCode ?? 0

        if status == 401, retry, tokens != nil {
            let task = refreshTask ?? Task { await self.refresh() }
            refreshTask = task
            let ok = await task.value
            refreshTask = nil
            if ok { return try await call(path, method: method, body: body, retry: false) }
        }
        let envelope = try JSONDecoder().decode(Envelope<T>.self, from: data)
        guard envelope.success, let payload = envelope.data else {
            throw HmsError(message: envelope.message, status: status)   // show it verbatim
        }
        return payload
    }
}
// .NET — a DelegatingHandler keeps the retry out of every call site.
public sealed class HmsAuthHandler : DelegatingHandler {
    private readonly TokenStore _store;
    private readonly SemaphoreSlim _gate = new(1, 1);   // one refresh at a time

    protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken ct) {
        request.Headers.Authorization = new("Bearer", _store.AccessToken);
        var response = await base.SendAsync(request, ct);
        if (response.StatusCode != HttpStatusCode.Unauthorized) return response;

        await _gate.WaitAsync(ct);
        try {
            if (!await _store.RefreshAsync(ct)) return response;   // session is gone
        } finally { _gate.Release(); }

        var retry = await CloneAsync(request);
        retry.Headers.Authorization = new("Bearer", _store.AccessToken);
        return await base.SendAsync(retry, ct);
    }
}

public record Envelope<T>(bool Success, string Message, T? Data, PageMeta? Meta);
public record PageMeta(int Current_page, int Per_page, int Last_page, int Total);
import requests

class Hms:
    def __init__(self, base):
        self.base, self.tokens = base.rstrip('/'), None
        self.http = requests.Session()

    def call(self, path, method='GET', json=None, params=None, _retry=True):
        headers = {}
        if self.tokens:
            headers['Authorization'] = 'Bearer ' + self.tokens['access_token']
        res = self.http.request(method, f'{self.base}/api/v1{path}',
                                json=json, params=params, headers=headers, timeout=30)
        body = res.json() if res.content else {}

        if res.status_code == 401 and _retry and self.tokens:
            if self.refresh():
                return self.call(path, method, json, params, _retry=False)
        if res.status_code >= 400 or body.get('success') is False:
            raise HmsError(body.get('message', 'Request failed'), res.status_code)
        return body

    def login(self, identifier, password):
        body = self.call('/auth/login', 'POST', {'identifier': identifier, 'password': password})
        if body['data'].get('requires_2fa'):
            return body['data']            # answer it with /auth/verify-2fa
        self.tokens = body['data']['tokens']
        return body['data']

    def refresh(self):
        res = self.http.post(f'{self.base}/api/v1/auth/refresh',
                             json={'refresh_token': self.tokens['refresh_token']}, timeout=30)
        if res.status_code >= 400:
            self.tokens = None
            return False
        self.tokens = res.json()['data']['tokens']
        return True

class HmsError(Exception):
    def __init__(self, message, status):
        super().__init__(message)
        self.status = status

Muundo wa majibu

Kila endpoint — ikifanikiwa au ikishindwa — inajibu kwa muundo mmoja. Andika kichanganuzi kimoja, kitumie kila mahali.

Response envelope

Every endpoint — success or failure — replies in one shape. Write one parser and use it everywhere.

{
  "success": true,
  "message": "Patients retrieved",
  "data": [ ... ],            // always present; null when there is nothing to send
  "meta": {                   // only on paged lists
    "current_page": 1,
    "per_page": 50,
    "last_page": 4,
    "total": 173
  }
}
data haipotei kamwe — ni null ikiwa hakuna kitu, kwa hiyo unaweza kuiingia bila kuangalia kwanza. meta inatokea tu kwenye orodha zenye kurasa. data is never absent — it is null when there is nothing, so you can index it without a nil check. meta appears only on paged lists.

Makosa

Jibu la kosa ni bahasha ile ile, ikiwa na success: false, data: null na message iliyoandikwa kwa ajili ya mtu — "A patient is already registered under that national ID". Onyesha ujumbe huo kama ulivyo.

Errors

A failure is the same envelope with success: false, data: null and a message written for a person — "A patient is already registered under that national ID". Show that message as it stands.

StatusMeaningWhat your client should do
400The body could not be read, or a required field is missingShow the message against the form
401No token, invalid, or expiredRefresh once, retry, then sign out
403Signed in, but not permitted — or the branch has the module switched offShow the message; do not retry. The two read differently on purpose
404No such recordRefresh the list — somebody may have removed it
409A conflict with what is already recordedShow the message; reload before retrying
429Rate limit (open routes only)Back off and retry later
500The server failedShow the message; allow a retry
503A service behind the backend is downRetry with backoff; say the feature is temporarily unavailable

Kurasa na uchujaji

Orodha zote hutumia maneno yale yale: ?search=, ?page=, ?per_page= (chaguo-msingi 50, kikomo 200). page_size inakubalika pia kwa daftari la wagonjwa. Soma meta.last_page ili kujua kama kuna ukurasa mwingine — usihesabu kwa kukisia urefu wa orodha.

Vichujio vya kawaida: status, from, to, patient_id, staff_id. Kila endpoint inaorodhesha vyake hapa chini.

Paging and filters

Every list speaks the same words: ?search=, ?page=, ?per_page= (default 50, capped at 200). page_size is also accepted on the patient register. Read meta.last_page to know whether there is another page — do not infer it from the length of the array.

Common filters are status, from, to, patient_id, staff_id. Each endpoint lists its own below.

GET /api/v1/patients?search=juma&status=active&page=2&per_page=50
GET /api/v1/billing/bills?status=unpaid&from=2026-01-01&to=2026-01-31

Ruhusa

Kila njia (isipokuwa chache zilizo wazi) inalindwa na msimbo wa ruhusa kama patient:read au payment:reverse. Orodha kamili ya ruhusa za mtumiaji inakuja ndani ya jibu la kuingia (data.permissions) — na pia kutoka GET /api/v1/auth/me.

Namna sahihi ya kujenga app: ficha au zima vitufe ambavyo mtumiaji hana ruhusa navyo, lakini usitegemee hiyo kama ulinzi. Seva itakataa vyovyote vile. Kuficha ni adabu; seva ndiyo ngome.

Ruhusa moja ni mahususi kabisa: portal:use. Ndiyo pekee akaunti ya mgonjwa inayoshikilia, na haifungui chochote nje ya /portal. Ukijenga app ya mgonjwa, hizo ndizo njia zako — na zote zinamtambua mgonjwa kutoka kwenye tokeni, si kutoka kwenye ombi.

Permissions

Every route but a handful of open ones is gated on a permission code such as patient:read or payment:reverse. The caller's full list arrives inside the sign-in reply (data.permissions) and from GET /api/v1/auth/me.

The right way to build against this: hide or disable what the user cannot do, but never treat that as the protection. The server refuses regardless. Hiding is manners; the server is the wall.

One permission is deliberately narrow: portal:use. It is the only one a patient account holds, and it opens nothing outside /portal. If you are building a patient app, those are your routes — and every one of them resolves the patient from the token, never from the request.

Swichi za moduli

Tawi linaweza kuzima moduli lisilo nayo — chumba cha upasuaji, benki ya damu, chumba cha maiti, gari la wagonjwa, kliniki za TB/CTC, huduma za uzazi. Endpoints zake hujibu 403 hapo, hata kama mtumiaji ana ruhusa.

403 mbili zinatofautiana kwa makusudi: moja inasema mtumiaji hana ruhusa (nenda kwa msimamizi apewe ruhusa), nyingine inasema tawi halina huduma hiyo (nenda kwa msimamizi aiwashe). Onyesha ujumbe wa seva ili mtumiaji ajue anaenda kwa nani.

Endpoints zenye swichi zimewekewa alama module hapa chini.

Module switches

A branch can switch off what it does not run — the theatre, the blood bank, the mortuary, ambulance dispatch, TB/CTC clinics, maternity. Their endpoints answer 403 there even for a user who holds the permission.

The two 403s read differently on purpose: one says the person lacks a permission (go to an administrator to be granted it), the other says the branch does not run that service (go to an administrator to have it switched on). Show the server's message so the user knows who to see.

Endpoints behind a switch are marked module below.

Faili, picha na PDF

  • Viambatisho: POST /api/v1/attachments kwa multipart/form-data (file, entity_type, entity_id). Kupakua ni GET /api/v1/attachments/:id/download.
  • Picha: GET|PUT /api/v1/photos/:subject/:id. Ruhusa inatokana na rekodi husika — anayeweza kufungua faili la mgonjwa anaweza kuona uso wake.
  • PDF: ankara, risiti, noti za krediti, taarifa za mgonjwa, vocha, oda za manunuzi na slipu za mishahara hurudi kama application/pdf si JSON. Hifadhi baiti kama zilivyo, kisha zifungue kwa kionyeshi cha PDF cha mfumo.

Kila ombi hapo linahitaji kichwa cha Authorization pia — usiweke anwani hizo moja kwa moja kwenye tagi ya <img> ambayo haipeleki tokeni.

Files, photos and PDFs

  • Attachments: POST /api/v1/attachments as multipart/form-data (file, entity_type, entity_id). Download with GET /api/v1/attachments/:id/download.
  • Photos: GET|PUT /api/v1/photos/:subject/:id. The permission comes from the subject — whoever may open the patient may see the patient's face.
  • PDFs: invoices, receipts, credit notes, patient statements, expense vouchers, purchase orders and payslips return application/pdf, not JSON. Keep the bytes as they are and hand them to the platform's PDF viewer.

All of these still need the Authorization header — so do not point a bare <img> tag at them, because it will not send one.

Kujenga app ya simu — mwanzo mwisho

Hatua zifuatazo zinatosha kutoka sifuri hadi app inayofanya kazi wodini.

  1. Anza stack: docker compose up, kisha thibitisha kwa curl http://localhost:4000/api/v1.
  2. Tengeneza mteja (client): pakua openapi.json na tumia openapi-generator (angalia sehemu ya zana), au andika safu ndogo ya HTTP kama mfano wa Dart hapo juu. Kwa app ndogo, kuandika mwenyewe ni rahisi zaidi.
  3. Skrini ya anwani ya seva: mtumiaji aandike anwani ya hospitali; thibitisha kwa GET /settings/system na uonyeshe jina na nembo iliyorudi.
  4. Kuingia: POST /auth/login → shughulikia requires_2fa → hifadhi tokeni kwenye hifadhi salama (Keychain / EncryptedSharedPreferences / flutter_secure_storage). Usihifadhi tokeni kwenye faili la kawaida.
  5. Chora menyu kutoka permissions zilizorudi, si kutoka orodha ya ndani.
  6. Skrini za kwanza zenye thamani: foleni (GET /visits?status=...), daftari la wagonjwa (GET /patients), kumbukumbu za mgonjwa (GET /patients/:id/visits, /vitals, /prescriptions, /diagnostics), na majibu ya dharura (GET /diagnostics/critical).
  7. Kuandika: anza na vitals (POST /visits/:id/vitals) na kusonga foleni (POST /visits/:id/advance) — vifupi, salama, na vinavyoonekana mara moja.
  8. Arifa: GET /notifications na POST /notifications/:id/read.
  9. Shughulikia 401/403 mahali pamoja (interceptor), si kwenye kila skrini.
  10. Jaribu kwa tawi lililozima moduli ili uone 403 ya moduli inavyoonekana kwa mtumiaji.
Simu za mgonjwa ni tofauti na simu za wafanyakazi. App ya mgonjwa itumie /portal/* pekee — ndizo zinazomtambua mgonjwa kutoka kwenye tokeni.

Building a mobile app — start to finish

These steps take you from nothing to an app that is useful on a ward.

  1. Bring the stack up: docker compose up, then check curl http://localhost:4000/api/v1.
  2. Get a client: take openapi.json and run openapi-generator (see tools), or hand-write a small HTTP layer like the Dart example above. For a focused app, hand-writing is usually less work.
  3. Server-address screen: let the user type the hospital's address, then confirm it with GET /settings/system and show the name and logo that come back.
  4. Sign in: POST /auth/login → handle requires_2fa → store the tokens in secure storage (Keychain / EncryptedSharedPreferences / flutter_secure_storage). Never in a plain file.
  5. Draw the menu from the returned permissions, not from a list compiled into the app.
  6. The screens worth building first: the queue (GET /visits?status=...), the register (GET /patients), one patient's record (GET /patients/:id/visits, /vitals, /prescriptions, /diagnostics), and critical results (GET /diagnostics/critical).
  7. First writes: vitals (POST /visits/:id/vitals) and moving the queue along (POST /visits/:id/advance) — short, safe, and immediately visible.
  8. Notifications: GET /notifications and POST /notifications/:id/read.
  9. Handle 401/403 in one place (an interceptor), not on every screen.
  10. Test against a branch with a module switched off so you see what that 403 looks like to a user.
A patient's phone is not a staff phone. A patient app uses /portal/* only — those are the routes that resolve the patient from the token.

Kujenga software ya desktop — mwanzo mwisho

Desktop ina faida mbili hospitalini: printa (risiti, ankara, vitambulisho) na vifaa (kisoma-bakodi, mizani, printa ya lebo). API ni ile ile; tofauti iko kwenye jinsi unavyoihifadhi tokeni na jinsi unavyochapisha.

  1. Chagua msingi: Electron au Tauri (JS/TS — tumia mfano wa TypeScript hapo juu), .NET WPF/WinUI (mfano wa C#), au Qt/Python. Tauri ni ndogo na nyepesi; .NET ni rahisi zaidi kwenye Windows za hospitali zilizopo.
  2. Hifadhi tokeni kwenye hifadhi ya siri ya mfumo — Windows Credential Manager, macOS Keychain, libsecret. Si kwenye localStorage ya Electron, na si kwenye faili ya usanidi.
  3. CORS haihusiki ukituma maombi kutoka mchakato mkuu (main process) au kutoka .NET. Ukituma kutoka renderer ya Electron, ongeza anwani yako kwenye ALLOWED_ORIGINS ya seva au tuma kupitia main process.
  4. Uchapishaji: endpoints za PDF zinarudi baiti tayari kuchapishwa — ankara (/billing/bills/:id/invoice), risiti (/billing/payments/:id/receipt), taarifa ya mgonjwa (/billing/patients/:id/statement), vocha (/billing/expenses/:id/voucher), oda ya manunuzi (/procurement/orders/:id/pdf), slipu ya mshahara (/hr/payslips/:id/pdf). Zihifadhi kwenye faili la muda kisha uzipeleke kwa printa ya mfumo.
  5. Kituo cha mauzo (POS): mauzo yote ni POST /billing/sales — ombi moja linalotengeneza ankara na kupokea malipo. Usitengeneze njia yako ya pili ya kupokea fedha.
  6. Kubadilisha tawi: akaunti inayosimamia matawi yote inapata branches kwenye jibu la kuingia; onyesha kichagua-tawi kwa hao tu.
  7. Sasisho la programu: weka nambari ya toleo lako kwenye kichwa cha User-Agent ili kumbukumbu za seva zionyeshe ni toleo lipi lilipiga simu.

Building desktop software — start to finish

Desktop earns its place in a hospital for two reasons: printers (receipts, invoices, wristbands) and devices (barcode scanners, scales, label printers). The API is identical; what differs is how you store the token and how you print.

  1. Pick a base: Electron or Tauri (JS/TS — use the TypeScript example above), .NET WPF/WinUI (the C# example), or Qt/Python. Tauri ships small; .NET is the easier fit on the Windows machines hospitals already own.
  2. Store tokens in the OS secret store — Windows Credential Manager, macOS Keychain, libsecret. Not Electron's localStorage, and not a config file.
  3. CORS does not apply when you call from a main process or from .NET. If you call from an Electron renderer, either add your origin to the server's ALLOWED_ORIGINS or route requests through the main process.
  4. Printing: the PDF endpoints return print-ready bytes — invoice (/billing/bills/:id/invoice), receipt (/billing/payments/:id/receipt), patient statement (/billing/patients/:id/statement), expense voucher (/billing/expenses/:id/voucher), purchase order (/procurement/orders/:id/pdf), payslip (/hr/payslips/:id/pdf). Write them to a temp file and hand that to the system printer.
  5. Point of sale: a counter sale is POST /billing/sales — one request that raises the bill and takes the money. Do not invent a second place where money is taken; two day-end figures that disagree is exactly what that endpoint exists to prevent.
  6. Branch switching: an account that spans branches receives branches in the sign-in reply. Draw a branch picker only for those accounts.
  7. Releases: put your build number in the User-Agent so the server's logs say which version made a call.

Mtandao ukikatika

Hospitali nyingi zina mtandao unaokatika. Usidanganye mtumiaji. Kama ombi halijafika seva, sema hivyo — usionyeshe alama ya "imehifadhiwa".

  • Kusoma: ni salama kuhifadhi nakala (cache) na kuonyesha ikiwa na tahadhari "ilipakuliwa saa fulani, huenda imepitwa na wakati".
  • Kuandika: usiweke foleni ya maandishi ya kliniki kimya kimya. Rekodi ya mgonjwa iliyoandikwa saa mbili baadaye, baada ya mgonjwa kuondoka, ni hatari zaidi kuliko ujumbe wa wazi wa "hakuna mtandao — iandike kwenye karatasi".
  • Ubaguzi: vipimo visivyo vya kliniki (mfano kusoma tu, au arifa) vinaweza kusubiri.

When the link drops

Hospital networks drop. Do not lie to the user. If a request never reached the server, say so — never show a "saved" tick for something that was not.

  • Reads: caching is fine, shown with a plain warning that it was loaded earlier and may be out of date.
  • Writes: do not silently queue clinical writes. A patient record that lands two hours later, after the patient has gone home, is more dangerous than an honest "no connection — write it on paper and enter it when the link returns".
  • The exception is non-clinical traffic — reads, notification acknowledgements — which can safely wait.

OpenAPI, Postman na codegen

Faili tatu zinasafiri pamoja na ukurasa huu, kwenye folda ile ile:

OpenAPI, Postman and codegen

Three files travel with this page, in the same folder:

FileWhat it is for
openapi.jsonOpenAPI 3.0.3 — import into Swagger UI, Insomnia, Stoplight, or feed a code generator
hms.postman_collection.jsonPostman collection — every endpoint, grouped as the router groups them
hms.postman_environment.jsonPostman environment — base_url and where the tokens land
# Postman: import both files, pick the environment, then run
# "Authentication → POST /auth/login". Its test script stores the tokens
# for every other request in the collection.

# A typed Dart client for Flutter
openapi-generator generate -i openapi.json -g dart-dio -o ./lib/hms_api

# Kotlin for Android
openapi-generator generate -i openapi.json -g kotlin -o ./hms-api

# Swift for iOS
openapi-generator generate -i openapi.json -g swift5 -o ./HmsApi

# C# for a Windows desktop client
openapi-generator generate -i openapi.json -g csharp -o ./HmsApi

# TypeScript types only, for Electron/Tauri
npx openapi-typescript openapi.json -o src/hms-api.d.ts
Nyaraka hizi hutengenezwa upya kwa cd backend && go run ./cmd/apidocs. Jaribio la go test ./cmd/apidocs hushindwa ikiwa zimepitwa na wakati, kwa hiyo haziwezi kubaki nyuma ya msimbo kimya kimya. Regenerate all of this with cd backend && go run ./cmd/apidocs. go test ./cmd/apidocs fails when what is committed is stale, so these files cannot quietly fall behind the code.

Orodha ya kabla ya kuzindua

  • Anwani ya seva inawekwa na mtumiaji, si ndani ya msimbo.
  • Tokeni ziko kwenye hifadhi salama ya mfumo.
  • Refresh moja tu kwa wakati; ikishindwa, mtumiaji anarudishwa kwenye kuingia.
  • Menyu inatokana na permissions za seva.
  • Ujumbe wa makosa unaonyeshwa kama ulivyo, si "Kuna hitilafu".
  • 403 ya ruhusa na 403 ya moduli zinaonekana tofauti kwa mtumiaji.
  • Hakuna alama ya "imehifadhiwa" kabla seva haijathibitisha.
  • Namba za simu zinatumia E.164 (+255…) — tumia country kutoka kwenye kikao.
  • Saa zote zinatumwa kama UTC ISO-8601 na kuonyeshwa kwa saa za eneo.
  • App imejaribiwa kwa akaunti yenye ruhusa chache, si ya msimamizi pekee.

Go-live checklist

  • The server address is set by the user, not compiled in.
  • Tokens live in the OS secret store.
  • One refresh at a time; on failure the user is returned to sign-in.
  • The menu is drawn from the server's permissions.
  • Error messages are shown verbatim, never as "Something went wrong".
  • A permission 403 and a module 403 look different to the user.
  • Nothing shows as saved before the server has confirmed it.
  • Phone numbers are E.164 (+255…) — use country from the session.
  • Times are sent as UTC ISO-8601 and displayed in local time.
  • Tested with a low-permission account, not only an administrator.

Marejeo: kila endpointReference: every endpoint

Bofya endpoint yoyote kuona vigezo, mwili wa ombi na mfano wa curl. Open any endpoint for its parameters, request body and a curl example.