Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-internal

    Set up visit types and working hours

    The first screen a new practice needs, and the one that did not exist. Booking offers visit types from one table and searches availability against another, so a practice with neither had an empty diary and no way to fill it from inside the product. A visit type is retired rather than deleted — appointments and claims reference the code, and a code that vanishes turns a year of history into rows nobody can label. The button says "retire" for that reason. Working hours are saved in a named time zone and the panel says which. It is the field most likely to be quietly wrong, availability is computed against it, and the symptom of getting it wrong is patients not turning up. The section only appears for a role holding MANAGE_SCHEDULE_TEMPLATES, and it sits last: a practice visits it once at the start and then almost never, so it should not compete with the day's work.

  2. · kamo-internal

    Write a prescription, an order and a vaccination

    The chart could show all three and create none. One panel rather than three, because they are one moment: a clinician finishing a visit writes the prescription, sends the lab and records the shot in the same thirty seconds, and three screens in three places is how the third gets forgotten — usually the vaccination, which is the one the registry and the recall query need. Each tab appears only if the member's role includes it. A button that is present and refuses teaches people the system is unreliable; a button that is absent teaches them who to ask. The server enforces all three regardless. The controlled-substance switch is stated, never guessed from the drug name. A lookup would be wrong in both directions — demanding the stronger permission for a misread name, or failing to demand it for one it did not recognise — so the prescriber asserts it and the server enforces the second right on what was asserted. The switch is disabled outright for somebody without that right, with a line saying to ask an administrator rather than a refusal after typing. Writing sits above reading on the tab: a clinician arrives here to record what they just decided, and a compose box below a long problem list is one nobody scrolls to. It is keyed on the patient so a half-typed prescription cannot follow somebody from one chart to the next, and a write bumps a token that makes the list below re-read — a list one prescription out of date reads as "it didn't save". Also: a missing RIGHT no longer reports as "not enabled for clinical data". Both arrive as 403 and mean opposite things — one is fixed by an administrator in minutes, the other is a contract — so the client now reads the reason the server sends and carries its sentence, which already names the permission.

  3. · kamo-internal

    Choose who pays for mailboxes, and show the count per invoice

    The cost-mode control appears only once a billing group exists — with none the setting changes nothing, and offering it invites the belief it is broken. Invoice rows carry the recorded mailbox count so a past charge stays explainable after the mailboxes are deleted.

  4. · InitializerService

    Schema for member-connected inboxes

    The old member-only unique on member_imap_configs is discovered by shape rather than by its Hibernate-generated name, and dropped only after the wider key exists.

  5. · kamo-shared-library

    Count connected personal mailboxes

  6. · SecurityService

    Edit demographics, set up the schedule, bill and post

    The last four rights that named operations which did not exist. EDIT_PATIENT_DEMOGRAPHICS: a patient's details could be entered once and never corrected. A field the caller omits is left alone rather than cleared, because the alternative empties a birth date when somebody fixes a surname — and a name or date change re-indexes the identity, without which a married name leaves the patient findable only under the name they no longer use and creates a duplicate the next time anybody registers them. MANAGE_SCHEDULE_TEMPLATES: visit types and working hours are the two things that must exist before anything can be booked, and both tables were readable and neither writable. A new practice's booking screen offered nothing and there was no way to change that from inside the product. A visit type is retired rather than deleted — appointments and claims reference the code, and a code that vanishes turns a year of history into rows nobody can label. Working hours require a time zone, because availability is computed against it and a block without one is read in whoever is looking's zone. MANAGE_CLAIMS and POST_INSURANCE_PAYMENTS: the revenue cycle could read seven queues and raise no claim. Creating and sending are separate calls because they are separate decisions — a create that could submit is how an unchecked claim reaches a clearinghouse. Payments post line by line, which is how a payer adjudicates and the only way to answer "which procedure was denied and why". The claim header is summed from its lines rather than taken from the caller: a header disagreeing with its own lines is the state that produces a balance nobody can explain to a patient. Every line settled at zero with adjustments is a denial, not a payment — different worklist, different deadline. The self-pay restriction moved from a banner to a refusal. §164.522(a)(1)(vi) is a patient's right, and until now the only thing between a restricted patient and a claim to their plan was a red panel on one screen, which does nothing about a claim raised from anywhere else. Checked when the claim is raised AND again when it is sent, because it may have been recorded in between and sending is the call that actually discloses. Amounts are read through their decimal string, never a double: 0.1 + 0.2 on a claim line is a balance a patient is asked to pay and cannot be told the reason for. 24 tests.

  7. · SecurityService

    POST /quotes/{uid}/convert

    On QuoteController rather than POSController: this controller enforces a right on every handler, POSController enforces none on any order endpoint, and it resolves sessions from the OTK attribute, the X-***-Token header and the *** cookie. Gated MANAGE_QUOTES because no order-mutation right exists to reuse. 409 for a state conflict the caller can retry after, 404 for a quote that is not there — matching get/update/revise/cancel. A malformed id is already a 400 from parseUuid before either branch is reached.

  8. · EmailService

    Back the personal-mailbox API the console already calls

    Eighteen client methods over twelve routes that had been shipping with no backend behind them. A member reaches only their own inbox and configures it only once an admin has asked them to. OAuth uses consumer scopes: the existing handlers request Workspace and tenant-admin scopes a personal account cannot grant.

  9. · kamo-shared-library

    Personal mailbox entities and two consumer providers

    GMAIL_PERSONAL and OUTLOOK_PERSONAL were offered by the picker and were not members of the enum, so selecting either produced an unusable org.

  10. · kamo-internal

    A closed head spins away instead of blinking out

    Right-clicking a head and choosing Close, or putting one in the goal — thrown in or carried in and dropped — now plays a 500ms exit: a counter-clockwise three-quarter turn while shrinking to nothing and fading out, then unmount. The head leaves every piece of engine bookkeeping the instant it is closed (cluster, motion values, throw physics), so its cluster reflows immediately and nothing can collide with a corpse. What survives the 500ms is an inert visual ghost frozen at the head's last position, in its own list, unmounted by a timer. The animation is opt-in per removal rather than universal, because hexheads:remove is not only fired by closes: restoring a minimized window fires it, and FloatingNotesContext fires it to re-dress a sticky note's head on a colour change. Spinning a head away in those cases would be wrong. Also drops the head from throwsRef on removal. A head can be right-clicked and closed mid-flight, and without that the throw RAF keeps a dead entry forever — it never drains, so the goal zone never hides again. prefers-reduced-motion gets a plain fade over the same 500ms.

  11. · SecurityService

    Prescribe, order and immunise

    The chart could display medications, orders and vaccinations and could produce none of them. The read paths, the FHIR renderers and the safety queues were all built against these tables while nothing could write a row, so a practice could look at a medication list it had no way to add to — and PRESCRIBE_MEDICATIONS, **************** PLACE_CLINICAL_ORDERS and ADMINISTER_IMMUNIZATIONS named operations that did not exist. These are the daily operations of a medical office. Five endpoints: write a prescription, stop one, place an order, close its loop, record a vaccination. A controlled substance needs a SECOND right, in addition to the ordinary prescribing one, and the check reads the DEA schedule off the drug rather than trusting a flag the caller sets. An unrecognised schedule counts as controlled: the failure that matters is the one that lets a controlled substance through, so a typo must not become a bypass. Prescriber, orderer and administering clinician all come from the session, never from the body. "Who prescribed this" is what a DEA audit, a malpractice review and a pharmacy callback all ask, and a caller-supplied answer is worth nothing to any of them. Refills remaining is derived from refills authorised for the same reason — letting a client set it is how a prescription arrives endlessly refillable. An order is created with closedAt null, deliberately. That is what puts it in the open-loops queue, which exists because the ordered test nobody chased is the classic way a diagnosis is missed. Closing it records who closed it and why; a row that ages out of a queue on its own teaches people to ignore the queue. A vaccination without a lot number is refused. It is the only field that answers "who received the recalled lot" — a question that arrives from outside the building with hours to answer it — and every other identifier on the row can be reconstructed. The VIS presentation timestamp is stamped from the server clock: it is the practice's evidence of a federal requirement, and evidence the caller supplies is evidence of nothing. 15 tests, most of them on the controlled-substance split.

  12. · kamo-internal

    Explain the mailbox charge where mailboxes are made

    Console rows price mail per mailbox with the live count; the mailboxes tab, department picker and provider help all state what counts, that a shared mailbox is billed once, and that aliases are unlimited and free.

  13. · kamo-internal

    Gate provider choice on the add-on, show mailbox charges

    Locked cards say which add-on unlocks them rather than being hidden or silently dead, and the gate fails open so a billing outage cannot strand an org mid-migration. Also translates twelve option labels that were hardcoded English, and adds the stats-tab panel showing what is billed and that aliases are free.

  14. · InitializerService

    Both mail add-ons are priced per mailbox

    Prices unchanged at $5 and $2; only the unit changes. Copy now states what actually counts, and that aliases are unlimited and free.

  15. · BillingService

    Expose mailbox counts, entitlement and cost mode

  16. · BillingService

    Bill e-mail per mailbox, reconciled nightly

    Quantity is server-computed under a reserved key a request body cannot reach, and pushed through the existing amend path — so Stripe's CREATE_PRORATIONS charges days added and credits days released. Also refuses to drop a mail add-on mailboxes are still using, and stops a billing group changing add-ons it only inherits.

  17. · kamo-shared-library

    An accepted quote becomes an order

    acceptPublic wrote four columns and appended an event. No Commitment, no Order, no Transaction — the Quote javadoc's claim that accepting mints a Commitment described code that did not exist. Deliberately does NOT create a Commitment: processOrderPaid returns immediately when order.getCommitment() is non-null, so hand-creating one disables the whole downstream. The order is DRAFT and payment moves it on through the existing state machine, which mints the Commitment, the Transaction and the FulfillmentOrders. Reuses the quote's Engagement rather than minting a second, and derives its status through EngagementLifecycle rather than hand-setting COMMITTED the way createRetailOrder does. Totals come from the quote, which QuoteTotals computed server-side — never through CreateRetailOrderRequest, which accepts client totals and double-counts lines on top of a supplied subtotal. A line that opts in with usesQuoteQuantity takes the quote's shared driver, not its own column: copying that verbatim would put a 60-seat deal on the order as one seat beside a lineTotal priced for sixty. Only the accepted option's lines convert, plus every option-less one. QuoteConversionResult carries orderUid as a String, not the UUID the plan named: Order extends BaseEntity, whose id is an INT8 unique_rowid() that JavaScript truncates. Ids leave this platform as strings. First production writer of ServiceLineItem anywhere.

  18. · kamo-shared-library

    Count an org's billable mailboxes

    Adds PER_MAILBOX pricing, the mail cost mode, and a stereotype-free aggregator: hosted bills every box on the server, third-party bills only what was mapped into Kamo. Deduped by address, so a mailbox six people share is one charge. Aliases are counted and never billed. An exhaustive switch with no default arm classifies every provider, so the next one added is a compile error rather than silently unbillable.

  19. · InitializerService

    Schema for per-mailbox billing

  20. · kamo-internal

    Each org chooses what an order takes from an accepted quote

    A radio group, not a switch — both are legitimate ways to sell, and neither is 'off'. Shows which is the default and what each does to a real charge: for a three-year quote at 10k/yr with a 5k setup fee, one bills 15k now and the other bills 35k. The billing key is omitted from the request entirely unless the user changed it. The server reads an absent key as 'leave unchanged' precisely so a vertical toggle cannot wipe the choice, and always sending the current value would have defeated that.

  21. · SecurityService

    The config body carries a scalar, and absence means unchanged

    updatePOSConfig bound Map<String, List<String>>, a shape that cannot carry quoteConversionBilling at all. Widened to Map<String, Object>. A key that is absent means leave it alone, not set it to null — the engagement-types tab sends only enabledEngagementTypes, so reading absence as null would wipe the org's billing choice on every vertical toggle, at HTTP 200 with nothing in any log.

  22. · kamo-shared-library

    Each org chooses what an order takes from an accepted quote

    There is no platform-wide right answer. A maintenance contractor on a three-year plan bills each period; a capital-equipment installer bills the contract up front. An Order's total becomes a captured payment, so either hard-coded choice charges half the tenants the wrong amount. Defaults to FIRST_PERIOD_PLUS_SETUP because it under-charges rather than over-charges. Null means the default rather than unset — every org predates this setting, and a backfill writing one value everywhere is a column that could have been nullable. A body that never mentions quoteConversionBilling means leave it alone, not clear it: the engagement-types tab sends only enabledEngagementTypes, so reading absence as null would wipe the choice on every vertical toggle. The column ships with no CHECK constraint. Hibernate's export wanted check (... in **************** which is frozen at CREATE TABLE and breaks the next value appended to the enum.

  23. · kamo-internal

    Say who holds a contested mailbox, and that aliases are free

    The snackbar replaced the server's 409 message with a generic failure, so a manager could not tell which member held the mailbox. Adds the note that sharing a mailbox costs nothing extra and that aliases are unlimited.

  24. · InitializerService

    One primary member per mailbox

    is_primary was unique per member only, so two members could both claim a mailbox — and PrimaryMailboxIdentityLookup returns null on an ambiguous address, so that address then authenticated neither of them.

  25. · kamo-internal

    Paint each tab from its organization's alias, not from the hostname

    The theme base was built from a hostname: drop the leading label, join the rest with dashes, and that is the bucket folder. It answered correctly while every organization had a host of its own. On internal.kamocrm.com — which now serves every organization that has no domain — it answers "the platform", so those organizations were painted in Kamo's logo and colours with their own branding provisioned and unreferenced. The folder is the organization's ALIAS. Every organization has one; a domain is optional. getThemePath() takes a slug and nothing else, and the slug reaches it three ways, in order of how early they are known: - app/layout.tsx passes organization.alias into DynamicMetaLoader from the SERVER render, so the very first theme fetch names the right tenant rather than depending on where OrgProvider sits in the tree relative to that effect. - OrgProvider records it during render, for everything that paints on first commit. - A caller can name another organization outright — /network lists every organization in the network and renders a logo for each, and orgLogoSrc took a DOMAIN, so the ones without a domain of their own resolved to no theme origin at all and fell back to an initial with their logo sitting in the bucket. The host half of the URL follows the address being browsed, unchanged: MinIO serves the whole bucket from every theme.<apex> name, so it decides only whether the request resolves — never whose assets come back. That is what lets an organization be entered before its own domain does. The create wizard now waits on, and opens, the folder the SERVER named. It derived the folder from the org's domain and got it wrong twice: GET /org/{id} answers with the entity, whose `domains` is LAZY and serializes as null outside a transaction, so the derivation fell through to "{alias}.kamocrm.com". For Panda Express that meant polling panda-express-kamocrm-com while the theme had been written to pandaexpress-com — 45 seconds of waiting on a folder that would never exist, then opening the workspace under a host that resolves to nothing. Also here, and part of the same story: - The alias field refuses a label the platform keeps for itself while it is being typed, matching SecurityService's OrgAliasPolicy. "theme" would answer at the host every tenant reads its assets from, and "default" is the base theme every new organization is seeded from. - error.tsx and not-found.tsx had their own copy of the derivation, building **************** — a scheme the bucket does not serve. The watermark on those screens was a 404 for every organization. - The dead **************** helpers behind that scheme are gone rather than left for the next caller. 253 test files, 3396 tests. The i18n key guard verified against a fresh clone of the dictionary, the way CI resolves it.

  26. · SecurityService

    Provision an organization's branding under its alias, and migrate what exists

    The folder every logo, colour, favicon and background is written to was the organization's hostname with the dots turned into dashes. A domain is optional now, so that could not name a folder for an organization that had not brought one — and the failure was silent in both directions. Uploading a logo was refused with "No domain configured for this organization"; reading one fell through to the platform's folder, so the customer saw Kamo's branding over a workspace whose own was provisioned and unreferenced. The folder is the organization's ALIAS, which is mandatory, unique, and already shaped like a path segment. See OrgThemeAssets in kamo-shared-library. What this changes here: - provisionFull/provisionUpdate take the organization and derive the folder from it, so a caller can no longer direct one organization's branding into a folder named after something else. provision-theme's `domain` field is ignored. - upload-logo, commit-backgrounds and provision-theme stop refusing an organization that has no domain. - The workspace picker's per-row logo drops two correlated string_agg subqueries per organization — they existed only to work out which folder each one's branding had been provisioned into, and the alias is on the row already. - The sign-in redirect NAMES the organization (&org=<alias>). internal.<apex> serves every organization without a host of its own, so a tab arriving with only a one-time key resolved the host, got the platform, and painted Kamo's logo over the workspace the member had just signed in to. enter-as had been fixed to say so; the ordinary sign-in had not. It is a reference, not a credential, and an alias that cannot be read never blocks the sign-in. - Members' virtual backgrounds stop deriving their folder from the request host, which answered "kamocrm-com" for every subdomain tenant — so a child organization's backgrounds were written into, listed from and DELETED out of the platform's folder. - The storage sweep attributes both prefixes, so a migrated organization's branding usage does not appear to halve overnight. ThemeFolderMigrationService copies each organization's assets into its alias folder at boot. Copies, never deletes: there is no safe moment to remove the old keys — a tab open across the deploy, a cached email, a quote PDF already sent all still reference them — and the cost of getting it wrong is every white-label customer's branding vanishing at once. Idempotent, and a MinIO blip must not stop the service starting. Already run against production: 14 organizations, 298 objects, all serving. OrgAliasPolicy is the third piece, and the one that matters most now the alias is a path segment. The shape rule was only ever applied by the availability endpoint the wizard calls as the field is typed — the create endpoint checked duplicates and all-digits and nothing else, so any caller that skipped the wizard could store an alias holding a space, a dot or a slash. It also reserves the labels the platform serves, because an organization aliased "theme" would answer at the host every tenant reads its assets from; and the bucket prefixes that belong to no organization, of which "default" is the dangerous one: the base theme every organization is seeded from, which such an organization's saves would rewrite. Verified against a clean checkout of main plus only these files: 1418 tests, 0 failures. No existing organization holds a reserved or malformed alias.

  27. · kamo-shared-library

    An organization's branding lives under its ALIAS, not its domain

    The folder an organization's logo, colours, favicons and backgrounds are written to in the public bucket was its hostname with the dots turned into dashes — wendys.com became public/wendys-com/. That only ever worked while every organization had a hostname. A domain is optional now: it is what makes white-label branding go live, not what makes an organization exist. So the derivation had nothing to answer with for an organization that had not brought one, and the failure was not an error. On the write side provisioning refused with "No domain configured for this organization"; on the read side the derivation fell through to the platform's own folder, so the organization was painted in Kamo's colours with its own assets sitting correctly provisioned and unreferenced. An alias, by contrast, is mandatory, unique, and already constrained to exactly the characters a path segment may hold. OrgThemeAssets is one definition of this for the whole estate. It was open-coded in a dozen places across six services and three front ends — the provisioner, the quote letterhead, transactional email, the e-signature watermark, the meeting lobby, the sign-in screen — and every one of them had to agree to the character, because a logo written under one key and requested under another is invisible in a way that looks like the upload having failed. The quote letterhead is the first caller moved across. It also documents the split the rest of the estate now follows: the FOLDER names the organization, and the HOST decides only whether the request resolves, since MinIO serves the whole bucket from every theme.<apex> name it answers to.

  28. · kamo-internal

    A tap opens the tool, a right-click opens its menu

    Clicking a hex head opened a context menu, which meant the common act — get my chat back — cost two clicks, and the menu itself was reachable no other way. The two are now separate gestures: - Left click / tap restores the window, dispatching the same `tool:focus` the menu's "Open" row already used. focusTool un-minimizes and emits removeHexhead, so the head clears itself. - Right-click opens the menu. Secondary buttons no longer start a drag, so the press that opens it cannot also fling the head. - Touch has no second button, so a 600ms stationary hold stands in. It is armed only for non-mouse pointers and disarmed the moment the head detaches, so "hold it still" is the whole gesture — dragging a head for longer never opens the menu, and a menu already open is retracted if a drag then starts. The menu's own changes: - The header X is gone. Its only label was "Close", which reads as "dismiss this menu" — the one thing it did not do — so it moves into the option list beside "Open {name}" as "Close {name}", where what it discards is spelled out. - Placement now puts a CORNER of the panel on the CENTRE of its head, picking whichever of the four corners keeps the most of it on screen. The old rule ("beside the head, flip, then clamp") is what made the position look arbitrary: the clamp shoved the panel away from its head near a screen edge, which is exactly where heads live. Extracted to **************** so it is pure and testable; the panel is measured rather than guessed at 460px. - A member's chat head shows their photo, falling back to initials. This needed a new HeadState.avatarSrc: the head's `src` always falls back to a generated initials image, so it could never answer "is there a photo?". - Every piece of chrome paints from var(--color-primary) instead of a fixed indigo/violet, which came out that colour for every white-label org regardless of their palette. Presence colours stay semantic. Also fixes a resize listener the popover added on every open and never removed. Needs kamo-translation-dictionary 5eac4334 (closeChat/closeTool), already pushed.

  29. · kamo-internal

    Hand the patient a link, not a token

    The invitation now has somewhere to go — the portal lives on apps.<practice> — so the panel shows the whole activation URL with a copy button rather than the opaque string the server issues. A patient handed a bare token has to work out where it goes. The ones who guess wrong paste a working key to their medical record into a search box. The mapping is a subdomain swap off the staff host, the mirror image of the assumption the apps site already makes about register.*, so no backend has to learn about front-end hostnames. A host that is not a tenant subdomain falls back to the token: a developer on localhost gets something usable rather than a link to a domain that does not exist.

  30. · kamo-internal

    Release the record as FHIR as well as C-CDA

    §164.524(c)(2)(ii) is the form and format the individual requested if it is readily producible. Both were already producible; only one was offered. A C-CDA is what a receiving practice's system ingests. A FHIR bundle is what a patient's own health app reads, and asking for one is the common case now rather than the exotic one. The helper line says who each format is FOR instead of naming the standard and leaving the front desk to work it out. The recipient goes on both paths. On the FHIR path it is what turns the read into a recorded disclosure — the same endpoint serves the chart screens, and those must not land in the patient's accounting as releases. The format sits above the recipient, not beside the button: it is part of what the requester asked for, not a download preference somebody discovers after typing the name.

Your universe is waiting.

Start free in minutes. Add products whenever you are ready — no migration, no new logins.

Free forever — no credit card required