crm

package
v1.801.425 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 4, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package crm is your sales pipeline: the companies, the people, the deals in play.

Plus the Startup Program intake, which lands as a scored application.

The three core entities are faithful to Twenty's `company` / `person` / `opportunity` standard objects, with Twenty's composite fields (FULL_NAME, EMAILS, CURRENCY, LINKS, ADDRESS) flattened to scalar columns for SQLite.

A CRM contact is a PROSPECT the org tracks. It is NOT a product user: an org's own users live in Hanzo IAM and apps/marketing resolves audiences from that roster (roster.go), never from this table. The two contact universes are deliberate and do not join.

Tenant isolation is enforced SERVER-SIDE on every request: the org is the value SanitizeIdentity minted from the VALIDATED bearer owner claim (HIP-0026) — never a client-supplied header, and never a field of a request body. Every store query filters WHERE org=?, so one tenant can never read or mutate another's data.

Surface (all org-scoped; /v1 only):

GET    /v1/crm/summary               per-org row counts (companies/contacts/opps)
GET    /v1/crm/companies             list companies                 -> {data:[…]}
POST   /v1/crm/companies             create a company               -> Company (201)
GET    /v1/crm/companies/:id         company detail                 -> Company
PUT    /v1/crm/companies/:id         update a company               -> Company
DELETE /v1/crm/companies/:id         delete a company (+ clear refs)
GET    /v1/crm/contacts              list contacts (?companyId=)     -> {data:[…]}
POST   /v1/crm/contacts             create a contact               -> Contact (201)
GET    /v1/crm/contacts/:id          contact detail                 -> Contact
PUT    /v1/crm/contacts/:id          update a contact               -> Contact
DELETE /v1/crm/contacts/:id          delete a contact (+ clear refs)
GET    /v1/crm/opportunities         list opportunities (?stage=)    -> {data:[…]}
POST   /v1/crm/opportunities        create an opportunity          -> Opportunity (201)
GET    /v1/crm/opportunities/:id     opportunity detail             -> Opportunity
PUT    /v1/crm/opportunities/:id     update an opportunity          -> Opportunity
DELETE /v1/crm/opportunities/:id     delete an opportunity

Every route above is a TYPED op, so each is one registry entry with N projections — the REST route, the OpenAPI operation, the /mcp tool, the CLI command and the generated SDK method all follow from it. The Startup Program intake (POST /v1/crm/applications, applications.go) is the ONE route here that stays a raw handler, and says why at its registration.

