Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-shared-library
Reconcile otherIncome.* facts into OtherIncome rows
Extends fact reconciliation with an otherIncome.* branch: award/benefit documents (Social Security, pension/retirement, child support) land a real OtherIncome row with a mapped OtherIncomeSource id + monthly amount. Idempotent on (borrower, source). The source is a deterministic mapping the extractor injects from the doc type — never an AI guess; the AI reads only the amount.
· SecurityService
Auto-classify uploads to catch wrong-document uploads
A cheap LIGHT-tier "what is this document?" pass on the first page classifies the upload into a coarse family and compares it to the requirement's asserted type. On mismatch the extract response flags it (detectedType + typeMismatch) so the borrower is told "this looks like a bank statement" instead of a wrong file silently extracting nothing. Advisory only — extraction still runs.
· SecurityService
Extract 12-month bank statements + written VOE
Widens vision extraction to two more borrower doc types that reuse the existing reconciliation vocabulary (zero reconciliation risk): BANK12MO reads as an asset statement (asset.*), VOE_W reads employer + gross monthly income (employment.*).
· SecurityService
Multi-page PDF document extraction (Phase 2)
The vision path was image-URL only, so a borrower's multi-page PDF (bank statement, tax return) couldn't be read. Now: - VaultDocPublicController serves ?page=N by rendering that PDF page to PNG on the fly (via ConversionService /render-page) — provider-agnostic, since many vision providers accept only an image URL, not a raw PDF. Non-PDF/no-page requests are unchanged. Img→MinIO load factored into a shared VaultDocLoader. - DocVisionExtractionService reads a PDF page-by-page (capped at 15), merging fields across pages by keeping the highest-confidence reading of each path. A single image is still read as one page (unchanged behavior). - ConversionPageClient calls ConversionService (reuses conversion.service.url).
· ConversionService
Full-res per-page PDF render for document extraction
Adds DocPageRenderService + POST /api/conversion/render-page (page N → PNG at 150 DPI, capped 300) and /page-count. This is the read-quality companion to the 300px thumbnail path: the borrower-document vision-extraction pipeline splits a multi-page statement/return into one legible PNG per page so any vision provider (all accept an image URL; only some accept a raw PDF) can read every page. Additive — existing conversion endpoints unchanged.
· kamo-shared-library
Capture + encrypt full SSN on the borrower application
The application posture needs the borrower's REAL Social, not a last-4 (a lender can't do anything with 4 digits). Borrower.ssnEncrypted existed but was never written — only ssnLastFour was populated. Now: - BorrowerDTO gains a WRITE_ONLY `ssn` (full) field — inbound only, never serialized back out; reads still expose only ssnLastFour. - BorrowerSectionService encrypts a supplied full SSN at rest (optional-injected SecretCipher — present in SecurityService, absent/booting-safe in contexts like KamoInitializer) and derives the last-4. A patch that omits SSN now preserves the stored value instead of wiping the last-4. - ssnEncrypted/itinEncrypted @Size(32) -> 256 so GCM-base64 ciphertext fits the existing 256-char column (no schema change).
· SecurityService
Optional ?phone= reverse lookup on GET /api/security/leads
The softphone CRM tab resolves an active caller number to a lead. Add an optional phone RequestParam that filters the org lead list by canonical (last-10-digit) match against PHONE_PERSONAL/PHONE_WORK before the lean LeadListDTO projection - no PII widening, same unassigned-visibility gating as the unfiltered grid.
· kamo-internal
Wire CRM Integration tab to real data
Replace the mock CRM tab with a live reverse-phone-lookup panel. - Resolve the active call / dialpad number to a CRM lead via leadsApi.searchByPhone (new ?phone= filter on GET /leads). - Show the matched lead: contact, status, loan figures, assigned officer; quick actions Call / Open Lead / Log Note (media/posts) / Schedule Callback (reused CallbackDialog); recent activity from the media feed. - Fallback when no lead matches: address-book Contacts, then the internal member directory (both client-side, reusing existing data paths), shown as an identity card. - New helpers app/lib/phone.ts (canonical digit match) and app/lib/directoryLookup.ts.
· EmailService
Support "unblock forever" remote-image consent
Adds a wildcard consent (allowAll / isAlwaysAllowed) so a member can opt to display external images in every message. Reuses the existing consent table via a reserved sentinel message-id key — no schema change. The POST /remote-image-consent endpoint accepts scope=always for this global choice.
· kamo-internal
Add "unblock forever" option for blocked external images
The external-images gate now offers two actions: Unblock (this message, as before) and Unblock forever, which displays external images in every message going forward. Unblock forever is gated behind a confirmation dialog and persists server-side (cross-device); a broadcast un-blocks other open messages immediately.
· SecurityService
A different preferred name forks a new lead
A genuinely different preferred name (compared case-insensitively with outer spacing trimmed) is a different person, so create a brand-new lead instead of renaming the existing one — it becomes the GUID's current lead via findLeadByGuid's uid-desc ordering. Trivial re-casing/spacing ("john" ↔ " John " ↔ "JOHN") does not fork. Applied to both the POST create path and the PATCH update path (the legal- name gate keeps the same preferred name, so it never forks).
· MediaService
Reach the borrower's OWN officer directly, no support-right gate
Drop the ACCEPT_SUPPORT_TICKETS requirement from assignToPreferredAgent: the borrower's assigned loan officer is reachable regardless of whether they're a configured support agent (the member id was already validated as that lead's officer in SecurityService). Keep an org-membership check as a safety belt so a tampered client can't route a session to another org's member; unknown/cross-org ids fall back to the default pending queue.
· APIService
Proxy GET /lo-presence to MediaService
The support-session proxy already forwards the raw body, so the merged widget's preferredAgentMemberId reaches MediaService untouched. Add the presence proxy so the borrower portal can query whether their assigned loan officer is online.
· MediaService
Direct-to-assigned-LO routing + LO presence endpoint
Powers the borrower portal's merged LO/chat widget: - **************** direct-assigns a public-chat ticket to a SPECIFIC agent (the borrower's assigned loan officer) instead of the pending queue, when that officer is an eligible support agent for the topic (adds them to the chat session + notifies them; records the pick on the round-robin tracker). Falls back to null so the caller keeps queue behavior. - **************** accepts preferredAgentMemberId and routes to that officer; only pings the org pending queue when unrouted. - PublicChatController: reads preferredAgentMemberId from the session body and adds GET /lo-presence?memberId= → {online} (PresenceService.isOnline) so the widget shows the officer's real online state and auto-covers with AI when away.
· SecurityService
Expose assigned-LO member id + phone for direct chat + presence
The merged LO/chat widget needs to (a) target the assigned officer directly in the borrower's live-support session and (b) query the officer's live presence. Both MediaService systems key on the member id, so /portal/assigned-lo now returns loMemberId (the accepting officer's Member id) and loPhone (from the officer's user record) alongside the existing name/NMLS/email.
· SecurityService
Assign a loan officer to a NAME-ONLY lead (before email/phone)
The borrower portal already creates the lead the instant a name is given, but enterAssignmentPoolIfActionable kept a name-only lead OUT of the credit accept pool (an LO couldn't reach a contactless borrower, so accepting just burned a credit). That reason no longer holds: a named lead surfaces the assigned LO to the borrower in-portal (LoContactCard, and the coming merged LO/chat widget), so a name is itself a reachable contact point. Admit a named lead to the pool so an officer can be assigned as early as possible — before email/phone arrive.
· EmailService
Inline-CID org logo (SVG->PNG) so it renders in Gmail; remove message content; preserve From-domain case
- FIX (security): notification emails carry no message body — remove messagePreview from template vars, ledger, samples and the settings toggle. - FIX (logo): org logos are SVG, which Gmail/most clients refuse to render. EmailLogoService rasterizes the org SVG to PNG (Batik) and embeds it inline via multipart/related (cid:emaillogo / emaillogomark), Redis-cached 24h, concurrent fetch, graceful fallback to the SVG URL on any failure. - FIX (From case): displayCasedDomain recases the brand label from the org title (prefix match, ccSLD-aware) so From reads KamoCRM.com not kamocrm.com.
· kamo-internal
Missed-message email deep links, auth-continuity stash, unread chat auto-popup, notification settings
DeepLinkToolBootstrap opens ?openChat/?openTicket tool windows (params survive the login round-trip via a sessionStorage stash captured pre-auth and preserved across /logout's wipe); AutoOpenUnreadChats pops unread chat sessions once per tab-session (cap 6, chat-only, deep-link deduped); Notifications settings tab (email on/off, preview, quiet hours) persisting notify.email.messages via MemberUiPreference; sessions/chat proxy now forwards categories filter.
· MediaService
Missed-message email notifications (chat + support) with per-source suppression
Durable NATS consumer chat-email-notifier on chat.session.> feeding a MESSAGE_EMAIL_NOTICES ledger (PENDING/SENT, per-sender for chat, per-ticket for support), 60s sweep with presence gate, quiet hours, digest window, retry-with-backoff (fail closed). Emails via EmailService templates with X-Internal-Auth; HTML-escaped variables; Member->User email fallback; mute-aware recipients. Sudo guards: impersonation no longer writes presence or clears/creates read signals. Enabled by default (chat.email.enabled=true).
· EmailService
Editor sample variables for missed-message notification templates
Preview samples for MISSED_CHAT_MESSAGE and SUPPORT_TICKET_REPLY incl. raw vs html-escaped sender/title variants used by the canonical bodies.
· kamo-shared-library
Missed-message email notification ledger + canonical templates
MessageEmailNotice entity (MESSAGE_EMAIL_NOTICES, unique recipient+sourceKey+channel) with PENDING/SENT suppression states, repository incl. sudo-safe markRead/ deletePendingRead, MISSED_CHAT_MESSAGE + SUPPORT_TICKET_REPLY canonical templates (registered in CANONICAL_KEYS), mute-aware MediaSessionMuteRepository lookup.
· EmailService
Microsoft 365 mail via Microsoft Graph app-only (zero per-mailbox setup)
Microsoft 365 mail now reads/sends through Microsoft Graph app-only (Mail.ReadWrite/Mail.Send application permissions) instead of IMAP — one app credential covers all current and future mailboxes tenant-wide, so adding a mailbox in Kamo just works: no New-ServicePrincipal, no per-mailbox grants. - GraphMailProvider (new): Graph **************** send/draft/search, adapting opaque Graph ids to the existing numeric-uid contract via GraphMessageIdService. Folders normalized to INBOX/Sent/Drafts/ Junk/Trash/Archive via locale-independent well-known-folder id resolution. Attachment $select uses the fileAttachment type-cast for contentId. - GraphMessageIdService (new): stable 52-bit synthetic uid; race-safe ON CONFLICT upsert. - Microsoft365Provider: extends GraphMailProvider; mail uses an app-only Graph token (tenant from the mailbox domain, creds from the platform OAuth registry); admin ops keep the delegated token. - ProviderHttpClient: URI-typed overloads (no RestTemplate re-encoding) + binary getBytes. - MailProviderResolver: MICROSOFT_365 no longer builds IMAP creds (Graph self- authenticates); removed now-dead platform-resolver wiring. KamoMail and the other IMAP providers (Exchange/Google/Zoho/IceWarp), the controller, metadata, and the frontend are all unchanged (numeric-uid contract preserved). Needs the new shared-lib table (KI run) and Graph Mail.ReadWrite + Mail.Send application permissions + admin consent on the M365 tenant.
· kamo-shared-library
GraphMessageIdMap entity for Microsoft 365 Graph mail id mapping
Maps opaque Microsoft Graph message ids to stable synthetic numeric UIDs per mailbox, so Graph mailboxes flow through the existing numeric-uid mail contract (controllers, thread/search/label metadata, frontend) with no changes. The synthetic uid is a 52-bit hash kept within JS safe-integer range; a race-safe ON CONFLICT upsert handles concurrent first-sight. Additive table **************** — created by KI, no data migration.
· DaemonService
Bump GENERATOR_VERSION 7 — backfill GBIF fauna into all cities
The bird engine now consumes the fauna block, so backfill it everywhere: the fill job re-drains stale rows at 24/min (user-requested cities first), so real locally-common birds converge across ~14h while the engine's habitat-guild fallback carries any not-yet-sampled or GBIF-thin city in the meantime.
· DaemonService
Sample locally-common birds from GBIF into city fauna
FaunaClient (mirrors PetrologyClient): GBIF occurrence facet on class Aves in a ~27km box around the city → top ~10 species by record count → resolve taxonomy (scientific + common name, family, order) + a normalized abundance weight. Verified real & habitat-specific (Tucson→Mourning Dove/Gila Woodpecker/Verdin/ Anna's Hummingbird; Pacific coast→Western Gull/cormorant; Everglades→White Ibis). Free/open, no key; bird distribution isn't in the KamoUniverse earth data. Wired into CitySceneSampler as an additive optional `fauna` block. The portal bird engine maps family/order → flight archetype + silhouette client-side, so this layer stays a dumb cached data source. GENERATOR_VERSION unchanged: fauna lands on freshly-sampled cities now and backfills on the bump when the render engine ships (avoids a premature 20k-city GBIF sweep for data nothing draws yet).
· DaemonService
63 US-ecoregion signature flora overrides (fully populate the biomes)
Cities within a state looked the same because flora came from just 15 coarse biome defaults (+1 Mojave override). Now curated real signature species for 63 US ecoregions (RESOLVE): Sierra Nevada → giant sequoia/ponderosa/sugar pine; N.Cal coast → coast redwood/douglas fir; Central Valley → valley/blue oak; Sonoran → saguaro/palo verde/ocotillo; Chihuahuan → agave/ ocotillo; Rockies → aspen/engelmann spruce/lodgepole; Appalachian → white oak/tulip poplar/ hickory; Everglades → mangrove/cypress/sabal palm; Piney Woods → loblolly/longleaf; plains → sparse bur oak/cottonwood; etc. So different ecoregions of one state now render distinct trees. GENERATOR_VERSION 6 → re-sample.
· DaemonService
Petrology (Macrostrat) + roads (.trf) + waterfalls (.hyf) — level up the KamoUniverse systems
GLiM never downloaded (PANGAEA 404s; KamoUniverse itself falls back to default lithology), so petrology comes from the Macrostrat global geologic-map API: per-point lithology → a coarse rock class **************** **************** The .trf road network and .hyf waterfall sidecars were baked on the drive but unused — now the sampler reads them too: the biggest road class + unpaved flag near the city, and the nearest waterfall's drop. GENERATOR_VERSION 2 → all city profiles re-sample.
· DaemonService
City-scene sampler over the KamoUniverse earth tiles
- EdtTile: byte-exact reader for EDT4 tiles (Copernicus DEM + ESA WorldCover + WorldClim + HydroSHEDS + RESOLVE ecoregions + ocean mask), memory-mapped, offset reads per layer (KBs per point, not the 210MB tile). Verified against the real N34_W117 Mojave tile. - EcoResolver: eco_biomes.json (exported verbatim from universe-world foliage.rs + eco_table.rs) → per-ecoregion flora palette. ECO 433 → Joshua tree, no duplication. - CitySceneSampler: coordinates → the versioned scene-factor document (terrain relief + SW horizon silhouette, climate + Koppen, coast/river/lake distance+azimuth, land-cover mix at 1/5/15km, flora palette, built density + dark-sky). Built centroids fall back to the dominant nearby ecoregion. - CitySceneryFillService + CitySceneryFillJob: drain PENDING (and stale-generator) city_scene_profile rows on a 1-min tick. k8s: read-only /mnt/worldchunks earthdata mount (present on both nodes), SCENERY_ENABLED.
· SecurityService
Expose full geo fields + city-scene profile API
- geoip/me now returns **************** (already loaded by the single block+location lookup, previously discarded) — powers city scenes and client personalization at zero new query cost. - CitySceneryService/Controller: GET /scenery/profile/{state}/{city} returns the city's sampled scene factors, materializing a PENDING row on first sight (portal renders the state scene meanwhile); resolves coordinates from the gazetteer, a caller lat/lon hint, or a stated-property ZIP. Internal PATCH overrides rail for curated art direction (X-Internal-Auth, constant-time).
· kamo-shared-library
CitySceneProfile entity + GeoLite city-lookup indexes for city-based portal scenes
- CitySceneProfile (city_scene_profile): per-city environmental scene factors (terrain/relief/silhouette, climate/koppen, hydrology, RESOLVE ecoregion + flora palette, land-cover mix, built/dark-sky) as a versioned JSONB contract, materialized cold-path from the KamoUniverse earth tiles. UUID pk, PENDING→READY status, art_overrides layer, generator_version for re-sample. - GeoLiteBlock: geo_name_id + postal_code indexes so a gazetteer city / stated ZIP resolves to a representative coordinate; block/location repos gain the finders. - Fixed two stale count-pinned tests (ImageAssocType roster, canonical templates).

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