Docs / Admin
Managing organizations and contacts
Summary — Organizations and Contacts are the CRM core of the platform: every opportunity, submission, consultation, and match ultimately links back to one. Staff create and maintain them in Stakeholders, track individual leads through a status funnel, and "Qualify" a contact directly into a pipeline opportunity.
Purpose — Understand organization types and fields, contact creation and email-based deduplication, the lead-status funnel and the Qualify action, engagement/activity logging, and how the geo picker tags a location to a record.
Audience — Staff, reviewer, lead, and admin roles who maintain the partner and contact database.
Prerequisites — None. Organizations are usually the first record created for a new partner, ahead of submissions or opportunities.
Overview
Key concepts:
- Organization — A partner entity (chamber, embassy, government body, investor, etc.) with a type, optional cluster, country, and description. Lives in
organizations, modulestakeholders. - Contact — A person tied to an organization (or standalone), with a lead status funnel and optional "primary" flag. Lives in
contacts. - Lead status funnel —
new→contacted→qualified→converted, withunqualifiedas an off-ramp at any point. - Qualify — A one-click staff action that creates a pipeline opportunity from a contact and marks that contact
converted. - Engagement / Activity — A logged interaction (meeting, email, call, event, site visit, document, other) tied to an org and/or contact. The org detail page shows both a newer Activity Timeline (communications module) and a Legacy Notes list (the original
engagement_historytable/actions still described here). - Geo tagging — Philippines administrative hierarchy (Region → Province → Municipality → Barangay) linkable to organizations (and submissions, consultations, opportunities) via
entity_locations.
Screenshot: Organization detail page showing the tab bar (Overview, Contacts, Pipeline, Docs, Activity, Location) with the Qualification card and Investor Profile form on the Overview tab.
Organization types and fields
Path: Dashboard → Stakeholders → + Organization (/stakeholders/new)
| Field | Required | Notes |
|---|---|---|
| Name | ✓ | max 255 chars |
| Short Name | — | max 50 chars |
Type (orgType) | ✓ | combobox, see table below |
| Cluster | — | cluster_1 / cluster_2 / cluster_3, or "None" |
| Country | — | max 100 chars |
| Website | — | must be a valid URL if provided |
| Location | — | GeoSelect picker (region minimum level), linked via entity_locations after creation, non-blocking if it fails |
| Description | — | max 2000 chars |
Organization types (orgType)
| Value | Label |
|---|---|
chamber | Chamber |
embassy | Embassy |
development_partner | Development Partner |
government | Government |
private_sector | Private Sector |
academic | Academic |
ngo | NGO |
international_org | International Org |
investor | Investor |
other | Other |
On success: toast "Organization created successfully", redirect to /stakeholders/{id}. createOrganizationAction requires staff role and logs audit event organization.created.
Editing an organization
updateOrganizationAction (staff role) accepts a partial update of the same fields (updateOrganizationSchema is createOrganizationSchema.partial()) and logs organization.updated with the changed field names.
Archiving
archiveOrganizationAction requires lead role, soft-deletes (is_archived = true), logs organization.archived.
Organizations list
Path: Dashboard → Stakeholders (/stakeholders)
Server-paginated (50 per page), searchable (q, debounced 300ms), filterable by org type. Columns: Name (+ short name), Type, Cluster (short label), Country. Empty state: "No organizations found" with a link to add one.
Creating and finding contacts
Path: Dashboard → Contacts (/contacts) → New Contact, or from an organization's Contacts tab → + Add Contact (pre-fills organizationId)
| Field | Required | Notes |
|---|---|---|
| First Name | ✓ | max 100 chars |
| Last Name | — | max 100 chars |
| — | valid email format if provided | |
| Phone | — | max 30 chars |
| Organization | — | via OrgPicker (search existing or quick-create) |
| Title | — | e.g. "CEO, Director" |
| Role in Organization | — | e.g. "Decision maker" |
Deduplication
createContact (service layer) checks for an existing contact with the same email before inserting (repo.findContactByEmail). If found, createContactAction returns { status: "duplicate", existingId, existingName } instead of creating a new row — no new contact is written. This is exact-email matching only; there is no fuzzy name/org matching.
ponytail note in the code: dedupe is exact-email only by design, with a comment flagging that name+org fuzzy matching could be added later if staff report false negatives (missed duplicates without an email match). Not currently implemented.
On success: toast "Contact created", redirect to /contacts.
Contact record fields
Contacts also carry leadStatus (see funnel below), isPrimary (flags one contact as the organization's primary point of contact), and roleInOrg distinct from title.
Lead status and the Qualify action
Where: Organization detail page → Contacts tab, action buttons per row (QualifyButton component).
| Lead status | Meaning | Available actions on this row |
|---|---|---|
new | Just added, no outreach yet | Qualify, Contacted, Not Qualified |
contacted | Outreach made | Qualify, Not Qualified |
qualified | Assessed as a real opportunity | Create Opportunity (same Qualify action, relabeled) |
unqualified | Assessed as not viable | Re-qualify (same Qualify action, relabeled) |
converted | Already turned into an opportunity | No actions shown — button hidden entirely |
Qualify (qualifyContactAction, staff role):
- Loads the contact; throws "Contact already converted to opportunity" if
leadStatusis alreadyconverted. - Creates a pipeline opportunity titled
"{First} {Last} — Investment Opportunity", linked to the contact's organization, withsource: "contact_qualification". - Sets the contact's
leadStatustoconverted. - Logs audit event
contact.qualifiedwith the new opportunity id.
On success: toast "Contact qualified — opportunity created", redirect to /pipeline/{opportunityId}.
Marking Not Qualified or Contacted calls updateLeadStatusAction (staff role) and only changes leadStatus — no opportunity is created and no other side effects occur.
Archiving a contact
archiveContactAction requires lead role, soft-deletes, logs contact.archived.
Bulk import (CSV)
The stakeholders module exposes importStakeholderCsvAction (staff role): it uploads a CSV to storage and fires the Inngest event stakeholder/csv.uploaded to trigger the stakeholder-sync background workflow, which is expected to create org/contact records from the file.
Gap: no page or button in the current UI calls
importStakeholderCsvAction. The action and its backing workflow exist in the codebase, but bulk CSV import is not reachable from the workbench today — a dedicated guide is planned once it is wired up.
Engagement and activity history
Where: Organization detail page → Activity tab.
Two things render here:
- Activity Timeline (
ActivityTimelinecomponent, communications module) — the current, richer activity ledger, with a Log Activity modal (LogActivityModal) to add entries and anEngagementBadgesummary. - Legacy Notes — the original
engagement_historyrecords, shown only if any exist, in a card below the timeline.
The legacy logEngagement / logEngagementAction (staff role) still exists in the stakeholders module and accepts:
| Field | Required | Notes |
|---|---|---|
| Engagement Type | ✓ | meeting, email, call, event, site_visit, document, other |
| Title | ✓ | max 255 chars |
| Description | — | max 5000 chars |
| Occurred At | — | defaults server-side if omitted |
| Organization / Contact | — | at least context is usually supplied by the page |
Logging an entry writes to engagement_history and logs audit event engagement.logged. New engagement logging in the UI goes through the newer Activity Timeline / Log Activity modal (communications module) rather than this legacy path directly, but both read from data associated with the same organization.
Investor profile and qualification state
The Overview tab of an organization also shows an Investor Profile form (sectors of interest, investment types, target clusters, min/max investment USD, target geographies, counterparty type, investment size band, preferred instruments, stage preferences, notes) and, if a profile exists, a Qualification card with a status badge and a "Move to" dropdown of allowed next states. These belong to the matching module (getInvestorProfile, upsertInvestorProfileAction, getQualificationState, getAllowedQualificationTransitions, transitionQualificationAction) — see the matching/investor-qualification documentation (planned) for the state machine itself; this article covers only where it surfaces on the organization page.
Geo tagging (GeoSelect / LocationPicker)
Where used: Organization create form (GeoSelect, formFieldPrefix="location"), organization detail page Location tab (LocationDisplay + LocationPicker), the public /submit wizard (Target Location), and /register.
GeoSelect is a cascading combobox: Region → Province → Municipality → Barangay, backed by searchGeoAction / listRegionsAction / listProvincesAction / listMunicipalitiesAction / listBarangaysAction. minLevel="region" means selection can stop as high as region. On selection it returns { geoId, geoLevel, name, fullPath } (e.g., fullPath: "Cebu City, Cebu, Central Visayas (VII)").
Linking a location to an entity calls linkLocation({ entityType, entityId, geoId, geoLevel, isPrimary }), writing an entity_locations row and logging entity_location.linked. Supported entityType values: submission, organization, consultation, opportunity. Unlinking (unlinkLocation, requires a userId) logs entity_location.unlinked.
Note: on the public
/submitand/registerforms, geo linking runs withuserId: null(unauthenticated) and is wrapped in.catch(() => {})— if it fails, the submission/registration still succeeds silently without a location tag.
PSGC refresh
refreshPsgc(userId) exists as an admin-triggerable function that logs a psgc.refresh_attempted audit event, but its metadata explicitly notes: "PSA API integration not yet wired — using seed data." All region/province/municipality/barangay data currently comes from seed data, not a live PSA API sync.
Roles and permissions
| Role | Create org/contact | Edit org/contact | Archive | Qualify contact | Log engagement | Link/unlink geo |
|---|---|---|---|---|---|---|
| staff | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ |
| reviewer | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ |
| lead | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| public | ✗ (only via /register, which bypasses these actions) | ✗ | ✗ | ✗ | ✗ | ✗ (only via public forms, unauthenticated path) |
Archive requires lead or admin on both organizations and contacts, matching the pipeline module's convention.
Best practices
- Search the OrgPicker before creating a new organization. Duplicate organizations fragment contact lists, matching, and reporting. Only use "+ create" when the org genuinely doesn't exist.
- Always supply an email when creating a contact if you have one. Dedupe only works on exact email match — without an email, the system cannot catch a duplicate.
- Move contacts through the lead-status funnel deliberately. Use "Contacted" once outreach happens, "Not Qualified" to close out dead leads, and reserve "Qualify" for contacts you are ready to turn into a real opportunity — it immediately creates a pipeline record.
- Tag a location whenever you have one. Geo tags drive the Philippines map view and location-based reporting; they are optional but non-blocking, so there's no cost to adding them at creation time.
- Log engagement activity through the Activity tab's Log Activity modal, not just in opportunity or consultation notes — it is the shared CRM history visible from the organization record.
Warnings
Contact dedupe is exact-email only. Two contacts with the same name but different emails (or no email at all) will both be created — this is a known limitation, not a bug.
Qualify creates an opportunity immediately, with no undo. Clicking "Qualify" (or "Create Opportunity" / "Re-qualify") on a contact creates a real pipeline opportunity right away. There is no confirmation dialog.
A converted contact cannot be re-qualified. The Qualify button is hidden entirely once leadStatus = converted; attempting the action again (e.g., via a stale page) throws "Contact already converted to opportunity".
Bulk CSV import has no UI entry point yet. Do not assume staff can bulk-upload contacts today — only the underlying action and workflow event exist.
PSGC geo data is static seed data, not a live government sync. New barangays/municipalities will not appear automatically; refreshPsgc currently no-ops against the seed set.
Troubleshooting
"Contact already converted to opportunity"
Symptom: Clicking Qualify on a contact throws this error.
Cause: The contact's leadStatus is already converted (an opportunity already exists from this contact).
Fix: Find the existing opportunity via the organization's Pipeline tab rather than qualifying again.
Contact not created — no error, but nothing appears
Symptom: Submitting the New Contact form seems to succeed (toast: "Contact created") but a contact you expected to be new doesn't show as a separate row.
Cause: A contact with that email already existed; createContactAction returned a duplicate result and reused the existing contact instead of creating a new one.
Fix: Search the organization's Contacts tab for the existing entry with that email — this is expected dedupe behavior, not a bug.
Location doesn't show on the Location tab after creating an organization
Symptom: You selected a location on the New Organization form but the Location tab is empty.
Cause: linkLocation on the create form is wrapped so a failure doesn't block organization creation — the org still saves even if geo linking silently failed.
Fix: Open Location tab → use LocationPicker to add the location again directly on the saved organization.
FAQ
What's the difference between "Add Contact" from an org page and "New Contact" from /contacts?
Same form and same createContactAction underneath. The org-page link pre-fills organizationId via the URL query string; the standalone /contacts page leaves it blank unless you pick one manually in the OrgPicker.
Does Qualify require an organization on the contact?
No — qualifyContact passes organizationId: contact.organizationId ?? undefined, so a contact with no organization can still be qualified; the resulting opportunity simply has no linked organization.
Can I change which contact is "primary" for an organization?
Yes, via updateContactAction's isPrimary field (set from a contact's edit form). The UI shows a "Primary" badge next to the flagged contact in the Contacts tab table.
What happens to roleInOrg vs title — aren't they the same thing?
They're separate fields: title is the person's job title (e.g., "CEO"), roleInOrg describes their role relative to this engagement (e.g., "Decision maker"). Both are optional and independently editable.
Related articles
- Managing submissions — How converted submissions and public registrations create organizations and contacts.
- The investment pipeline lifecycle — What happens after a contact is qualified or a submission is converted.
- A dedicated guide for the matching module's investor qualification state machine is planned.