feat(crm): many-to-many contact-company relationships with role and status #38

Closed
opened 2026-05-06 12:25:25 +00:00 by casper-stevens · 3 comments
Member

Overview

Requested in home#210 comment #29840 by Marion.

The current Person model has a single company_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 metadata
  • Company has no linked-persons list at all
  • No way to track former relationships or role context

Required Changes

pub struct PersonCompanyLink {
    pub sid: String,
    pub person_sid: String,
    pub company_sid: String,
    // Role specific to this relationship (e.g. "Investor", "Board Member")
    pub role: String,
    // Active or Former
    pub status: LinkStatus,
}

pub enum LinkStatus {
    Active,
    Former,
}

2. Person model

  • Remove company_sid: Option<String>
  • Add function_title: Option<String> — general independent role (e.g. "Investor", "Lawyer") not tied to any company

3. Person detail page

Add a Linked Companies table below the person info card:

Company Role Status
Acme Corp (link) Board Member Active
Old Co (link) Advisor Former
  • Company name is a clickable link to the company detail page
  • Add/edit/remove links inline
  • Multiple links addable during person create/edit

4. Company detail page

Add a Linked Contacts table:

Person Role Status
Alice (link) CEO Active
Bob (link) Advisor Former

5. Person create/edit form

  • Replace single company dropdown with a dynamic multi-link widget
  • Each row: company picker + role text input + status dropdown (Active/Former)
  • Add row / remove row buttons

Acceptance Criteria

  • PersonCompanyLink model added with person_sid, company_sid, role, status
  • Person.company_sid replaced by link table (migration or re-seed)
  • Person.function_title field added for independent general role
  • Person detail page shows Linked Companies table with clickable company names
  • Company detail page shows Linked Contacts table with clickable person names
  • Person create/edit form supports adding multiple company links at once
  • Status can be Active or Former; former links are preserved (not deleted)
  • Store methods for CRUD on PersonCompanyLink
  • Build passes, no clippy warnings

Reference

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"

## Overview Requested in [home#210 comment #29840](https://forge.ourworld.tf/lhumina_code/home/issues/210#issuecomment-29840) by Marion. The current `Person` model has a single `company_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 metadata - `Company` has no linked-persons list at all - No way to track former relationships or role context ## Required Changes ### 1. New `PersonCompanyLink` model ```rust pub struct PersonCompanyLink { pub sid: String, pub person_sid: String, pub company_sid: String, // Role specific to this relationship (e.g. "Investor", "Board Member") pub role: String, // Active or Former pub status: LinkStatus, } pub enum LinkStatus { Active, Former, } ``` ### 2. Person model - Remove `company_sid: Option<String>` - Add `function_title: Option<String>` — general independent role (e.g. "Investor", "Lawyer") not tied to any company ### 3. Person detail page Add a **Linked Companies** table below the person info card: | Company | Role | Status | |---------|------|--------| | Acme Corp (link) | Board Member | Active | | Old Co (link) | Advisor | Former | - Company name is a clickable link to the company detail page - Add/edit/remove links inline - Multiple links addable during person create/edit ### 4. Company detail page Add a **Linked Contacts** table: | Person | Role | Status | |--------|------|--------| | Alice (link) | CEO | Active | | Bob (link) | Advisor | Former | ### 5. Person create/edit form - Replace single company dropdown with a dynamic multi-link widget - Each row: company picker + role text input + status dropdown (Active/Former) - Add row / remove row buttons ## Acceptance Criteria - [ ] `PersonCompanyLink` model added with `person_sid`, `company_sid`, `role`, `status` - [ ] `Person.company_sid` replaced by link table (migration or re-seed) - [ ] `Person.function_title` field added for independent general role - [ ] Person detail page shows Linked Companies table with clickable company names - [ ] Company detail page shows Linked Contacts table with clickable person names - [ ] Person create/edit form supports adding multiple company links at once - [ ] Status can be Active or Former; former links are preserved (not deleted) - [ ] Store methods for CRUD on `PersonCompanyLink` - [ ] Build passes, no clippy warnings ## Reference 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"
Author
Member

Implementation Spec for Issue #38

Objective

