Changelog
What's new.
Every improvement to the platform, as it ships.
· MediaService
Capture the demo lead before the booking step
The marketing site's demo form collected name, email, company, company size, industry and a message, then advanced to scheduling without telling anyone. Lead creation lived only inside **************** so every visitor who did not complete a booking — including every visitor at all while no availability is published — was discarded after handing over their details. - POST **************** writes the lead on its own. Owning org comes from the X-Public-Chat-Org-Id header APIService stamps from the validated key, never from the body. - findOrCreatePublicLead is now the single lead-writing path for all three public surfaces (demo form, schedule, join), so one visitor produces one lead however many steps they complete. It stamps the org's "KamoCRM" / "Demo Request" vendor product and takes the market from that vendor, and it routes through LeadService.createLead instead of saving the entity directly — the old inline save produced no history row and no LEAD_CREATED event, so those leads never appeared in an already-open grid. - Company size, industry and the visitor's message are kept as a lead history entry; Lead has no column for any of them and they are the part a salesperson actually reads. - GET /api/public/webinar/types lets a public page discover the type id rather than carry a hardcoded copy, and slots/available now also returns availableTimes — the same availability resolved against the owning org's timezone into instants that can be posted back as a scheduledStartTime.
· kamo-shared-library
Public demo-request lead capture primitives
A visitor who fills in the marketing site's demo form but never books a time left no trace: the lead was only ever written inside the booking transaction. Capturing it up front needs three pieces the library did not have. - **************** match a repeat submission back onto the lead the same form already produced. Scoped to the vendor product rather than to the email alone so an anonymous form can never adopt (and re-stamp) a lead staff created for that address. - PublicWebinarDateSlotsDTO: availability resolved into absolute instants. The stored slot is a local date plus a wall-clock time with no zone, which a browser cannot turn into a bookable scheduledStartTime. - PublicWebinarTypeDTO: a webinar type without the presenter roster, so a public page can discover the type id without leaking presenter email addresses.
· kamo-internal
Register Provider Setup in navigation, and write the quick tips
The Provider Setup tab rendered inside SettingsView but was reachable only by typing the URL — it was missing from both places that list settings destinations: the settings nav menu and the settings landing page. It now leads each, matching the tab order, since which provider hosts meetings decides which of the other settings are even available. The quick tips on the five existing tabs were never written — every string was a placeholder ("Help Heading", "Bullet Lobby Title"). They now say something, and cover the settings added for Zoom and Teams: in-meeting controls, passcode policy, lobby bypass and presenter roles, remote recording pull and transcripts, and provider-sent invitations. Each tab also explains where providers differ, so a disabled control has a stated reason rather than just looking broken.
· kamo-internal
Enforce geo and IP access rules on page loads
checkAccess evaluated the rules itself and matched nothing for its entire life: it compared rule types against **************** while the publisher writes the enum names WHITELIST/BLACKLIST/TEMP_BLOCK. Geo was a comment saying Node has no GeoLite database. Both halves are gone — it now reads the verdict SecurityService caches in Redis and asks that service on a miss, so there is one implementation of the policy and no copy left here to drift. The address is the right-most public hop of X-Forwarded-For rather than the left-most. Proxies append, so the left element is whatever the client sent, and reading it would have let a header decide which country the visitor is treated as being in. A blocked visitor now gets a plain 403 document instead of raw JSON — this reaches a browser doing a top-level navigation. It names no rule and no country: the reader cannot act on that, and someone probing the boundary should not be handed a map of it. Fails open on every path. A proxy that blocks when Redis is briefly unreachable takes the whole tenant offline.
· APIService
Enforce tenant access rules at the gateway
This filter has been inert since it was written. resolveOrgId read org:domain:<host> from Redis, nothing ever wrote that key, and the null return fell through to allow; the geo branch was an empty if-block with a comment deferring to SecurityService, which was not enforcing either. Every request passed. It now reads the verdict SecurityService caches and asks that service on a miss, so the policy lives in one place rather than being reimplemented here. Hot path is one Redis read; a miss costs one short-timeout HTTP call per organization and address per 30s. Client address is the right-most public hop of X-Forwarded-For. Proxies append, so the left element is attacker-supplied — reading it would have let a header choose which country the request is judged as coming from. Exempts liveness probes, CORS preflight and the decision endpoint itself, and never judges cluster-internal addresses: service-to-service traffic has no country and blocking it would take the platform down. Fail-open on every path, with a test per dependency. This sits in front of all tenant API traffic, so a filter that blocks when Redis is slow is a total outage. Also drops **************** — template scaffolding from the initial commit, in a package with no @SpringBootConfiguration to find. It has never passed; CI builds with -DskipTests so nothing noticed. Removing it makes 'mvn test' usable here.
· SecurityService
Actually enforce geo and IP access rules
The rules were stored, cached to Redis and never consulted. AccessCheckController was the only caller of the evaluation and nothing called AccessCheckController; APIService's filter resolved orgs from a Redis key nothing wrote and treated the miss as permission. Configuring a geo block did nothing at all. AccessDecisionService is now the single authority. Login calls it directly, the gateway filter and the kamo-internal proxy reach the same decision through the check endpoint, and the policy exists once instead of once per layer. The Node copy had drifted far enough to match nothing: it compared rule types to "whitelist"/"temp-block" while the publisher writes WHITELIST/TEMP_BLOCK. GeoLite resolution is cached in Redis for a day, positively and negatively, and skips private addresses entirely. The address comes from a header, so without a negative cache a caller rotating bogons turns every request into a fresh probe of the 5.7M-row table — the DoS shape findByIpInRange was already rewritten once to avoid. ClientIpResolver reads the RIGHT-most public hop of X-Forwarded-For, not the left-most. The left element is whatever the client sent: proxies append, so "X-Forwarded-For: 8.8.8.8" plus Traefik's own append would have let anyone pick the country their block is judged against. Every geo rule would have been one header away from bypass. Fail-open throughout, per dependency and asserted per dependency. An access control that fails closed on a Redis wobble locks every tenant out of the platform, which is a worse incident than the window it closes. A whitelisted address also bypasses geo, so an admin who blocks their own country keeps a way back in.
· kamo-internal
Platform registration card for the Zoom Marketplace app
Completes Zoom's one-click connect path. The backend already resolves PlatformOAuthProviderType.ZOOM, but with no card here a platform operator had nowhere to enter the Marketplace credentials, so the meeting Provider Setup screen always reported one-click connect as unregistered. Optional by design: the per-org Server-to-Server path needs nothing here and remains the recommended default, so an empty card simply keeps one-click hidden. Teams meetings reuse the existing Microsoft 365 registration.
· kamo-internal
Microsoft Teams Phone provider, front to back
Adds Teams to **************** and wires the whole path through: provider selection, the Entra + Azure Communication Services config, tenant admin consent, the per-member connect, and an in-browser softphone. The softphone is the part that differs structurally from every other provider. Teams exposes no SIP registrar to third parties, so a Teams line cannot be registered with SIP.js or RingCentral's WebPhone. The supported route is Azure Communication Services' custom Teams endpoint: the backend exchanges the member's Entra token for an ACS token, and TeamsAcsPhoneSdk turns that into a call agent behind the same PhoneSdk interface the SIP adapters implement, so useSoftphone and every UI component stay provider-agnostic. SipCredentials gains optional acs* fields; sipInfo is simply empty for Teams. The ACS bundle is browser-only and imported dynamically inside start(), since a static import would break server rendering. Two Teams-specific truths surfaced in the UI rather than left to fail at runtime: in-browser calling is optional (leave ACS blank and the org still gets presence and call history, with the dialler hidden rather than erroring), and voicemail is off by default because Teams has no voicemail API — messages land in each member's Exchange mailbox, so reading them is a materially wider consent that an admin should opt into deliberately. The member connect card on the phone-settings tab is now provider-aware instead of RingCentral-only. Both providers need a per-member authorization, for different reasons: RingCentral because a softphone may only register as the connected account's own extension, Teams because presence writes and the calling token exchange are delegated-only. Branding: the tile uses the Microsoft corporate mark and the plain product name. Microsoft permits naming Teams to describe an integration but restricts Teams logos and branding to certified Teams partners, so no Teams glyph is shipped. Verified: npm test — 40 files, 394 tests, 0 failures; tsc --noEmit clean; the i18n key guard and client-navigation guard both pass. Dictionary strings landed first in kamo-translation-dictionary 0801a17.
· VOIPService
Microsoft Teams Phone as a provider
Teams is not one API, so the provider glues three control planes together: • Graph, app-only — call history (getPstnCalls), presence reads, directory and Teams device inventory. Call records are application-permission by design; Microsoft states plainly that they have no delegated form. • Graph, delegated — presence writes (Presence.ReadWrite exists only as a delegated, self-only permission) and voicemail, which lives in the member's Exchange mailbox because Teams has no voicemail resource at all. • Azure Communication Services — the in-browser softphone on the member's own Teams Phone line, via the custom Teams endpoint token exchange. This is the only supported route: Teams exposes no SIP registrar to third parties, and the Graph calling API joins as a bot rather than as the user. Capabilities are computed from what an instance is actually configured for rather than hardcoded, so an org without ACS reports voiceCalls=false and the UI hides the dialler instead of surfacing a 501 mid-call. Deliberately absent, because no Microsoft API exists at any version: SMS from a Teams number, call queues, call forwarding, auto-answer, and starting/stopping recording. Those stay on the interface defaults that raise 501. Credentials resolve per-instance first (an org registers its own Entra app — the common enterprise shape, and it needs nothing of Kamo the platform) and fall back per-field to a MICROSOFT_TEAMS row in the platform OAuth registry. Onboarding runs in two hops because the tenant must be discovered before it can be pinned: an admin signs in, the id_token's `tid` gives the real tenant, then they are sent to that tenant's /v2.0/adminconsent. Consent is confirmed by minting an app token and calling Graph — Microsoft returns admin_consent=True even on failure, and warns that the `tenant` redirect parameter is forgeable, so neither is trusted. Both Teams flows and the per-member delegated connect multiplex onto /api/voip/oauth/callback, the one path already public at the gateway, so APIService needs no new route. Change notifications ride the existing /api/voip/webhook for the same reason. Graph's handshake differs from RingCentral's — a ?validationToken query parameter echoed as text/plain — and is answered before anything else, since Graph drops notifications for ten minutes if an endpoint is slow. Subscriptions are created unfiltered on purpose: the subscription filter still targets the legacy `participants` collection that stopped returning data in June 2026. Two pre-existing defects fixed because Teams makes them load-bearing: • CallController, CapabilitiesController and CallFeaturesController resolved the provider via getPrimaryForOrg — the org's oldest active instance of ANY type. That is only ever right for a single-provider org. Teams will usually be a second provider, so every member's history, voicemail and agent state would have been served by whichever provider was created first. They now use a member-aware getForMember, mirroring what SipController already did. • Deleting an instance left member_voip_oauth_token rows behind, since there is no FK to cascade from, and they kept reporting "connected". Verified: mvn test — 133 tests, 0 failures (49 new).
· kamo-internal
Provider Setup tab and capability-gated meeting settings
Adds the Provider Setup tab to /settings/features/meets and makes every other tab honest about the provider it is configuring. useMeeting no longer builds meet.<domain>/<room> in the browser — that was what made every meeting a Kamo Meets meeting regardless of configuration. It now creates the meeting through the backend and uses the URLs the provider returns, so the Interaction Center, the chat tool window and the member directory all route through the abstraction without changes of their own: they share MeetingDialog, which shares this hook. - Provider Setup: provider cards with a settings-coverage bar, so the cost of switching is visible before the switch rather than discovered afterwards on a tab of greyed-out controls. Credential forms are write-only — the server returns which fields are set, never their values — plus a live connection test and per-provider setup instructions. - Capability gating: useMeetProvider reads the provider's supported settings and MeetSettingRow disables the rest with a tooltip naming the provider. Unsupported settings are shown rather than hidden, and are genuinely disabled rather than merely dimmed, so they cannot be changed by keyboard. Saves omit them entirely. Gating fails open — a failed capability lookup shows everything, since greying out a working screen is worse than showing a control the backend would ignore. - New settings the other providers support: join-before-host, in-meeting chat, screen share, dial-in, authenticated-users-only, passcode length and embedding, lobby bypass scope, presenter and attendee controls, cloud recording, transcripts, recording download and provider-sent invitations. - /meet/join/<ref> is now provider-aware: Kamo Meets still gets a one-time key minted at click time, Zoom and Teams redirect to the provider's own URL. It accepts a meeting id or a room name, so invitations sent before this change keep working. - The email invitation link is built from a UUID-validated meeting id rather than a room name, keeping the existing rule that a client-supplied URL is never trusted into an org-branded email.
· kamo-shared-library
MICROSOFT_TEAMS provider type
Appends MICROSOFT_TEAMS to VoipProviderType. The enum persists as ORDINAL on **************** so the position is load-bearing and the constant goes last; the class Javadoc now says so rather than leaving the next person to infer it. No DDL change — an ORDINAL column carries no value whitelist, so this needs no KamoInitializer run. Adds two derived queries on MemberVoipOAuthTokenRepository: • findByProviderAndInstanceId — maps an inbound provider identity (a Microsoft Entra object id on a Teams call record) back to a Kamo member. • deleteByInstanceId — these rows have no FK to the instance, so deleting a provider was leaving them behind still reporting "connected".
· APIService
Route the meeting provider OAuth callback
Zoom and Microsoft redirect the browser to this gateway after an org authorises Kamo's app. The popup lands here with no tenant session — the org travels in ?state — so the callback needs the sessionless, auth-stripping passthrough the email provider callback already uses, not the ordinary /api/meet/** forward.
· MediaService
Meeting provider abstraction with Zoom and Microsoft Teams
Meetings were Kamo Meets by construction — MeetService persisted a Meet room name and nothing else. This introduces the abstraction and two remote providers behind it. meet/provider mirrors the email provider layer: MeetingProviderResolver assembles a MeetingContext (decrypted credentials, refreshed OAuth grant, org policy, the provider-side host account) and a capability map, so callers ask for MeetingScheduler rather than branching on provider type. Adding a fourth provider means adding a bean, not editing call sites. - KamoMeetProvider: room naming and URL construction, moved server-side. The org-namespacing that stops two tenants sharing a Prosody conference used to live in the browser; it is now pinned by tests. - ZoomProvider: REST v2, Server-to-Server OAuth or the Marketplace app. Creates on behalf of the organizing member, pulls cloud recordings and transcripts. - MicrosoftTeamsProvider: Graph onlineMeetings, app-only or delegated. Its connection test probes meeting creation, because the application access policy Teams also requires is the usual thing an admin misses — the permission alone authenticates fine and then 403s on every meeting. MeetingFeature is the contract with the admin UI: each provider declares the settings it can honour, and the settings screens disable the rest. Claims are asserted in tests rather than trusted, since an over-claiming provider produces toggles that silently do nothing — the failure this layer exists to prevent. ProviderRecordingSweep pulls remote recordings into Kamo's own storage so retention, quota and post-meeting summaries work identically on every provider. Downloads are capped: this service also serves chat and meetings under a 1Gi limit, so an oversized recording is skipped with a warning rather than risking the pod. Credentials are encrypted with the existing platform key, so no new secret has to be provisioned. Session handling shared by the meet endpoints moves into MeetSessionResolver — deliberately without widening its rights check.
· InitializerService
Schema for the multi-provider meeting layer
Creates **************** and org_meeting_oauth_tokens, adds the provider-identity columns to MEETING_RECORDS and MEETING_RECORDINGS and the Zoom/Teams-capable settings to ORG_MEET_CONFIG, and backfills every existing organization to an explicit KAMO_MEET/ACTIVE row. Two things this run taught us, both encoded here. Hibernate's ddl-auto builds the SessionFactory before any CommandLineRunner, so the CREATE TABLE statements normally no-op and the columns arrive with no defaults — the backfill therefore supplies id and timestamps explicitly rather than relying on them, which is why its first version silently inserted nothing. And the uniqueness declared on the entities means Hibernate maintains its own constraints, so the named indexes an earlier revision added were duplicates; they are dropped here. Adds com.kamo.z.shared.meet to the explicit @EnableJpaRepositories list, without which the new repositories have no beans and KI aborts before any runner fires.
· kamo-shared-library
Model meetings as provider-agnostic
Kamo Meets was the only possible backend: OrgMeetConfig was a flat Meet settings bag and a MeetingRecord held nothing but a room name. This adds the schema for running an organization on Zoom or Microsoft Teams instead. - meet.provider: MeetingProviderType / MeetingProviderStatus, the per-org OrgMeetingProviderEntity (encrypted credential blob, last-verified state) and OrgMeetingOAuthTokenEntity, mirroring the email_provider package that already backs the email provider abstraction. - MeetingRecord gains the hosting provider's own identity — remote meeting id, join and host URLs, passcode, host account — so a meeting can be rejoined and reconciled whoever hosts it. - MeetingRecording gains providerRecordingId, which keeps the pull of a remote provider's cloud recording idempotent. - OrgMeetConfig gains the settings Zoom and Teams support that a Meet-only config had no reason to carry: join-before-host, passcode shaping, lobby bypass scope, presenter and attendee controls, cloud recording, transcripts, provider-sent invitations, and the fallback host meetings are created under when a member has no account on the provider. Every org with no provider row resolves to KAMO_MEET, so this is inert until an admin chooses otherwise. Columns were applied to production by KamoInitializer before this landed. PlatformOAuthProviderType gains ZOOM(9) for the Zoom Marketplace app; Teams meetings reuse MICROSOFT(3). Its guard test moves with it, since an enum and the test pinning its persisted ids cannot land separately.
· kamo-internal
Allow-list countries on the geo-blocking card
The card could only deny countries, so an org that serves one country had to enumerate every other one. It now has both lists: a mode toggle picks which list the chosen country lands on, and the two tables sit side by side. An empty allow list permits everything not blocked; a non-empty one denies every country absent from it. That reversal is drastic enough to state on the card rather than leave to be discovered, so the notice above the tables says which regime is in force. The unique (org, country) constraint keeps a country on exactly one list, which would make re-adding it to the other one fail, so each row carries a move action that flips it via PUT instead.
· SecurityService
Allow-list countries alongside geo blocks
The geo section only ever denied: a country was blocked or it was absent. Orgs that want to serve one country had to enumerate every other one, which is unmaintainable and silently wrong the moment a new country code appears. org_geo_block_rules.is_blocked already existed and was hardcoded true, so an allow rule is that column set false — no schema change. Evaluation: an explicit block wins, then an allow list denies everything absent from it, and an empty allow list permits everything not blocked. That keeps existing block-only orgs behaving exactly as before. publishGeoRules now ships both sides of the rule set, since allow rules are load-bearing once one exists. Cached payloads written before this change carry no isBlocked field and survive the cache TTL across a deploy, so a missing field reads as blocked. Also scopes setBlocked and delete to the caller's org. delete previously resolved a rule by UUID alone, so an id from another tenant would have been honoured.
· kamo-shared-library
Read quality on adjustments and guideline versions
An assembled price is a base cell PLUS the adjustments stacked onto it, so judging only the cell judges half the number: a cleanly-read 99.875 with a misread -1.250 LLPA on top is still a wrong price. MlosPriceAdjustment now carries the read quality of that one adjustment, so the sign-off gate can take the weakest link across the whole assembly. MlosGuidelineVersion gets the same field for consistency with the rate-sheet spine -- it already carried sourceDocumentId, so this completes its provenance. Both nullable; null means NO SIGNAL rather than good. Columns applied to prod CockroachDB via KamoInitializer.
· kamo-internal
Open links back into this app in the tab the member is already in
Mail in the reading pane opens every link in a new tab, which is right for the whole internet and wrong for us. Our notification emails are almost entirely links back into the app, and sending those to a fresh tab throws away the session the member already has and makes them sit through a cold boot to reach a page this tab could render immediately. Same origin is the whole test — byte-equal with the page rendering the email, not "looks like a Kamo domain" and not a suffix match on the org's domain. That is exactly the set of URLs the current session is valid for, which is the entire reason to stay in this tab, and it is un-spoofable by mail: another tenant's host, a lookalike, or userinfo pointing the host elsewhere are all different origins and go to a new tab like any other stranger's link. Routing rather than assigning location: a full document load tears down the React tree and destroys every open tool window along with any unsent draft, which is why this repo has a build gate against hard internal navigation. Modified and middle clicks are left alone — that gesture is the user deliberately asking for a new tab. Listening from the parent works because the sandbox already keeps allow-same-origin for the height measurement while omitting allow-scripts, so nothing from the email itself ever runs; the handler is ours, attached from outside. One trap worth recording, caught by its own test: returning a path rather than a URL is necessary but NOT sufficient. "https://host//evil.test" is genuinely same-origin, and its pathname is "//evil.test" — which the router reads as protocol-relative and follows off-site. An emailed open redirect that passes an origin check. Such a link now falls through to a new tab, where the browser resolves the real, harmless URL, rather than being silently rewritten to somewhere the email did not point.
· kamo-shared-library
Per-cell extraction confidence + eligibility-matrix provenance
Two gaps in the confidence model, both on the money path. PER-CELL. A sheet-wide floor is the wrong grain for judging one price: a single badly-read coupon drags the whole sheet down and would send every OTHER price on it to a human, while a high sheet floor says nothing about the specific cell a borrower was actually quoted from. **************** records how well THIS rate/price/lock row was read, so a misread coupon costs only its own price. ELIGIBILITY PROVENANCE. MlosEligibilityMatrix had no path to a source document at all -- its only route was a NULLABLE guidelineVersionId, so a promoted matrix could not be traced to the sheet it came from. Eligibility decides whether a loan is offerable, which makes it exactly where a denial has to be explainable; a provenance gap there is a gap at the point a borrower is told no. It now carries sourceDocumentId and the same extraction confidence contract as the rate-sheet spine. Both nullable, and null means NO SIGNAL rather than good. Columns applied to prod CockroachDB via KamoInitializer.
· kamo-signer-monorepo
Show the agent's phone, toll-free and email on the customer's copy
· kamo-internal
Show the agent's phone, toll-free and email on the preview
Each on its own line: a run-on of two numbers and an address reads as one string and gets dialled wrongly.
· SecurityService
Resolve the agent's published contact details
Email comes from the member's primary mailbox rather than TeamMember.emailWork — that column is free text predating mailbox provisioning and routinely disagrees with the address they actually send from. It remains the fallback when no mailbox is primary. A VOIP-enabled member with an extension is published as the org's own line plus that extension, offering the toll-free number too when the org has one. Without an extension, their work phone — the only number they hold that is meant to be published. A disabled VOIP config publishes nothing, because that extension does not answer. Every lookup is defensive: failing to render a customer-facing document because a mailbox row is missing would be worse than printing one fewer way to make contact.
· kamo-shared-library
Publish every way to reach the agent, not just a name
Name and title alone tell a customer who owns the quote but not how to ask about it. The block now carries email and phone, resolved by the caller — the primary mailbox lives in the email provider schema and the extension in the VOIP config, neither reachable from a static helper. Main line and toll-free are separate fields so the document can print both: choosing between them is choosing what the call costs the customer. lineWithExtension pairs an extension with the org line it hangs off, and returns null if either half is missing. An orphan "ext. 204" is not a number anyone can call, and a bare org line would misroute the customer to the switchboard as though it were the agent's.
· kamo-shared-library
Extraction confidence on the catalog spine + auto sign-off basis
A price is only as trustworthy as the sheet it was assembled from, and nothing on the pricing path carried that signal: confidence existed ONLY on pre-promotion staging rows (MlosExtractedCandidate) that no production code read. **************** is the WEAKEST per-field confidence across the approved candidates promoted into the version -- minimum, never mean. A sheet of 200 clean cells and one 0.20 misread averages to 0.996 and would clear any threshold, while that one cell is the one that misprices a loan. Null means NO SIGNAL (hand-authored, or promoted before the column existed) and consumers must refuse to auto-approve on it; reading absent as good is how an unverified price goes out unreviewed. WORM-protected like the other priceable columns -- snapshotted in @PostLoad and diffed in @PreUpdate, or it would be a silently mutable hole in the audit chain. MlosPricingSignoff gains automated / basis_confidence / threshold_applied. Without them a machine approval is indistinguishable from a human one except by a member id, which on the money path is an audit defect. The threshold is stored beside the value because configuration moves: 'confidence was 0.72' cannot be judged later without knowing whether the bar that day was 0.70 or 0.90. Columns applied to prod CockroachDB via KamoInitializer.
· kamo-internal
Quotes tab — every quote across the account's leads
A server-paged DataGrid beside Leads, with search over number and title, status, total range and created-date range. Filters share one debounce and collapse into a single query, so adjusting a range while typing costs one round-trip rather than one per keystroke per field. Any filter change resets to page 1 — staying on page 6 of a narrower result set reads as an empty grid. Expiry is shown apart from status: a SENT quote can be past its validity date and still read as SENT, so those rows carry their own marker instead of being silently mislabelled. Rows and the lead column both route into that lead's quotes tab.
· SecurityService
GET /quotes/account/{uid} — paged, filtered, sorted
Dates arrive as ISO strings and parse leniently: a filter the client sends badly should narrow nothing rather than 400 the whole grid. A date-only "to" bound covers the whole of that day, otherwise filtering to today returns nothing raised since midnight.
· kamo-shared-library
Page and filter an account's quotes in the database
An account view needs every quote across all its leads. Loading them to render a page degrades every month the account is in use, and any total shown would only cover what was fetched — so paging, filtering and sorting all happen in SQL. The query is native because Quote holds only a soft leadId with no association to join through; it joins leads.uid = commerce_quotes.lead_id and scopes on leads.account_uid. Pulling the account's leads into an IN-list would not survive a large book of business. Every filter is a null-guarded predicate, so one query serves the unfiltered grid and any combination of filters rather than a method per permutation. Sort keys are allow-listed: the value reaches a native ORDER BY, so accepting an arbitrary field name from the client would be an injection point. The list projection also gains sentAt, acceptedAt, version and the option/line counts — an overview is read for "how long has this been out, did it land", which the previous fields could not answer.
· kamo-signer-monorepo
Show the assigned agent on the customer's copy
Matches the staff preview: the letterhead names the org, this names the person to ask.
· kamo-internal
Show the assigned agent on the preview
A "Your contact" panel above the footer — name, title, phone and email — so the customer knows who to ask about the price. Absent entirely when the lead is unassigned.

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