From e824ff2206f8a539a9084ccf51b5b73801d860d8 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 20 Jun 2026 11:26:39 -0500 Subject: [PATCH] Capture phone (office) + mobile (cell) on card intake; ship v0.1.0:98 Completes business-card contact capture. The transcription prompt now labels Phone/Mobile/Fax on separate lines, and the extractor maps an office/main number -> phone and a cell -> mobile, never a fax. Both carry the same digit-in-source integrity rule as email/LinkedIn: a number is kept only if its digits literally appear in the source (or, on revise, the instruction) -- never minted. The proposal card shows Phone + Mobile and they're editable (aliases phone/tel/office, mobile/cell). Server: _upsert_contact_from_fundraising now accepts contact.phone + contact.mobile and writes them to the canonical contact record (contact-level, not grid pills), shipped in s9pk v0.1.0:98. No schema change -- the contacts columns already exist. 41/41 backend suite green + the matrix_intake units; card flow end-to-end is live-smoke. --- backend/matrix_intake/crm_client.py | 7 +++-- backend/matrix_intake/parse.py | 31 ++++++++++++++++++-- backend/matrix_intake/proposals.py | 6 +++- backend/matrix_intake/spark.py | 6 ++-- backend/matrix_intake/test_crm_client.py | 9 ++++-- backend/matrix_intake/test_parse.py | 37 ++++++++++++++++++++++++ backend/matrix_intake/test_proposals.py | 12 ++++++++ backend/server.py | 11 ++++--- backend/test_grid_add_investor.py | 21 +++++++------- start9/0.4/startos/utils.ts | 5 ++-- start9/0.4/startos/versions/index.ts | 5 ++-- start9/0.4/startos/versions/v0.1.0.98.ts | 18 ++++++++++++ 12 files changed, 139 insertions(+), 29 deletions(-) create mode 100644 start9/0.4/startos/versions/v0.1.0.98.ts diff --git a/backend/matrix_intake/crm_client.py b/backend/matrix_intake/crm_client.py index 7d73b34..e553cfd 100644 --- a/backend/matrix_intake/crm_client.py +++ b/backend/matrix_intake/crm_client.py @@ -162,10 +162,13 @@ def build_commit_payload(proposal): "name": proposal.get("contact_name") or proposal.get("investor_name") or "", "email": proposal.get("contact_email") or "", "title": proposal.get("contact_title") or "", - # city + linkedin_url are already honored by the server's contact upsert - # (_upsert_contact_from_fundraising); city also syncs to the grid contact pill. + # city + linkedin_url + phone + mobile are honored by the server's contact upsert + # (_upsert_contact_from_fundraising); city also syncs to the grid contact pill, the + # rest land on the canonical contact record. phone = office/main line, mobile = cell. "city": proposal.get("city") or "", "linkedin_url": proposal.get("linkedin_url") or "", + "phone": proposal.get("phone") or "", + "mobile": proposal.get("mobile") or "", } note = proposal.get("note") or "" # The CRM's grid note line uses subject-or-body for its one-line summary, so a non-empty diff --git a/backend/matrix_intake/parse.py b/backend/matrix_intake/parse.py index f02ab7e..c6ea2af 100644 --- a/backend/matrix_intake/parse.py +++ b/backend/matrix_intake/parse.py @@ -26,6 +26,9 @@ SYSTEM = ( ' "contact_title": the person\'s role/title if stated, else null.\n' ' "city": the person\'s city or location if stated (e.g. "New York"), else null.\n' ' "linkedin_url": the person\'s LinkedIn URL if explicitly present, else null. Never invent one.\n' + ' "phone": the office/main/direct phone number if present (a line labeled Phone/Tel/Office/' + 'Direct, or a single unlabeled number); never a fax or a cell. Else null.\n' + ' "mobile": the cell/mobile number if present (a line labeled Cell/Mobile); never a fax. Else null.\n' ' "note": any meeting note, context, or next step, else null.\n' "Use null (not empty string) for anything not present." ) @@ -56,7 +59,12 @@ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") _LINKEDIN_RE = re.compile(r"(?:https?://)?(?:[a-z]{2,3}\.)?linkedin\.com/[A-Za-z0-9_%/\-.]+", re.I) _VALID_INTENTS = {"new_investor", "meeting_note", "unclear"} _FIELDS = ("intent", "investor_name", "contact_name", "contact_email", "contact_title", - "city", "linkedin_url", "note") + "city", "linkedin_url", "phone", "mobile", "note") + + +def _digits(s): + """Bare digit run of a string (drops spaces/dashes/parens/dots), for phone-integrity checks.""" + return re.sub(r"\D", "", str(s or "")) def _clean(v): @@ -89,6 +97,15 @@ def normalize(raw, source_text=""): lm = _LINKEDIN_RE.search(source_text or "") out["linkedin_url"] = lm.group(0).rstrip(".,;:!?)]}>\"'") if lm else None + # Phone integrity: keep a number (in its printed formatting) only if its digit run actually + # appears in the source — the model must never mint or "complete" a number. phone = the + # office/main line, mobile = the cell; which is which is the model's call (prompted), this + # only validates that the number is real. (≥7 digits avoids matching a stray short run.) + src_digits = _digits(source_text) + for f in ("phone", "mobile"): + d = _digits(out.get(f)) + out[f] = out.get(f) if (len(d) >= 7 and d in src_digits) else None + # City is left as a plain extracted field (no source gate): a wrong city is low-harm and the # human sees it on the card before approving, unlike a wrong email/LinkedIn. @@ -114,10 +131,10 @@ REVISE_SYSTEM = ( "team member typed. You are given the CURRENT proposal as JSON and an INSTRUCTION. Apply " "the instruction and reply with ONLY the full revised JSON object, these keys:\n" ' "investor_name", "contact_name", "contact_email", "contact_title", "city", ' - '"linkedin_url", "note".\n' + '"linkedin_url", "phone", "mobile", "note".\n' "Change ONLY what the instruction asks; copy every other field through unchanged. Use null " "for a field the instruction clears or that is genuinely absent. Never invent an email " - "address or a LinkedIn URL." + "address, a LinkedIn URL, or a phone number." ) _REVISABLE = ("investor_name", "contact_name", "contact_title", "city", "note") @@ -143,6 +160,14 @@ def _apply_revision(proposal, model_out, instruction): lm = _LINKEDIN_RE.search(instruction or "") if lm: out["linkedin_url"] = lm.group(0).rstrip(".,;:!?)]}>\"'") + # Phone/mobile too: a revised number is accepted only if its digits appear in the instruction + # (never let the model mint one); otherwise the existing value is kept. + instr_digits = _digits(instruction) + for f in ("phone", "mobile"): + if f in model_out: + cand = _clean(model_out.get(f)) + d = _digits(cand) + out[f] = cand if (cand and len(d) >= 7 and d in instr_digits) else out.get(f) # Don't let a revision strip the proposal down to nothing actionable. if not out.get("investor_name") and not out.get("contact_name"): out["investor_name"] = proposal.get("investor_name") diff --git a/backend/matrix_intake/proposals.py b/backend/matrix_intake/proposals.py index c64079e..c466d06 100644 --- a/backend/matrix_intake/proposals.py +++ b/backend/matrix_intake/proposals.py @@ -20,6 +20,8 @@ _EDIT_ALIASES = { "title": "contact_title", "role": "contact_title", "city": "city", "location": "city", "linkedin": "linkedin_url", "linkedin_url": "linkedin_url", "li": "linkedin_url", + "phone": "phone", "tel": "phone", "office": "phone", + "mobile": "mobile", "cell": "mobile", "note": "note", } @@ -29,7 +31,7 @@ _NO = {"no", "n", "cancel", "discard", "reject", "stop", "👎", "❌"} _NEW = {"new", "none", "new investor", "none of these", "create", "create new", "add new", "neither"} _CONTENT_FIELDS = ("intent", "investor_name", "contact_name", "contact_email", "contact_title", - "city", "linkedin_url", "note") + "city", "linkedin_url", "phone", "mobile", "note") class ProposalStore: @@ -177,6 +179,8 @@ def render(proposal): ("Contact", proposal.get("contact_name")), ("Email", proposal.get("contact_email")), ("Title", proposal.get("contact_title")), + ("Phone", proposal.get("phone")), + ("Mobile", proposal.get("mobile")), ("City", proposal.get("city")), ("LinkedIn", proposal.get("linkedin_url")), ("Note", proposal.get("note")), diff --git a/backend/matrix_intake/spark.py b/backend/matrix_intake/spark.py index c776ecc..d89bd4a 100644 --- a/backend/matrix_intake/spark.py +++ b/backend/matrix_intake/spark.py @@ -34,10 +34,12 @@ CARD_SYSTEM = ( "character:\n" " - Email: check the local part, the @, and the domain separately (transcribe 'mara.com' as " "'mara.com', never 'marac.com').\n" - " - Phone number(s).\n" + " - Phone, cell/mobile, and fax numbers — keep each on its own labeled line so they aren't " + "confused (put an office/main/direct number on Phone:, a cell/mobile number on Mobile:, and a " + "fax on Fax:).\n" " - Website / LinkedIn URL.\n" "Then list, each on its own labeled line and ONLY if present on the card:\n" - " Name: Title: Company: Email: Phone: LinkedIn: City:\n" + " Name: Title: Company: Email: Phone: Mobile: Fax: LinkedIn: City:\n" "If a character is genuinely ambiguous, give your single best reading — never invent extra " "characters to fill a gap. If the image is not a readable business card, reply with the single " "word NONE. Output only the labeled lines, nothing else." diff --git a/backend/matrix_intake/test_crm_client.py b/backend/matrix_intake/test_crm_client.py index 9956217..02d6325 100644 --- a/backend/matrix_intake/test_crm_client.py +++ b/backend/matrix_intake/test_crm_client.py @@ -16,18 +16,21 @@ def test_new_investor_payload(): assert out["create_investor_if_missing"] is True assert "row_id" not in out assert out["contact"] == {"name": "Jane Doe", "email": "jane@acme.com", "title": "GP", - "city": "", "linkedin_url": ""} + "city": "", "linkedin_url": "", "phone": "", "mobile": ""} assert out["body"] == "met at conf" assert out["source"] == "matrix_intake" -def test_contact_carries_city_and_linkedin_when_present(): +def test_contact_carries_card_fields_when_present(): p = {"intent": "new_investor", "investor_name": "Acme Capital", "contact_name": "Jane Doe", "contact_email": "jane@acme.com", "city": "New York", - "linkedin_url": "linkedin.com/in/janedoe", "note": "met at conf"} + "linkedin_url": "linkedin.com/in/janedoe", "phone": "212-555-0100", + "mobile": "917-555-0199", "note": "met at conf"} out = crm_client.build_commit_payload(p) assert out["contact"]["city"] == "New York" assert out["contact"]["linkedin_url"] == "linkedin.com/in/janedoe" + assert out["contact"]["phone"] == "212-555-0100" # office/main line + assert out["contact"]["mobile"] == "917-555-0199" # cell def test_existing_investor_uses_row_id_not_create(): diff --git a/backend/matrix_intake/test_parse.py b/backend/matrix_intake/test_parse.py index 8e76d48..7405070 100644 --- a/backend/matrix_intake/test_parse.py +++ b/backend/matrix_intake/test_parse.py @@ -233,6 +233,43 @@ def test_revise_linkedin_taken_only_from_instruction(): assert r2["contact_title"] == "GP" +def test_phone_and_mobile_kept_when_digits_in_source(): + # A card transcription separates Phone/Mobile/Fax; the model maps office->phone, cell->mobile. + src = ("New investor — from a business card:\nName: Daniel Raupp\nCompany: Fortitude\n" + "Phone: 631-474-5610\nFax: 631-474-1806\nCell: 631-922-1195") + p = parse.parse_message( + src, + parse_fn=_stub({"intent": "new_investor", "investor_name": "Fortitude", + "contact_name": "Daniel Raupp", "phone": "631-474-5610", + "mobile": "631-922-1195"}), + ) + assert p["phone"] == "631-474-5610" + assert p["mobile"] == "631-922-1195" # the cell, kept in its printed formatting + + +def test_fabricated_phone_dropped_when_digits_not_in_source(): + p = parse.parse_message( + "new prospect Gamma Partners, talked to their GP", + parse_fn=_stub({"intent": "new_investor", "investor_name": "Gamma Partners", + "contact_name": "their GP", "phone": "555-867-5309"}), + ) + assert p["phone"] is None # number not in the source → never minted + + +def test_revise_phone_taken_only_from_instruction(): + proposal = {"intent": "new_investor", "investor_name": "Acme", "contact_name": "Jane", + "contact_email": None, "contact_title": None, "city": None, "linkedin_url": None, + "phone": None, "mobile": None, "note": None, "_source_text": "Acme Jane"} + r1 = parse.revise(proposal, "her cell is 917-555-0199", + parse_fn=_stub({"mobile": "917-555-0199"})) + assert r1["mobile"] == "917-555-0199" + # model tries to set a number but the instruction has none → keep existing (None) + r2 = parse.revise(proposal, "set her title to GP", + parse_fn=_stub({"mobile": "000-000-0000", "contact_title": "GP"})) + assert r2["mobile"] is None + assert r2["contact_title"] == "GP" + + def test_revise_cannot_empty_the_proposal(): proposal = {"intent": "new_investor", "investor_name": "Acme", "contact_name": "Jane", "contact_email": None, "contact_title": None, "note": "x", "_source_text": "Acme Jane"} diff --git a/backend/matrix_intake/test_proposals.py b/backend/matrix_intake/test_proposals.py index 508f179..4596d27 100644 --- a/backend/matrix_intake/test_proposals.py +++ b/backend/matrix_intake/test_proposals.py @@ -68,6 +68,18 @@ def test_render_shows_city_and_linkedin_when_present(): assert "LinkedIn: linkedin.com/in/jane" in out +def test_interpret_edit_phone_and_mobile_aliases(): + assert proposals.interpret_reply("phone=212-555-0100") == ("edit", ("phone", "212-555-0100")) + assert proposals.interpret_reply("cell: 917-555-0199") == ("edit", ("mobile", "917-555-0199")) + + +def test_render_shows_phone_and_mobile_when_present(): + p = {**SAMPLE, "phone": "212-555-0100", "mobile": "917-555-0199"} + out = proposals.render(p) + assert "Phone: 212-555-0100" in out + assert "Mobile: 917-555-0199" in out + + def test_interpret_unknown(): assert proposals.interpret_reply("maybe later")[0] == "unknown" diff --git a/backend/server.py b/backend/server.py index f5be86e..2ce23f4 100644 --- a/backend/server.py +++ b/backend/server.py @@ -826,6 +826,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id location_query = str(contact.get('location_query') or '').strip() linkedin_url = str(contact.get('linkedin_url') or '').strip() phone = str(contact.get('phone') or '').strip() + mobile = str(contact.get('mobile') or '').strip() if not full_name and not email: return None first_name, last_name = _split_full_name(full_name) @@ -871,20 +872,21 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id next_location_query = location_query or str(existing['location_query'] or '') next_linkedin = linkedin_url or str(existing['linkedin_url'] or '') next_phone = phone or str(existing['phone'] or '') + next_mobile = mobile or str(existing['mobile'] or '') next_org = org_id or existing['organization_id'] conn.execute(""" UPDATE contacts SET first_name = ?, last_name = ?, email = ?, title = ?, - organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, updated_at = ? + organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ? WHERE id = ? - """, (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, now(), existing['id'])) + """, (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, next_mobile, now(), existing['id'])) return existing['id'] contact_id = generate_id() conn.execute(""" INSERT INTO contacts ( - id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, created_by, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?) + id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( contact_id, first_name or 'Unknown', @@ -899,6 +901,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id location_query, linkedin_url, phone, + mobile, actor_user_id, now() )) diff --git a/backend/test_grid_add_investor.py b/backend/test_grid_add_investor.py index 7fee809..2423f58 100644 --- a/backend/test_grid_add_investor.py +++ b/backend/test_grid_add_investor.py @@ -165,22 +165,23 @@ def main(): # The Matrix card flow sends these on the contact dict; the upsert must persist them. print("\n[contact fields: phone + city + linkedin persist on the contact]") st, d = _req(port, "POST", "/api/fundraising/log-communication", token, { - "investor_name": "MARA Holdings", "create_investor_if_missing": True, - "contact": {"name": "Doug Mellinger", "email": "doug@mara.example", - "phone": "1.914.456.2146", "city": "New York", - "linkedin_url": "linkedin.com/in/dougmellinger"}, + "investor_name": "Fortitude Investment Group", "create_investor_if_missing": True, + "contact": {"name": "Daniel Raupp", "email": "draupp@fortitude.example", + "phone": "631-474-5610", "mobile": "631-922-1195", "city": "Setauket, NY", + "linkedin_url": "linkedin.com/in/danielraupp"}, "type": "note", "body": "from a business card", "append_note": True, }) check(st == 201, f"create with contact fields -> 201 (got {st})") c = _db() - crow = c.execute("SELECT phone, city, linkedin_url FROM contacts WHERE lower(email) = ?", - ("doug@mara.example",)).fetchone() + crow = c.execute("SELECT phone, mobile, city, linkedin_url FROM contacts WHERE lower(email) = ?", + ("draupp@fortitude.example",)).fetchone() c.close() check(crow is not None, "contact row exists") - check(bool(crow) and crow[0] == "1.914.456.2146", f"phone persisted (got {crow[0] if crow else None!r})") - check(bool(crow) and crow[1] == "New York", f"city persisted (got {crow[1] if crow else None!r})") - check(bool(crow) and crow[2] == "linkedin.com/in/dougmellinger", - f"linkedin persisted (got {crow[2] if crow else None!r})") + check(bool(crow) and crow[0] == "631-474-5610", f"phone (office) persisted (got {crow[0] if crow else None!r})") + check(bool(crow) and crow[1] == "631-922-1195", f"mobile (cell) persisted (got {crow[1] if crow else None!r})") + check(bool(crow) and crow[2] == "Setauket, NY", f"city persisted (got {crow[2] if crow else None!r})") + check(bool(crow) and crow[3] == "linkedin.com/in/danielraupp", + f"linkedin persisted (got {crow[3] if crow else None!r})") # ── unknown source_row_id is refused (guard) ── print("\n[guard: reminder on an unknown source_row_id -> 404]") diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts index 7e172cc..6c90d45 100644 --- a/start9/0.4/startos/utils.ts +++ b/start9/0.4/startos/utils.ts @@ -62,8 +62,9 @@ export const PACKAGE_TITLE = 'Ten31 Database' // * 0.1.0:94 (NL-query correctness fix: the comms_by_user + email_counts_by_user intents were counting/listing a user's ENTIRE captured sent corpus [internal/vendor/personal], not only email to a matched investor — they lacked the EXISTS email_investor_links gate that recent_emails + the Communications panel use. Added the matched-only gate to both [+ a regression test seeding an unmatched sent email]; no schema change, no UI change) // * 0.1.0:95 (mobile-first redesign goes live + installable PWA. The Grid, Pipeline, Reminders & Contacts screens are touch-native on phones below 768px [safe-area bottom-tab nav, card lists, drag-dismiss bottom sheets, swipe actions, full-screen Grid detail, SVG tab icons + ·Ten31· wordmark], with an app-wide light theme + toggle. Installable home-screen PWA: manifest.webmanifest [standalone display, #0b1118 theme] + square/apple-touch icons + a pre-auth /manifest.webmanifest route; iOS-first, no service worker. Pipeline funnel v2: 4-stage lead→engaged→diligence→commitment [in-app migration 0007] with derived grid signals [pipeline_stage/existing_investor/recency] injected-on-GET, stripped-on-write. Desktop UI unchanged; no LLM path. Bundles the previously deploy-pending mobile Phases 0–8 + drag-reorder views + the PWA) // * 0.1.0:96 (login page mobile/PWA conformance — the one surface the v95 mobile redesign skipped. CSS-only: 100vh→100dvh [dynamic viewport, fixes the centered card tucking under the iOS standalone status bar], a <768px media query adding 16px screen gutters + env[safe-area-inset] top/bottom clearance + touch-sized fields [inputs 46px/15px, button 46px], full-bleed card on small phones, and the §4 card depth shadow on the login card to match .section. No markup/JS/schema change; desktop login unchanged) -// * Current: 0.1.0:97 (mobile top-bar polish + native zoom behaviour. Viewport meta gains maximum-scale=1 + user-scalable=no: kills pinch-zoom AND the iOS auto-zoom-on-focus that jerked the page in on every <16px input tap [app-wide, not just login]; OS accessibility zoom still works. Top-bar account initial now flex-centered + dc-aligned [IBM Plex Mono, accent-light, 13px — was defaulting to inline/baseline, off-center]. Quick-log pencil bumped --text-muted→--text-secondary for real affordance [the dc t3 grey thin-outline read as empty next to the color sun emoji on-device]. CSS-only; no JS/schema change) -export const PACKAGE_VERSION = '0.1.0:97' +// * 0.1.0:97 (mobile top-bar polish + native zoom behaviour. Viewport meta gains maximum-scale=1 + user-scalable=no: kills pinch-zoom AND the iOS auto-zoom-on-focus that jerked the page in on every <16px input tap [app-wide, not just login]; OS accessibility zoom still works. Top-bar account initial now flex-centered + dc-aligned [IBM Plex Mono, accent-light, 13px — was defaulting to inline/baseline, off-center]. Quick-log pencil bumped --text-muted→--text-secondary for real affordance [the dc t3 grey thin-outline read as empty next to the color sun emoji on-device]. CSS-only; no JS/schema change) +// * Current: 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change) +export const PACKAGE_VERSION = '0.1.0:98' export const DATA_MOUNT_PATH = '/data' export const WEB_PORT = 8080 diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 4f5cfe9..c9a7a49 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -58,8 +58,9 @@ import { v_0_1_0_94 } from './v0.1.0.94' import { v_0_1_0_95 } from './v0.1.0.95' import { v_0_1_0_96 } from './v0.1.0.96' import { v_0_1_0_97 } from './v0.1.0.97' +import { v_0_1_0_98 } from './v0.1.0.98' export const versionGraph = VersionGraph.of({ - current: v_0_1_0_97, - other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96], + current: v_0_1_0_98, + other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97], }) diff --git a/start9/0.4/startos/versions/v0.1.0.98.ts b/start9/0.4/startos/versions/v0.1.0.98.ts new file mode 100644 index 0000000..c168fdd --- /dev/null +++ b/start9/0.4/startos/versions/v0.1.0.98.ts @@ -0,0 +1,18 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +// Server half of business-card capture for the Matrix intake bot: the fundraising contact +// upsert (_upsert_contact_from_fundraising) now accepts phone + mobile on the contact dict +// (alongside city + linkedin_url, which already worked) and writes them to the canonical +// contact record. No schema change — the contacts table already has these columns. The bot's +// matching transcription/extraction/card changes ship on the Spark (git pull + rebuild), not here. +export const v_0_1_0_98 = VersionInfo.of({ + version: '0.1.0:98', + releaseNotes: { + en_US: [ + 'Business-card intake (Matrix bot) now captures a contact phone, mobile/cell, city, and', + 'LinkedIn from a scanned card onto the contact record (cell to Mobile, office to Phone,', + 'fax skipped). No user-facing CRM change.', + ].join(' '), + }, + migrations: { up: async () => {}, down: async () => {} }, +})