Order 131: binds /v1/crm/* before the AI subsystem's /v1/* catch-all (150). serve.go auto-registers GET /v1/crm/health.

Index

Constants

View Source
const (
	StageApplied        = "applied"
	StageScreened       = "screened"
	StageQualified      = "qualified"
	StageCreditsOffered = "credits-offered"
	StageOnboarded      = "onboarded"
	StageRejected       = "rejected"
)

Startup pipeline stages, in order. `rejected` is an off-pipeline terminal.

Variables

This section is empty.

Functions

func Mount

func Mount(app cloud.Router, deps cloud.Deps) error

Mount wires the crm surface onto app per HIP-0106. Complex flavour: it keeps a package global (mounted) for Shutdown, so it constructs the Service value directly.

func Shutdown

func Shutdown() error

Shutdown closes the crm store. Idempotent.

Types

type Application

type Application struct {
	// ID is the server-minted application id ("appl_" + 128 random bits).
	ID string `json:"id"`
	// Org is the owning tenant — the program org, which is the deployment brand.
	// Never on the wire: it is the isolation key the server reads, not a field a
	// caller sends or reads.
	Org string `json:"-"`
	// Company is the applicant's company name.
	Company string `json:"company"`
	// Website is the applicant's website as submitted.
	Website string `json:"website"`
	// ContactName is the person who applied.
	ContactName string `json:"contactName"`
	// Email is the applicant's email — half of the (email, company) key a
	// resubmission refreshes instead of duplicating.
	Email string `json:"email"`
	// Role is the applicant's role at their company.
	Role string `json:"role"`
	// Stage is the pipeline stage: applied, screened, qualified, credits-offered,
	// onboarded or rejected. Server-owned — it starts at "applied" and moves only
	// through the transition machine.
	Stage string `json:"stage"`
	// Tier1 is whether the applicant is tier-1 backed, derived deterministically
	// at intake from the submitted fund list — independent of the AI screen.
	Tier1 bool `json:"tier1"`
	// Metadata is the FULL submitted form, every field, including the arrays the
	// promoted columns above do not carry (tier1Investors, useCases) and the
	// deterministic tier1Matched list.
	Metadata map[string]any `json:"metadata"`
	// Screen is the AI screen. It runs after intake, so a freshly created
	// application carries a "pending" screen.
	Screen ScreenResult `json:"screen"`
	// Events is the append-only stage-transition log, oldest first.
	Events []StageEvent `json:"events"`
	// CompanyID is the CRM Company minted for this lead at intake, so the startup
	// also appears in the org's standard CRM tabs. Empty when that best-effort
	// projection did not run.
	CompanyID string `json:"companyId"`
	// ContactID is the CRM Contact minted for this lead at intake. Empty when that
	// best-effort projection did not run.
	ContactID string `json:"contactId"`
	// Reason is why the application was rejected, required to reject. Empty
	// otherwise.
	Reason string `json:"reason"`
	// CreatedAt is the unix second the application arrived. Server-owned.
	CreatedAt int64 `json:"createdAt"`
	// UpdatedAt is the unix second of the last write. Server-owned.
	UpdatedAt int64 `json:"updatedAt"`
}

Application is one startup-program submission plus its AI screen and pipeline state. Metadata carries the FULL submitted payload (all form fields, including arrays like tier1Investors/useCases); the promoted columns are query/display projections. Tier1 is deterministically derived at intake from the submitted fund list (independent of the AI screen's judgement).

type Company

type Company struct {
	// ID is the server-minted company id ("comp_" + 128 random bits).
	ID string `json:"id"`
	// Org is the owning tenant. Never on the wire: it is the isolation key the
	// server reads from the validated principal, not a field a caller sends or reads.
	Org string `json:"-"`
	// Name is the company name.
	Name string `json:"name"`
	// DomainName is the company's primary domain, e.g. "acme.com".
	DomainName string `json:"domainName"`
	// Employees is the headcount.
	Employees int64 `json:"employees"`
	// City is the head-office city.
	City string `json:"city"`
	// Country is the head-office country.
	Country string `json:"country"`
	// ARR is annual recurring revenue in minor units (cents) of Currency.
	ARR int64 `json:"arr"`
	// Currency is the ISO code ARR is denominated in; a write that names none
	// stores USD.
	Currency string `json:"currency"`
	// ICP marks the company as an ideal-customer-profile fit.
	ICP bool `json:"idealCustomerProfile"`
	// Linkedin is the company's LinkedIn URL.
	Linkedin string `json:"linkedinLink"`
	// XLink is the company's X (Twitter) URL.
	XLink string `json:"xLink"`
	// CreatedAt is the unix second the company was created. Server-owned.
	CreatedAt int64 `json:"createdAt"`
	// UpdatedAt is the unix second of the last write. Server-owned.
	UpdatedAt int64 `json:"updatedAt"`
}

Company is an org-scoped account record, faithful to Twenty's `company` standard object (composites flattened to scalar columns for SQLite): ARR is minor units (cents) of Currency; ICP is the ideal-customer-profile flag.

type Contact

type Contact struct {
	// ID is the server-minted contact id ("cont_" + 128 random bits).
	ID string `json:"id"`
	// Org is the owning tenant. Never on the wire: it is the isolation key the
	// server reads from the validated principal, not a field a caller sends or reads.
	Org string `json:"-"`
	// FirstName is the person's given name.
	FirstName string `json:"firstName"`
	// LastName is the person's family name.
	LastName string `json:"lastName"`
	// Email is the person's email address.
	Email string `json:"email"`
	// Phone is the person's phone number.
	Phone string `json:"phone"`
	// JobTitle is the person's role at their company.
	JobTitle string `json:"jobTitle"`
	// City is where the person is based.
	City string `json:"city"`
	// CompanyID links the contact to one of the org's companies; empty when the
	// contact stands alone, and cleared when its company is deleted. A write
	// naming a company the org does not own is refused with 422.
	CompanyID string `json:"companyId"`
	// Linkedin is the person's LinkedIn URL.
	Linkedin string `json:"linkedinLink"`
	// XLink is the person's X (Twitter) URL.
	XLink string `json:"xLink"`
	// CreatedAt is the unix second the contact was created. Server-owned.
	CreatedAt int64 `json:"createdAt"`
	// UpdatedAt is the unix second of the last write. Server-owned.
	UpdatedAt int64 `json:"updatedAt"`
}

Contact is an org-scoped person record, faithful to Twenty's `person` standard object (FULL_NAME/EMAILS/PHONES composites flattened). CompanyID is an optional in-org relation to a Company.

type Opportunity

type Opportunity struct {
	// ID is the server-minted opportunity id ("oppo_" + 128 random bits).
	ID string `json:"id"`
	// Org is the owning tenant. Never on the wire: it is the isolation key the
	// server reads from the validated principal, not a field a caller sends or reads.
	Org string `json:"-"`
	// Name is the deal name.
	Name string `json:"name"`
	// Amount is the deal value in minor units (cents) of Currency.
	Amount int64 `json:"amount"`
	// Currency is the ISO code Amount is denominated in; a write that names none
	// stores USD.
	Currency string `json:"currency"`
	// Stage is the pipeline stage, always one of NEW, SCREENING, MEETING,
	// PROPOSAL or CUSTOMER — stored upper-case whatever case the write used.
	Stage string `json:"stage"`
	// CloseDate is the expected close as a unix second (0 = unset).
	CloseDate int64 `json:"closeDate"`
	// CompanyID links the deal to one of the org's companies; empty when
	// unlinked, and cleared when that company is deleted. A write naming a
	// company the org does not own is refused with 422.
	CompanyID string `json:"companyId"`
	// PointOfContact links the deal to one of the org's contacts; empty when
	// unlinked, and cleared when that contact is deleted. A write naming a
	// contact the org does not own is refused with 422.
	PointOfContact string `json:"pointOfContactId"`
	// CreatedAt is the unix second the opportunity was created. Server-owned.
	CreatedAt int64 `json:"createdAt"`
	// UpdatedAt is the unix second of the last write. Server-owned.
	UpdatedAt int64 `json:"updatedAt"`
}

Opportunity is an org-scoped deal record, faithful to Twenty's `opportunity` standard object. Amount is minor units (cents) of Currency; CloseDate is a unix second (0 == unset). Stage is validated against the default pipeline.

type ScreenResult

type ScreenResult struct {
	// Status is the screen's state: pending | done | failed.
	Status string `json:"status"`
	// Score is the model's 0..100 fit score, clamped to that range.
	Score int `json:"score"`
	// Tier1Backed is the model's read on tier-1 backing, normalized to
	// "yes", "no" or "unclear" (anything it cannot resolve reads "unclear").
	Tier1Backed string `json:"tier1Backed"`
	// SuggestedCredits is the recommended credit grant in USD, snapped to the
	// nearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.
	SuggestedCredits int `json:"suggestedCredits"`
	// Summary is the model's short assessment of the application.
	Summary string `json:"summary"`
	// DraftReply is a suggested email reply for staff to edit and send.
	DraftReply string `json:"draftReply"`
	// Model is the LLM the screen ran on.
	Model string `json:"model"`
	// ScreenedAt is the unix second the screen finished (0 while pending).
	ScreenedAt int64 `json:"screenedAt"`
	// Error says why a failed screen failed — no AI gateway configured, a gateway
	// error, or a reply that carried no parseable JSON. Absent on success.
	Error string `json:"error,omitempty"`
}

ScreenResult is the AI screen stored on an application. Status is pending → done | failed; a failed/absent screen never blocks intake.

type StageEvent

type StageEvent struct {
	// From is the stage moved out of; empty on the intake event that opens the log.
	From string `json:"from"`
	// To is the stage moved into.
	To string `json:"to"`
	// At is the unix second of the move.
	At int64 `json:"at"`
	// By is who moved it: "system" for intake and the AI auto-advance, else the
	// validated staff user id.
	By string `json:"by"`
	// Note is the free-text comment recorded with the move. Absent when none.
	Note string `json:"note,omitempty"`
}

StageEvent is one entry in an application's append-only stage-transition log.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the CRM database. ONE SQLite file — the system namespace's "crm" — holds every org's records; tenant isolation is the `org` column, enforced on EVERY query. This mirrors clients/prompts and clients/eval exactly (the ONE storage pattern). MaxOpenConns(1) serializes writes against the single-writer file.

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database. Idempotent-safe via sql.DB.

func (*Store) CountApplications

func (s *Store) CountApplications(ctx context.Context, org string) (int, error)

CountApplications returns the org's application count (overview cards).

func (*Store) Counts

func (s *Store) Counts(ctx context.Context, org string) (companies, contacts, opps int, err error)

Counts returns per-org row counts across the three entities — a real, non-fabricated summary for the CRM module's overview cards.

func (*Store) CreateApplication

func (s *Store) CreateApplication(ctx context.Context, a Application) (Application, error)

func (*Store) CreateCompany

func (s *Store) CreateCompany(ctx context.Context, c Company) (Company, error)

func (*Store) CreateContact

func (s *Store) CreateContact(ctx context.Context, c Contact) (Contact, error)

func (*Store) CreateOpportunity

func (s *Store) CreateOpportunity(ctx context.Context, o Opportunity) (Opportunity, error)

func (*Store) DeleteCompany

func (s *Store) DeleteCompany(ctx context.Context, org, id string) (bool, error)

DeleteCompany removes a company and NULLs any dangling contact/opportunity refs to it within the same org (no orphaned foreign refs). One transaction.

func (*Store) DeleteContact

func (s *Store) DeleteContact(ctx context.Context, org, id string) (bool, error)

DeleteContact removes a contact and clears any opportunity point-of-contact refs to it within the org. One transaction.

func (*Store) DeleteOpportunity

func (s *Store) DeleteOpportunity(ctx context.Context, org, id string) (bool, error)

func (*Store) FindApplicationByEmailCompany

func (s *Store) FindApplicationByEmailCompany(ctx context.Context, org, email, company string) (Application, error)

FindApplicationByEmailCompany returns the org's application matching a case-insensitive (email, company) pair, or errNotFound. Basis for idempotent intake (a resubmission updates rather than duplicates).

func (*Store) GetApplication

func (s *Store) GetApplication(ctx context.Context, org, id string) (Application, error)

func (*Store) GetCompany

func (s *Store) GetCompany(ctx context.Context, org, id string) (Company, error)

func (*Store) GetContact

func (s *Store) GetContact(ctx context.Context, org, id string) (Contact, error)

func (*Store) GetOpportunity

func (s *Store) GetOpportunity(ctx context.Context, org, id string) (Opportunity, error)

func (*Store) ListApplications

func (s *Store) ListApplications(ctx context.Context, org, stage string, limit int) ([]Application, error)

ListApplications lists an org's applications, optionally filtered by pipeline stage (stage=="" means all). Newest first.

func (*Store) ListCompanies

func (s *Store) ListCompanies(ctx context.Context, org string, limit int) ([]Company, error)

func (*Store) ListContacts

func (s *Store) ListContacts(ctx context.Context, org, companyID string, limit int) ([]Contact, error)

ListContacts lists the org's contacts, optionally filtered to one company (companyID=="" means all). Most-recently-updated first.

func (*Store) ListOpportunities

func (s *Store) ListOpportunities(ctx context.Context, org, stage string, limit int) ([]Opportunity, error)

ListOpportunities lists the org's opportunities, optionally filtered by stage (stage=="" means all). Most-recently-updated first.

func (*Store) UpdateApplication

func (s *Store) UpdateApplication(ctx context.Context, a Application) (Application, error)

UpdateApplication persists the mutable columns (stage, tier1, metadata, screen, events, links, reason). ID/Org/CreatedAt are immutable keys.

func (*Store) UpdateCompany

func (s *Store) UpdateCompany(ctx context.Context, c Company) (Company, error)

func (*Store) UpdateContact

func (s *Store) UpdateContact(ctx context.Context, c Contact) (Contact, error)

func (*Store) UpdateOpportunity

func (s *Store) UpdateOpportunity(ctx context.Context, o Opportunity) (Opportunity, error)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL