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, module stakeholders.
  • Contact — A person tied to an organization (or standalone), with a lead status funnel and optional "primary" flag. Lives in contacts.
  • Lead status funnelnewcontactedqualifiedconverted, with unqualified as 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_history table/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)

FieldRequiredNotes
Namemax 255 chars
Short Namemax 50 chars
Type (orgType)combobox, see table below
Clustercluster_1 / cluster_2 / cluster_3, or "None"
Countrymax 100 chars
Websitemust be a valid URL if provided
LocationGeoSelect picker (region minimum level), linked via entity_locations after creation, non-blocking if it fails
Descriptionmax 2000 chars

Organization types (orgType)

ValueLabel
chamberChamber
embassyEmbassy
development_partnerDevelopment Partner
governmentGovernment
private_sectorPrivate Sector
academicAcademic
ngoNGO
international_orgInternational Org
investorInvestor
otherOther

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)

FieldRequiredNotes
First Namemax 100 chars
Last Namemax 100 chars
Emailvalid email format if provided
Phonemax 30 chars
Organizationvia OrgPicker (search existing or quick-create)
Titlee.g. "CEO, Director"
Role in Organizatione.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 statusMeaningAvailable actions on this row
newJust added, no outreach yetQualify, Contacted, Not Qualified
contactedOutreach madeQualify, Not Qualified
qualifiedAssessed as a real opportunityCreate Opportunity (same Qualify action, relabeled)
unqualifiedAssessed as not viableRe-qualify (same Qualify action, relabeled)
convertedAlready turned into an opportunityNo actions shown — button hidden entirely

Qualify (qualifyContactAction, staff role):

  1. Loads the contact; throws "Contact already converted to opportunity" if leadStatus is already converted.
  2. Creates a pipeline opportunity titled "{First} {Last} — Investment Opportunity", linked to the contact's organization, with source: "contact_qualification".
  3. Sets the contact's leadStatus to converted.
  4. Logs audit event contact.qualified with 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:

  1. Activity Timeline (ActivityTimeline component, communications module) — the current, richer activity ledger, with a Log Activity modal (LogActivityModal) to add entries and an EngagementBadge summary.
  2. Legacy Notes — the original engagement_history records, 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:

FieldRequiredNotes
Engagement Typemeeting, email, call, event, site_visit, document, other
Titlemax 255 chars
Descriptionmax 5000 chars
Occurred Atdefaults server-side if omitted
Organization / Contactat 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 /submit and /register forms, geo linking runs with userId: 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

RoleCreate org/contactEdit org/contactArchiveQualify contactLog engagementLink/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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.