Replace Person.company_sid: Option<String> (single link, no metadata) with a PersonCompanyLink join model stored as local JSON files under db_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_sdk does not have a PersonCompanyLink type 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_sid is 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.rsnew file: PersonCompanyLink and LinkStatus structs
  • crates/hero_biz_ui/src/models/mod.rs — add pub mod person_company_link and re-export
  • crates/hero_biz_ui/src/models/person.rs — add function_title: Option<String> field (local-only, not persisted to OSIS)
  • crates/hero_biz_ui/src/services/mod.rs — add CRUD methods for PersonCompanyLink; update get_persons_for_company to use links
  • crates/hero_biz_ui/src/web/templates/mod.rs — update PersonDetailTemplate, CompanyDetailTemplate, PersonFormTemplate
  • crates/hero_biz_ui/src/web/handlers/mod.rs — update persons_detail, companies_detail, person_new, person_edit, person_create, person_update; add person_link_delete handler; update PersonForm
  • crates/hero_biz_ui/src/web/server.rs — add route for link deletion

Implementation Plan

Files: crates/hero_biz_ui/src/models/person_company_link.rs, crates/hero_biz_ui/src/models/mod.rs
Dependencies: none
Subtasks:

  • Create person_company_link.rs with:
    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
    #[serde(rename_all = "snake_case")]
    pub enum LinkStatus { #[default] Active, Former }
    
    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
    pub struct PersonCompanyLink {
        pub sid: String,
        pub person_sid: String,
        pub company_sid: String,
        pub role: String,
        pub status: LinkStatus,
    }
    
  • Add pub mod person_company_link; and pub use person_company_link::{LinkStatus, PersonCompanyLink}; to models/mod.rs
  • Add function_title: Option<String> with #[serde(skip_serializing_if = "Option::is_none", default)] to Person struct in models/person.rs

Files: crates/hero_biz_ui/src/services/mod.rs
Dependencies: Step 1
Subtasks:

  • Add helper link_dir(db_root, context) -> PathBuf returning {db_root}/{context}/person_company_links/
  • Add load_all_person_company_links(&self, context) -> Result<Vec<PersonCompanyLink>>: reads all *.json files from link dir
  • Add load_person_company_links(&self, context, person_sid) -> Result<Vec<PersonCompanyLink>>: filters by person_sid
  • Add load_company_person_links(&self, context, company_sid) -> Result<Vec<PersonCompanyLink>>: filters by company_sid
  • Add save_person_company_link(&self, context, link: &PersonCompanyLink) -> Result<String>: generates sid if empty (use same generate_sid() pattern as elsewhere or uuid/short random), writes JSON file
  • Add delete_person_company_link(&self, context, link_sid) -> Result<()>: removes JSON file
  • Update get_persons_for_company: after filtering by company_sid, also include persons linked via PersonCompanyLink; deduplicate by sid

Step 3: Update PersonDetailTemplate and CompanyDetailTemplate

Files: crates/hero_biz_ui/src/web/templates/mod.rs
Dependencies: Step 1
Subtasks:

  • Add linked_companies: Vec<(PersonCompanyLink, Company)> field to PersonDetailTemplate
  • In PersonDetailTemplate::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 display
  • Add linked_persons: Vec<(PersonCompanyLink, Person)> field to CompanyDetailTemplate; keep existing persons: Vec<Person> (used by old code path)
  • In CompanyDetailTemplate::render(), add a "Linked Contacts" card with table: Person (link to /c/{context}/contacts/{sid}), Role, Status badge

Step 4: Update PersonFormTemplate

Files: crates/hero_biz_ui/src/web/templates/mod.rs
Dependencies: Step 1
Subtasks:

  • Add existing_links: Vec<(PersonCompanyLink, Company)> and function_title: Option<String> fields to PersonFormTemplate
  • Replace the single company <select> with:
    • A function_title text input (independent general role)
    • A "Company Links" section with a dynamic table: each row has company <select>, role text input, status <select> (Active/Former), and a Remove button
    • An "Add company link" button that clones a hidden template row via JavaScript
    • Hidden inputs named link_company_sid[], link_role[], link_status[] (array-style) for form submission
    • Pre-populate rows from existing_links

Files: crates/hero_biz_ui/src/web/handlers/mod.rs, crates/hero_biz_ui/src/web/server.rs
Dependencies: Steps 2, 3, 4
Subtasks:

  • Update persons_detail: load links via store.load_person_company_links(context, &id); for each link, load company; pass as linked_companies
  • Update companies_detail: load links via store.load_company_person_links(context, &id); for each link, load person; pass as linked_persons
  • Update person_new: load companies for dropdown; pass existing_links: vec![]
  • Update person_edit: load links for the person; load companies; pass both to template
  • Update PersonForm struct: add link_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 body
  • Update person_create/person_update: after saving person, parse link rows from form, delete existing links for this person (on update), save new links
  • Add async fn person_link_delete(State, Path((context, person_id, link_sid))): calls store.delete_person_company_link, redirects back to person detail
  • In server.rs, add route: .route("/c/:context/contacts/:id/links/:link_sid/delete", post(handlers::person_link_delete))

Acceptance Criteria

  • PersonCompanyLink model added with person_sid, company_sid, role, status
  • Person.function_title field added
  • Store CRUD: save, load by person, load by company, delete
  • Person detail page shows Linked Companies table with clickable company names and role/status
  • Company detail page shows Linked Contacts table with clickable person names and role/status
  • Person create/edit form supports adding multiple company links (dynamic rows)
  • Former links are preserved (not deleted when status=Former)
  • Link delete route works
  • cargo build passes with no clippy errors

Notes

  • No OSIS changes needed: links live in db_root local files. Person.company_sid is kept in the OSIS struct for backward compat but the UI ignores it after this change.
  • SID generation for links: use alphanumeric random 6-char sid (grep for existing generate_sid or similar in services, or use uuid::Uuid::new_v4().to_string()[..6]).
  • Form array fields: Axum's Form<T> extractor does not natively support field[] repeated keys. Parse link rows by extracting link_company_sid_N, link_role_N, link_status_N indexed fields, or use a custom extractor. The simplest approach: encode all rows as a JSON string in a hidden links_json input and parse on the server.
  • get_persons_for_company: update to union persons from company_sid filter AND from PersonCompanyLink to avoid breaking existing data.
## Implementation Spec for Issue #38 ### Objective Replace `Person.company_sid: Option<String>` (single link, no metadata) with a `PersonCompanyLink` join model stored as local JSON files under `db_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_sdk` does not have a `PersonCompanyLink` type 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_sid` is 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**: `PersonCompanyLink` and `LinkStatus` structs - `crates/hero_biz_ui/src/models/mod.rs` — add `pub mod person_company_link` and re-export - `crates/hero_biz_ui/src/models/person.rs` — add `function_title: Option<String>` field (local-only, not persisted to OSIS) - `crates/hero_biz_ui/src/services/mod.rs` — add CRUD methods for `PersonCompanyLink`; update `get_persons_for_company` to use links - `crates/hero_biz_ui/src/web/templates/mod.rs` — update `PersonDetailTemplate`, `CompanyDetailTemplate`, `PersonFormTemplate` - `crates/hero_biz_ui/src/web/handlers/mod.rs` — update `persons_detail`, `companies_detail`, `person_new`, `person_edit`, `person_create`, `person_update`; add `person_link_delete` handler; update `PersonForm` - `crates/hero_biz_ui/src/web/server.rs` — add route for link deletion ### Implementation Plan #### Step 1: Add `PersonCompanyLink` model **Files**: `crates/hero_biz_ui/src/models/person_company_link.rs`, `crates/hero_biz_ui/src/models/mod.rs` **Dependencies**: none **Subtasks**: - Create `person_company_link.rs` with: ```rust #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum LinkStatus { #[default] Active, Former } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PersonCompanyLink { pub sid: String, pub person_sid: String, pub company_sid: String, pub role: String, pub status: LinkStatus, } ``` - Add `pub mod person_company_link;` and `pub use person_company_link::{LinkStatus, PersonCompanyLink};` to `models/mod.rs` - Add `function_title: Option<String>` with `#[serde(skip_serializing_if = "Option::is_none", default)]` to `Person` struct in `models/person.rs` #### Step 2: Add store CRUD for `PersonCompanyLink` **Files**: `crates/hero_biz_ui/src/services/mod.rs` **Dependencies**: Step 1 **Subtasks**: - Add helper `link_dir(db_root, context) -> PathBuf` returning `{db_root}/{context}/person_company_links/` - Add `load_all_person_company_links(&self, context) -> Result<Vec<PersonCompanyLink>>`: reads all `*.json` files from link dir - Add `load_person_company_links(&self, context, person_sid) -> Result<Vec<PersonCompanyLink>>`: filters by `person_sid` - Add `load_company_person_links(&self, context, company_sid) -> Result<Vec<PersonCompanyLink>>`: filters by `company_sid` - Add `save_person_company_link(&self, context, link: &PersonCompanyLink) -> Result<String>`: generates sid if empty (use same `generate_sid()` pattern as elsewhere or `uuid`/short random), writes JSON file - Add `delete_person_company_link(&self, context, link_sid) -> Result<()>`: removes JSON file - Update `get_persons_for_company`: after filtering by `company_sid`, also include persons linked via `PersonCompanyLink`; deduplicate by sid #### Step 3: Update `PersonDetailTemplate` and `CompanyDetailTemplate` **Files**: `crates/hero_biz_ui/src/web/templates/mod.rs` **Dependencies**: Step 1 **Subtasks**: - Add `linked_companies: Vec<(PersonCompanyLink, Company)>` field to `PersonDetailTemplate` - In `PersonDetailTemplate::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 display - Add `linked_persons: Vec<(PersonCompanyLink, Person)>` field to `CompanyDetailTemplate`; keep existing `persons: Vec<Person>` (used by old code path) - In `CompanyDetailTemplate::render()`, add a "Linked Contacts" card with table: Person (link to `/c/{context}/contacts/{sid}`), Role, Status badge #### Step 4: Update `PersonFormTemplate` **Files**: `crates/hero_biz_ui/src/web/templates/mod.rs` **Dependencies**: Step 1 **Subtasks**: - Add `existing_links: Vec<(PersonCompanyLink, Company)>` and `function_title: Option<String>` fields to `PersonFormTemplate` - Replace the single company `<select>` with: - A `function_title` text input (independent general role) - A "Company Links" section with a dynamic table: each row has company `<select>`, role text input, status `<select>` (Active/Former), and a Remove button - An "Add company link" button that clones a hidden template row via JavaScript - Hidden inputs named `link_company_sid[]`, `link_role[]`, `link_status[]` (array-style) for form submission - Pre-populate rows from `existing_links` #### Step 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.rs` **Dependencies**: Steps 2, 3, 4 **Subtasks**: - Update `persons_detail`: load links via `store.load_person_company_links(context, &id)`; for each link, load company; pass as `linked_companies` - Update `companies_detail`: load links via `store.load_company_person_links(context, &id)`; for each link, load person; pass as `linked_persons` - Update `person_new`: load `companies` for dropdown; pass `existing_links: vec![]` - Update `person_edit`: load links for the person; load companies; pass both to template - Update `PersonForm` struct: add `link_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 body - Update `person_create`/`person_update`: after saving person, parse link rows from form, delete existing links for this person (on update), save new links - Add `async fn person_link_delete(State, Path((context, person_id, link_sid)))`: calls `store.delete_person_company_link`, redirects back to person detail - In `server.rs`, add route: `.route("/c/:context/contacts/:id/links/:link_sid/delete", post(handlers::person_link_delete))` ### Acceptance Criteria - [ ] `PersonCompanyLink` model added with `person_sid`, `company_sid`, `role`, `status` - [ ] `Person.function_title` field added - [ ] Store CRUD: save, load by person, load by company, delete - [ ] Person detail page shows Linked Companies table with clickable company names and role/status - [ ] Company detail page shows Linked Contacts table with clickable person names and role/status - [ ] Person create/edit form supports adding multiple company links (dynamic rows) - [ ] Former links are preserved (not deleted when status=Former) - [ ] Link delete route works - [ ] `cargo build` passes with no clippy errors ### Notes - **No OSIS changes needed**: links live in `db_root` local files. `Person.company_sid` is kept in the OSIS struct for backward compat but the UI ignores it after this change. - **SID generation for links**: use alphanumeric random 6-char sid (grep for existing `generate_sid` or similar in services, or use `uuid::Uuid::new_v4().to_string()[..6]`). - **Form array fields**: Axum's `Form<T>` extractor does not natively support `field[]` repeated keys. Parse link rows by extracting `link_company_sid_N`, `link_role_N`, `link_status_N` indexed fields, or use a custom extractor. The simplest approach: encode all rows as a JSON string in a hidden `links_json` input and parse on the server. - **`get_persons_for_company`**: update to union persons from `company_sid` filter AND from `PersonCompanyLink` to avoid breaking existing data.
Author
Member

Test Results

  • Total: 1
  • Passed: 1
  • Failed: 0

cargo test passed cleanly. cargo build completed with no errors and no clippy warnings.

## Test Results - Total: 1 - Passed: 1 - Failed: 0 `cargo test` passed cleanly. `cargo build` completed with no errors and no clippy warnings.
Author
Member

Implementation Summary

All changes implemented on branch development_casper.

Files created

  • crates/hero_biz_ui/src/models/person_company_link.rsPersonCompanyLink struct and LinkStatus enum (Active/Former)

Files modified

  • crates/hero_biz_ui/src/models/mod.rs — re-exports PersonCompanyLink and LinkStatus
  • crates/hero_biz_ui/src/models/person.rs — added function_title: Option<String> field
  • crates/hero_biz_ui/src/services/mod.rs — added load_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; updated get_persons_for_company to union link-table results
  • crates/hero_biz_ui/src/web/templates/mod.rsPersonDetailTemplate shows Linked Companies card; CompanyDetailTemplate shows Linked Contacts card; PersonFormTemplate has dynamic multi-link widget with add/remove rows
  • crates/hero_biz_ui/src/web/handlers/mod.rs — all detail/form/create/update handlers wired with real link data; person_link_delete handler added
  • crates/hero_biz_ui/src/web/server.rs — added route /c/:context/contacts/:id/links/:link_sid/delete

Architecture 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 to hero_osis_sdk were needed.

Acceptance criteria status

  • PersonCompanyLink model added with person_sid, company_sid, role, status
  • Person.function_title field added
  • Store CRUD: save, load by person, load by company, delete
  • Person detail page shows Linked Companies table with clickable company names and role/status
  • Company detail page shows Linked Contacts table with clickable person names and role/status
  • Person create/edit form supports adding multiple company links (dynamic rows via JS)
  • Former links preserved (status=former kept in store, not deleted)
  • Link delete route works
  • Build passes, no warnings
## Implementation Summary All changes implemented on branch `development_casper`. ### Files created - `crates/hero_biz_ui/src/models/person_company_link.rs` — `PersonCompanyLink` struct and `LinkStatus` enum (Active/Former) ### Files modified - `crates/hero_biz_ui/src/models/mod.rs` — re-exports `PersonCompanyLink` and `LinkStatus` - `crates/hero_biz_ui/src/models/person.rs` — added `function_title: Option<String>` field - `crates/hero_biz_ui/src/services/mod.rs` — added `load_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`; updated `get_persons_for_company` to union link-table results - `crates/hero_biz_ui/src/web/templates/mod.rs` — `PersonDetailTemplate` shows Linked Companies card; `CompanyDetailTemplate` shows Linked Contacts card; `PersonFormTemplate` has dynamic multi-link widget with add/remove rows - `crates/hero_biz_ui/src/web/handlers/mod.rs` — all detail/form/create/update handlers wired with real link data; `person_link_delete` handler added - `crates/hero_biz_ui/src/web/server.rs` — added route `/c/:context/contacts/:id/links/:link_sid/delete` ### Architecture 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 to `hero_osis_sdk` were needed. ### Acceptance criteria status - [x] `PersonCompanyLink` model added with `person_sid`, `company_sid`, `role`, `status` - [x] `Person.function_title` field added - [x] Store CRUD: save, load by person, load by company, delete - [x] Person detail page shows Linked Companies table with clickable company names and role/status - [x] Company detail page shows Linked Contacts table with clickable person names and role/status - [x] Person create/edit form supports adding multiple company links (dynamic rows via JS) - [x] Former links preserved (status=former kept in store, not deleted) - [x] Link delete route works - [x] Build passes, no warnings
mik-tf added this to the ACTIVE project 2026-05-06 17:32:02 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_biz#38
No description provided.