feat(crm): many-to-many contact-company relationships with role and status #38
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_biz#38
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Overview
Requested in home#210 comment #29840 by Marion.
The current
Personmodel has a singlecompany_sid: Option<String>field — one company, no role, no status. This needs to be replaced with a proper many-to-many join model so a contact can be linked to multiple organisations simultaneously (or historically), each with a context-specific role and an Active/Former status.Current State
Person.company_sid: Option<String>— one company, no metadataCompanyhas no linked-persons list at allRequired Changes
1. New
PersonCompanyLinkmodel2. Person model
company_sid: Option<String>function_title: Option<String>— general independent role (e.g. "Investor", "Lawyer") not tied to any company3. Person detail page
Add a Linked Companies table below the person info card:
4. Company detail page
Add a Linked Contacts table:
5. Person create/edit form
Acceptance Criteria
PersonCompanyLinkmodel added withperson_sid,company_sid,role,statusPerson.company_sidreplaced by link table (migration or re-seed)Person.function_titlefield added for independent general rolePersonCompanyLinkReference
Marion's spec: "understand a contact's full picture at a glance: who they are, where they work or have worked, and in what capacity"
Implementation Spec for Issue #38
Objective
Replace
Person.company_sid: Option<String>(single link, no metadata) with aPersonCompanyLinkjoin model stored as local JSON files underdb_root, so a contact can be linked to multiple companies with a role and Active/Former status. Update the Person detail page, Company detail page, and Person create/edit form accordingly.Architecture Decision
hero_osis_sdkdoes not have aPersonCompanyLinktype and cannot be modified in this PR. Links are stored as individual JSON files under{db_root}/{context}/person_company_links/{sid}.json, following the existing local-file pattern (same as chat messages and uploads).Person.company_sidis kept in OSIS for backward compatibility but is no longer used by the UI after migration.Files to Modify/Create
crates/hero_biz_ui/src/models/person_company_link.rs— new file:PersonCompanyLinkandLinkStatusstructscrates/hero_biz_ui/src/models/mod.rs— addpub mod person_company_linkand re-exportcrates/hero_biz_ui/src/models/person.rs— addfunction_title: Option<String>field (local-only, not persisted to OSIS)crates/hero_biz_ui/src/services/mod.rs— add CRUD methods forPersonCompanyLink; updateget_persons_for_companyto use linkscrates/hero_biz_ui/src/web/templates/mod.rs— updatePersonDetailTemplate,CompanyDetailTemplate,PersonFormTemplatecrates/hero_biz_ui/src/web/handlers/mod.rs— updatepersons_detail,companies_detail,person_new,person_edit,person_create,person_update; addperson_link_deletehandler; updatePersonFormcrates/hero_biz_ui/src/web/server.rs— add route for link deletionImplementation Plan
Step 1: Add
PersonCompanyLinkmodelFiles:
crates/hero_biz_ui/src/models/person_company_link.rs,crates/hero_biz_ui/src/models/mod.rsDependencies: none
Subtasks:
person_company_link.rswith:pub mod person_company_link;andpub use person_company_link::{LinkStatus, PersonCompanyLink};tomodels/mod.rsfunction_title: Option<String>with#[serde(skip_serializing_if = "Option::is_none", default)]toPersonstruct inmodels/person.rsStep 2: Add store CRUD for
PersonCompanyLinkFiles:
crates/hero_biz_ui/src/services/mod.rsDependencies: Step 1
Subtasks:
link_dir(db_root, context) -> PathBufreturning{db_root}/{context}/person_company_links/load_all_person_company_links(&self, context) -> Result<Vec<PersonCompanyLink>>: reads all*.jsonfiles from link dirload_person_company_links(&self, context, person_sid) -> Result<Vec<PersonCompanyLink>>: filters byperson_sidload_company_person_links(&self, context, company_sid) -> Result<Vec<PersonCompanyLink>>: filters bycompany_sidsave_person_company_link(&self, context, link: &PersonCompanyLink) -> Result<String>: generates sid if empty (use samegenerate_sid()pattern as elsewhere oruuid/short random), writes JSON filedelete_person_company_link(&self, context, link_sid) -> Result<()>: removes JSON fileget_persons_for_company: after filtering bycompany_sid, also include persons linked viaPersonCompanyLink; deduplicate by sidStep 3: Update
PersonDetailTemplateandCompanyDetailTemplateFiles:
crates/hero_biz_ui/src/web/templates/mod.rsDependencies: Step 1
Subtasks:
linked_companies: Vec<(PersonCompanyLink, Company)>field toPersonDetailTemplatePersonDetailTemplate::render(), add a "Linked Companies" card with table: Company (link to/c/{context}/companies/{sid}), Role, Status badge (Active=green, Former=secondary); replace the old single-company link displaylinked_persons: Vec<(PersonCompanyLink, Person)>field toCompanyDetailTemplate; keep existingpersons: Vec<Person>(used by old code path)CompanyDetailTemplate::render(), add a "Linked Contacts" card with table: Person (link to/c/{context}/contacts/{sid}), Role, Status badgeStep 4: Update
PersonFormTemplateFiles:
crates/hero_biz_ui/src/web/templates/mod.rsDependencies: Step 1
Subtasks:
existing_links: Vec<(PersonCompanyLink, Company)>andfunction_title: Option<String>fields toPersonFormTemplate<select>with:function_titletext input (independent general role)<select>, role text input, status<select>(Active/Former), and a Remove buttonlink_company_sid[],link_role[],link_status[](array-style) for form submissionexisting_linksStep 5: Update handlers and add link-delete route
Files:
crates/hero_biz_ui/src/web/handlers/mod.rs,crates/hero_biz_ui/src/web/server.rsDependencies: Steps 2, 3, 4
Subtasks:
persons_detail: load links viastore.load_person_company_links(context, &id); for each link, load company; pass aslinked_companiescompanies_detail: load links viastore.load_company_person_links(context, &id); for each link, load person; pass aslinked_personsperson_new: loadcompaniesfor dropdown; passexisting_links: vec![]person_edit: load links for the person; load companies; pass both to templatePersonFormstruct: addlink_company_sid: Option<String>(comma-separated or repeated),link_role: Option<String>,link_status: Option<String>fields using#[serde(rename = "link_company_sid[]")]or parse manually from raw form bodyperson_create/person_update: after saving person, parse link rows from form, delete existing links for this person (on update), save new linksasync fn person_link_delete(State, Path((context, person_id, link_sid))): callsstore.delete_person_company_link, redirects back to person detailserver.rs, add route:.route("/c/:context/contacts/:id/links/:link_sid/delete", post(handlers::person_link_delete))Acceptance Criteria
PersonCompanyLinkmodel added withperson_sid,company_sid,role,statusPerson.function_titlefield addedcargo buildpasses with no clippy errorsNotes
db_rootlocal files.Person.company_sidis kept in the OSIS struct for backward compat but the UI ignores it after this change.generate_sidor similar in services, or useuuid::Uuid::new_v4().to_string()[..6]).Form<T>extractor does not natively supportfield[]repeated keys. Parse link rows by extractinglink_company_sid_N,link_role_N,link_status_Nindexed fields, or use a custom extractor. The simplest approach: encode all rows as a JSON string in a hiddenlinks_jsoninput and parse on the server.get_persons_for_company: update to union persons fromcompany_sidfilter AND fromPersonCompanyLinkto avoid breaking existing data.Test Results
cargo testpassed cleanly.cargo buildcompleted with no errors and no clippy warnings.Implementation Summary
All changes implemented on branch
development_casper.Files created
crates/hero_biz_ui/src/models/person_company_link.rs—PersonCompanyLinkstruct andLinkStatusenum (Active/Former)Files modified
crates/hero_biz_ui/src/models/mod.rs— re-exportsPersonCompanyLinkandLinkStatuscrates/hero_biz_ui/src/models/person.rs— addedfunction_title: Option<String>fieldcrates/hero_biz_ui/src/services/mod.rs— addedload_all_person_company_links,load_person_company_links,load_company_person_links,save_person_company_link,delete_person_company_link,delete_all_links_for_person; updatedget_persons_for_companyto union link-table resultscrates/hero_biz_ui/src/web/templates/mod.rs—PersonDetailTemplateshows Linked Companies card;CompanyDetailTemplateshows Linked Contacts card;PersonFormTemplatehas dynamic multi-link widget with add/remove rowscrates/hero_biz_ui/src/web/handlers/mod.rs— all detail/form/create/update handlers wired with real link data;person_link_deletehandler addedcrates/hero_biz_ui/src/web/server.rs— added route/c/:context/contacts/:id/links/:link_sid/deleteArchitecture note
Links are stored as local JSON files under
{db_root}/{context}/person_company_links/{sid}.json, following the existing local-file pattern. No changes tohero_osis_sdkwere needed.Acceptance criteria status
PersonCompanyLinkmodel added withperson_sid,company_sid,role,statusPerson.function_titlefield added