A minimal viable CRM (alpha / proof of concept) built to validate the core sales workflow loop: create a contact → attach them to a deal → log activity → move the deal through a pipeline.
git clone https://github.com/rkseattle/minicrm.git
cd minicrm
cp .env.example .env
# Edit .env: set ADMIN_PASSWORD and generate JWT_SECRET (see .env.example for instructions)
docker compose up -dOpen http://localhost:5173 — the admin account is created automatically on first boot.
To populate realistic demo data:
npm run seed:demo- Frontend: React (Vite), TanStack Query, React Router, Tailwind CSS
- Backend: Node.js + Express, REST API, TypeScript
- Database: PostgreSQL 16
- Validation: Zod (shared schemas used on both client and server)
- Auth: JWT stored in httpOnly cookies
- Infrastructure: Docker + Docker Compose
- Monorepo: npm workspaces (
/client,/server,/shared)
To run with source mounts and hot-reload instead of built images:
docker compose -f docker-compose.yml -f docker-compose.dev.yml upThe application will be available at:
- Client: http://localhost:5173
- Server API: http://localhost:3001
To develop without Docker:
npm installServer:
cp server/.env.example server/.env
# Edit server/.env with your local Postgres credentials
npm run dev --workspace=minicrm-serverClient:
npm run dev --workspace=minicrm-clientServer tests (requires a running Postgres instance):
cp .env.test.example .env.test
# Fill in DB_PASSWORD and JWT_SECRET — see comments in .env.test.example for generation instructions.
# Never commit .env.test — it is excluded by .gitignore.
npm test --workspace=minicrm-serverClient tests:
npm test --workspace=minicrm-client
# With coverage:
npm run test:coverage --workspace=minicrm-clientE2E tests (requires the app running and a minicrm_e2e database):
cd qa && env $(cat e2e/.env | grep -v '^#' | grep -v '^$' | xargs) npm run test -- --grep @functional
# Smoke tests only:
cd qa && env $(cat e2e/.env | grep -v '^#' | grep -v '^$' | xargs) npm run test -- --grep @smokeCopy qa/e2e/.env.example to qa/e2e/.env and fill in E2E_BASE_URL, E2E_ADMIN_EMAIL, and E2E_ADMIN_PASSWORD before running.
The E2E suite is built on Playwright with a custom framework layer designed for long-term resilience and maintainability. All specs are tagged @functional; a subset is additionally tagged @smoke.
Tests are organized in four layers:
Tests (qa/e2e/tests/apps/minicrm/functional/<domain>/)
↓ import behaviors, never raw locators
Behaviors (qa/e2e/behaviors/minicrm/)
↓ named async fns; compose Page Objects; return typed results; no assertions
Page Objects (qa/e2e/pages/minicrm/)
↓ encapsulate all UI interactions; use HealingLocators
HealingLocators (qa/e2e/framework/healing/)
↓ multi-strategy resilient element finders
Tests import from @apps/minicrm/fixtures.js rather than @playwright/test directly, which provides the custom fixtures alongside the standard ones.
HealingLocator resolves elements using a priority-ordered list of strategies. When the primary strategy fails, it automatically falls back through the list and records the event. This keeps tests green through routine UI refactors without requiring immediate selector updates.
Strategy priority order (highest to lowest):
testId—data-testidattribute (most resilient)role— ARIA role + namelabel— form label texttext— visible textcss— CSS selectorxpath— XPath expression (least resilient)
When all static strategies are exhausted and an intent string is provided, the locator optionally falls back to an AI-powered recovery step that generates a candidate selector from a scoped DOM snapshot (requires ANTHROPIC_API_KEY; disabled if not set).
Every heal event (primary strategy failed; fallback used) is recorded in memory per worker and flushed to per-shard JSON files at the end of a run. A post-CI merge step produces a single healing-report.json summarizing total heals, AI vs. static heals, and per-event detail. In CI this report is posted as a sticky PR comment so teams can spot selectors that are consistently brittle.
The TestDataManager fixture tracks every entity created during a test and deletes them in reverse order during teardown. Tests create entities via the REST API using helpers from qa/e2e/apps/minicrm/helpers.ts (e.g. createTestContact, createTestDeal) which automatically register the entity for cleanup. Pre-existing data is never touched.
Both are available as Playwright fixtures:
restClient— typed HTTP client wrapping Playwright'sAPIRequestContext; throwsRestClientErroron 4xx/5xx; supports Bearer, API Key, and Basic Auth strategies.grpcClient— wraps@grpc/grpc-jswith JSON serialization; supports unary calls and async-iterable server-streaming calls.
globalSetup.ts runs once before all workers. It logs in via the REST API and writes session state (cookies) to .auth/admin.json. All tests reuse this cached state, eliminating per-test browser login overhead. Tests that intentionally exercise unauthenticated flows opt out with test.use({ storageState: undefined }).
The qa/e2e/framework/ directory must contain zero application-domain references (no MiniCRM-specific strings, route paths, or domain terms). A CI step (check-framework-purity.sh) enforces this — the framework is designed to be dropped into any project unchanged.
E2E tests run in Phase 3 of the CI pipeline (after server and client unit tests pass). Each push triggers two parallel matrix dimensions:
| Dimension | Values |
|---|---|
| Project | desktop, mobile-web |
| Shard | 1, 2, 3, 4 |
That produces 8 concurrent runners, each with 4 Playwright workers — 32 parallel test slots per push. Per-shard artifacts (JUnit XML, blob reports, healing files) are collected and merged by an aggregation job. The merged JUnit results are posted to the GitHub Checks tab via dorny/test-reporter; the test summary and healing report are posted as sticky PR comments.
All pull requests are automatically reviewed by Claude Code via .github/workflows/claude-review.yml. The review runs on pull_request events (opened, synchronize, reopened) and posts inline comments and a summary review.
- Architecture violations — business logic in controllers or routes;
pool.query()calls outsideserver/src/services/ - Validation — missing Zod validation on request bodies; ORDER BY params not allowlist-validated before SQL interpolation
- Frontend — missing
data-testidattributes on interactable elements; hardcoded user-facing strings not wrapped int('key'); physical Tailwind directional classes instead of logical property utilities (RTL safety) - Security — missing
authenticatemiddleware; missingrequireRole('admin')on admin routes; PATCH/DELETE ownership not enforced;owner_idfrom request body instead ofreq.user.id - Documentation — new service functions missing JSDoc; async handlers not wrapped in
try/catchorasyncHandler; non-standard error response shape - Scope — any implementation touching out-of-scope features
- PR title — conventional commit prefix (
feat:,fix:,chore:,test:,docs:)
Style issues enforced by ESLint/Prettier, naming preferences, and correct-but-stylistically-different patterns.
Comments quote the specific code that violated a rule and explain the correct pattern. If the diff is clean, the review will say so briefly.
Push a new commit to the branch. The synchronize event re-triggers the review automatically.
.github/workflows/claude-review-autofix.yml listens for review comments from github-actions[bot] (the actor used by the review workflow) and runs Claude Code on the branch to address the feedback automatically, then pushes a fix commit.
/client React + Vite frontend
/server Express API server
/shared Zod schemas shared between client and server
/db PostgreSQL migration files (node-pg-migrate)
| Directory | Purpose |
|---|---|
server/src/routes/ |
Route definitions only (no logic) |
server/src/controllers/ |
Request/response shaping |
server/src/services/ |
Business logic and database queries |
server/src/middleware/ |
JWT auth, role gating |
| Directory | Purpose |
|---|---|
client/src/api/ |
Axios wrappers, one file per resource |
client/src/pages/ |
Full page components |
client/src/components/ |
Reusable UI components |
Migrations run automatically on server startup. To run manually:
npm run migrate --workspace=minicrm-serverSeed realistic demo data into any MiniCRM environment for local development or live demos:
# Insert demo data (idempotent — safe to check before running)
npm run seed:demo
# Preview what would be inserted without writing to the DB
npm run seed:demo -- --dry-run
# Remove all demo data (leaves real data untouched)
npm run remove:demoBoth scripts read database connection from .env (DB_USER, DB_PASSWORD, DB_NAME, DB_HOST, DB_PORT).
Demo data volume:
- 2 accounts (Acme Corporation, Globex Industries)
- 20 contacts (10 per account)
- 10 deals across Prospecting, Qualification, Proposal, Negotiation, Closed Won, and Closed Lost stages
- 15 activities (calls, emails, notes, meetings, tasks) linked to deals and contacts
All demo records have is_demo = true. The remove script deletes only rows where is_demo = true, so real data is never affected. Running the seed script twice is a no-op if demo data already exists.
- Email/password login and logout
- Admin can invite users (generates a set-password link)
- Admin can set a user's password directly from the Users page (no email invite required); the user is prompted to change it on their next login
- Users prompted to change their password are redirected to
/change-passwordimmediately after login - Admin can assign roles (admin / rep) and deactivate / reactivate users
- JWT stored in httpOnly cookie; sessions expire after 8 hours
- Password requirements: at least 8 characters, at least one letter, and at least one number (validated on both client and server via shared Zod schema)
- Database migration:
007_add_must_change_password.jsaddsmust_change_passwordboolean column tousers
- Full CRUD for lead records with required fields (
first_name,email) and optional fields (last_name,phone,company_name,lead_source,notes) - Lead status lifecycle:
New → Contacted → Qualified → Disqualified; inline status update from list view with color-coded badges - Optional disqualification reason stored when status is set to
Disqualified; disqualified leads hidden by default with a "Show disqualified" toggle - Status change history recorded in
lead_status_historyand displayed on the lead detail page - Atomic lead conversion: "Convert Lead" button creates a contact, account, and deal in a single transaction; converted leads hidden by default with a "Show converted" toggle
- Conversion modal prefills contact and deal fields from the lead; supports creating a new account or linking an existing one via typeahead search
- "Converted from lead" back-reference banner shown on the created contact and deal detail pages
- Duplicate email warning on create (matching contact behavior); rep can dismiss or create anyway
- Full CRUD REST API at
/api/leads; conversion endpoint atPOST /api/leads/:id/convert; status history atGET /api/leads/:id/status-history - Database migrations:
020_create_leads.jsaddsleadsandlead_status_historytables; addssource_lead_idFK column tocontactsanddeals
- List all contacts in a sortable table with owner column
- Create, edit, and delete contacts via inline forms
- Contact detail page with full field display including resolved owner name
- Search contacts by name or email via the search input (passes
?search=<text>to the API; case-insensitive substring match acrossfirst_name,last_name, andemail) - Search contacts by linked account name via the account search input (
?accountSearch=<text>) - Filter contacts by owner (all vs. mine) via
?owner=mequery parameter - Owner defaults to the creating user; can be reassigned to any active user from the edit form
- Duplicate email detection on create: returns a persistent inline warning banner with a link to the existing contact; rep can still proceed by clicking "Create anyway"
- Address fields:
address_line1,address_line2,city,state_region,postal_code,country— collapsible section in the form, displayed on detail page when populated - Social profile URLs:
linkedin_url,twitter_x_url— collapsible section in the form, displayed as clickable links on detail page when populated - Contact merge: merge two contact records into one via
POST /api/contacts/:id/merge; winner survives, loser is deleted; per-field value choices; activities and deal links re-routed to winner; merged audit entry written - Full CRUD REST API at
/api/contacts; merge endpoint:POST /api/contacts/:id/merge
- List all accounts in a sortable table with owner column
- Create, edit, and delete accounts via inline forms
- Account detail page with full field display including resolved owner name
- Search accounts by company name via the search input (passes
?search=<text>to the API; case-insensitive substring match onname) - Filter accounts by industry via
?industry=<text> - Filter accounts by account type via
?accountType=<type> - Filter accounts by owner (all vs. mine) via
?owner=mequery parameter - Owner defaults to the creating user; can be reassigned to any active user from the edit form
- Account type field:
Prospect,Customer,Partner,Vendor,Competitor,Other— displayed as badge on list and detail pages - Parent account relationship: accounts may have a parent account; circular chain detection prevents invalid hierarchies; subsidiary accounts listed on parent detail page
- Type-ahead parent account search in the edit form
- Linked contacts listed on the account detail page
- Full CRUD REST API at
/api/accounts; additional endpoints:GET /api/accounts/search,GET /api/accounts/:id/children
- List all deals in a table with stage, value, close date, linked account, and owner columns
- Create, edit, and delete deals via inline forms
- Deal detail page with full field display including resolved owner name and linked account
- Filter deals by owner (all vs. mine) via
?owner=mequery parameter - Owner defaults to the creating user; can be reassigned to any active user from the edit form
- Linked contacts listed on the deal detail page (populated via
deal_contactsjoin table) - Pipeline stages (fixed): Prospecting → Qualification → Proposal → Negotiation → Closed Won / Closed Lost
- Full CRUD REST API at
/api/deals - Database migrations:
004_create_deals.js,005_create_deal_contacts.js
- Unified activity model with types: Note, Call, Email, Meeting, Task
- Activities can be attached to a contact, account, or deal (at least one required)
- Activity type auto-defaults to "Task" when a due date is provided; "Note" otherwise
- Task completion — mark a task as complete from the timeline; completed tasks are visually distinguished (strikethrough subject, "Complete" badge)
- Edit and delete activities from the timeline (owners and admins only)
ActivityTimelineis a shared component embedded in Contact, Account, and Deal detail pages- Full CRUD REST API at
/api/activitieswith?contact,?account,?deal, and?owner=mefilter support - Database migration:
006_create_activities.js
- Call and Email activities support two additional fields: direction (Inbound / Outbound, required) and outcome (free text, optional)
- The
ActivityFormconditionally shows direction and outcome fields when the selected type is Call or Email; direction is required before the form can be submitted - The
ActivityTimelinedisplays the direction label below the type badge and the outcome text in the card body - Database migration:
010_add_communication_fields_to_activities.js(addsdirection activity_directionandoutcome textcolumns, both nullable)
- Dedicated
/taskspage (linked in the nav bar as My Tasks) listing all Task-type activities owned by the current user - Tasks sorted by due date ascending (no due date appears last)
- Overdue tasks (past due date, still open) show the due date in red with an "Overdue" badge
- Each row shows subject, type badge, due date, and the name of the linked record (contact, account, or deal) as a clickable link
- User can mark any open task complete directly from the list — no navigation to the parent record needed
- Completed tasks are hidden by default; a Show completed toggle reveals them
- API endpoint:
GET /api/activities/my-tasks— returns Task-type activities for the authenticated user, withlinked_record_nameandlinked_record_typefields joined from the parent record
- New
/admin/settingsroute and Admin Settings nav link (visible to admins only) - Admin can set a system-wide default language from a dropdown populated with all supported locales
- Selected default persists across restarts via the
system_settingstable (key/value store) - API endpoints:
GET /api/settings/default-language— public, returns{ language }(used on app load)PATCH /api/settings/default-language— admin only, body{ language }, returns{ language }
- Shared Zod schema
settingsSchema.tsin/shared/schemas/definesSUPPORTED_LOCALESand the request/response schemas; locale display names are stored in the i18n translation files undersettings.languages.* - Database migration:
008_create_system_settings.jscreates thesystem_settingstable and seeds the default row (default_language = 'en')
- Admin can choose between three navigation layouts from the Admin Settings page: Top Nav (tab bar, default), Left Nav (collapsible sidebar), and Hamburger Menu (icon-triggered overlay)
- The selected layout is stored in the
system_settingstable (nav_layoutkey) and applies immediately to all users without a page reload - API endpoints:
GET /api/settings/nav-layout— public, returns{ layout }(used on app load)PATCH /api/settings/nav-layout— admin only, body{ layout }, returns{ layout }
- The active layout is distributed via
NavLayoutContext/NavLayoutProvider; page components use<NavBar />without knowing which layout is active - Each layout is a self-contained React component (
NavTop,NavLeft,NavHamburger) withdata-testidattributes following thenav-{layout}-{destination}convention (e.g.nav-top-contacts,nav-left-deals,nav-hamburger-tasks) - Database migration:
014_add_nav_layout_setting.jsseeds thenav_layout = 'top'default row
- Overdue task digest: A daily cron job runs at 08:00 server time. For each active user who has opted in to overdue task notifications, it sends one HTML email listing all open Tasks past their due date that have not previously been notified. Deduplication is tracked in the
overdue_task_notificationstable — each task is notified at most once. - Assignment notifications: When a contact, account, or deal is reassigned, the new owner receives an email if they have assignments notifications enabled. Multiple assignment events within a 2-minute window are batched into a single email per recipient.
- User notification preferences: Every authenticated user can configure three per-category toggles on the
/profilepage: overdue task digests, assignment notifications, and deal stage change notifications. Admins additionally have a global kill switch on the Admin Settings page that suppresses all notification emails regardless of individual preferences. - API endpoints:
GET /api/users/me/notification-preferences— auth required, returns{ preferences: { notify_overdue_tasks, notify_assignments, notify_deal_stage_changes } }PATCH /api/users/me/notification-preferences— auth required, body with any subset of the three boolean fieldsGET /api/users/notification-recipient-count— admin only, returns{ count }(active users with any notification pref enabled)GET /api/settings/email-notifications— auth required, returns{ enabled: boolean }PATCH /api/settings/email-notifications— admin only, body{ enabled: boolean }
- Database migrations:
016_add_notification_prefs_to_users.jsadds three boolean columns tousers;017_create_overdue_task_notifications.jscreates the dedup table and seeds theemail_notifications_enabledsystem setting - Email transport uses nodemailer with SMTP env vars (
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASS); in development and test environments emails are stubbed (logged to console rather than sent)
- Admin Settings includes a Pipeline Stages section where admins can add, rename, reorder (up/down buttons), and delete custom pipeline stages with per-stage default probability percentages
- Closed Won and Closed Lost are always present, always last, and cannot be renamed or deleted (marked
is_fixed = truein the DB) - Deleting a stage is blocked if any open (non-terminal) deals are currently in that stage — the admin sees the deal count and must move those deals first
- Stage names must be unique (case-insensitive); blank names are rejected
- Renaming a stage atomically updates all deals in the old stage name in the same DB transaction — no deal records are orphaned
- Pipeline stage definitions are stored in the new
pipeline_stagestable (migration021_create_pipeline_stages.js); thedeals_stage_checkconstraint is removed in the same migration - The client fetches the live stage list on app startup via
GET /api/settings/pipeline-stages(public endpoint) and caches it for 5 minutes via React Query (PIPELINE_STAGES_QUERY_KEY) - All stage selectors (
DealForm,DealCard,AutomationRulesPage) and board columns (DealsPage,StageColumn) now consume the live stage list via theusePipelineStageshook instead of the hardcoded constant - Custom stage names are displayed using their raw name; built-in stage names still use i18n translation keys
- API endpoints:
GET /api/settings/pipeline-stages— public, returns{ stages: PipelineStageResponse[] }in sort orderPOST /api/settings/pipeline-stages— admin only, body{ name, sort_order, probability? }, returns the new stagePATCH /api/settings/pipeline-stages/:id— admin only, body{ name?, sort_order?, probability? }, returns updated stage; fixed stages reject name changes with 403DELETE /api/settings/pipeline-stages/:id— admin only, returns{ id }on success; 409 if open deals exist
- Database migration:
021_create_pipeline_stages.jscreates thepipeline_stagestable, seeds the six default stages, and drops the hardcodeddeals_stage_checkconstraint
- Each deal now stores an ISO 4217 currency code (
USD,EUR,GBP,CAD,AUD,JPY,CHF) alongside its value - The Deal Form includes a currency selector; new deals default to the system-wide default currency (see below)
- Deal cards and deal detail pages format the value with the correct currency symbol and locale-appropriate number format via
Intl.NumberFormat - Pipeline board stage columns and dashboard summary cards detect mixed currencies in a stage: when deals with different currencies are present, totals are replaced with "Multiple currencies — values not summed" to avoid misleading cross-currency arithmetic
- Win/loss report similarly shows the mixed-currency note when the filtered deals span multiple currencies
- Admin can set a system-wide default currency on the Admin Settings page; new deals without an explicit currency pick up the system default at creation time
- CSV export includes a
Currencycolumn alongsideValue - API endpoints:
GET /api/settings/default-currency— public, returns{ currency }(used at deal creation time); cached client-side for 5 minutes viaDEFAULT_CURRENCY_QUERY_KEYPATCH /api/settings/default-currency— admin only, body{ currency }, returns{ currency }
- Database migration:
031_add_currency_to_deals.jsaddscurrency VARCHAR(3) NOT NULL DEFAULT 'USD'to thedealstable
- Tags are freeform labels that can be attached to any contact, account, or deal
- Tag input on detail pages (
ContactDetailPage,AccountDetailPage,DealDetailPage): type to create or search existing tags, press Enter or comma to confirm, × to remove; matching existing tags appear as a suggestion dropdown (combobox pattern, keyboard-accessible) - Tag badges on list views (
ContactsPage,AccountsPage, andDealsPagelist view): compact#tag-namebadges rendered alongside each row; mobile card views also show badges - Tag filter on list views: a dropdown lets users filter to records tagged with one or more tags (any-match); active filters appear as removable badge chips; resets to page 1 on change
- Admin Tags page at
/admin/tags: admins can rename or delete tags globally; accessible from admin nav - Tag names are stored lowercase; creating a tag that already exists (case-insensitive) returns the existing tag (idempotent upsert)
- Filter uses
EXISTSsubquery in SQL — does not break existing queries when no tags are selected - List responses embed tags via
JSON_AGGlateral subquery to avoid N+1 queries - Database migration:
032_create_tags.jscreatestags,contact_tags,account_tags, anddeal_tagstables; junction tables have composite PKs andON DELETE CASCADE - API endpoints:
GET /api/tags— list all tagsPOST /api/tags— create a tag (name required)GET /api/tags/:id— get a tag by IDPATCH /api/tags/:id— rename a tag (admin only)DELETE /api/tags/:id— delete a tag and all its junction rows (admin only)GET /api/contacts/:id/tags— list tags attached to a contactPOST /api/contacts/:id/tags— attach a tag by name (creates tag if new)DELETE /api/contacts/:id/tags/:tagId— detach a tag from a contact- Same pattern applies to
/api/accounts/:id/tagsand/api/deals/:id/tags
- Any authenticated user can set a personal preferred language from the Profile page (
/profile) or by using the language dropdown in the nav bar - Personal preference overrides the system-wide default at all times; setting it to "Use system default" clears the preference and falls back to the admin-configured default
- The language dropdown in the nav bar now persists the selection to the server (previously session-only)
- On login, the user's stored preference is returned with the
/api/auth/meresponse and applied immediately — no language flash - API endpoints:
GET /api/users/me/language— auth required, returns{ language: SupportedLocale | null }PATCH /api/users/me/language— auth required, body{ language: SupportedLocale | null }, returns{ language }
- Database migration:
009_add_user_preferred_language.jsadds the nullablepreferred_languagecolumn to theuserstable
- Stat cards on the dashboard home page: overdue tasks, tasks due today, open deal count, and total open pipeline value
- Overdue task count is clickable and navigates to My Tasks pre-filtered to show only overdue tasks (
/my-tasks?filter=overdue) - Per-stage breakdown table showing open deal count and total value for each active pipeline stage (Closed Won / Closed Lost excluded)
- Admins see team-wide metrics; reps see their own data only — enforced server-side
- Data is always fresh on page load (React Query
staleTime: 0) - API endpoint:
GET /api/dashboard/summary— returns{ overdueTasks, tasksDueToday, openDealCount, openPipelineValue, stageBreakdown }(auth required)
- Admin-only page at
/admin/automation, accessible from the Automation nav link (visible to admins only) - Admins can create automation rules that pair a trigger with an action
- Triggers: Deal stage changes to a specific stage, Deal is created, Contact is created
- Actions: Create task (with configurable subject, type, assignee, and due date offset in days), Send notification (logs message to the server)
- Rules have a name and an enable/disable toggle; disabled rules do not fire
- Rule execution is synchronous — fires inline after the triggering database operation
- For
create_taskactions, the assignee can be the record owner or a specific user - A logs drawer shows the 20 most recent executions per rule: timestamp, triggering record, and outcome (Success / Error with error message on failure)
- API endpoints (admin-only):
GET /api/automation/rules— list all rulesPOST /api/automation/rules— create a ruleGET /api/automation/rules/:id— get a rulePATCH /api/automation/rules/:id— update a rule (supports partial updates including togglingenabled)DELETE /api/automation/rules/:id— delete a rule and its logs (CASCADE)GET /api/automation/rules/:id/logs— 20 most recent execution logs
- Database migrations:
011_create_automation_rules.js,012_create_automation_rule_logs.js - Shared Zod schemas in
shared/schemas/automationSchema.ts
- Admin-only report page at
/reports/win-loss, accessible from the navigation bar - Displays Closed Won count and total value, Closed Lost count and total value, and win rate (Won / Total Closed) for a selected date range
- Date range defaults to the current month; presets for "this quarter" and a custom date range are also available
- Admins can filter the report by owner (rep); reps always see only their own deals
- Loss reason breakdown table shows top loss reasons by count when loss reasons were captured
- Report data is filtered by
close_date(notcreated_at) - API endpoint:
GET /api/reports/win-loss?start=YYYY-MM-DD&end=YYYY-MM-DD[&owner_id=UUID]— returns{ wonCount, wonValue, lostCount, lostValue, winRate, lossReasonBreakdown }(auth required)
- Admin-only report page at
/reports/activity-volume, accessible from the navigation bar - Displays activity counts broken down by type (Note, Call, Email, Meeting, Task) and by rep for a selected date range
- Date range defaults to the current month; presets for "this week", "this quarter", and a custom date range are also available
- Admins can filter by owner (rep); reps always see only their own activity
- CSV export available for the full report dataset
- API endpoint:
GET /api/reports/activity-volume?start=YYYY-MM-DD&end=YYYY-MM-DD[&owner_id=UUID]— returns activity counts grouped by type and owner (auth required)
- Unified search bar available in the nav bar searches across contacts, accounts, deals, and leads simultaneously
- Case-insensitive partial-word matching; returns up to 10 results per entity type
- Ownership-aware: admins see results from all records; reps see only records they own
- Results panel shows entity type label and key fields (e.g. name, email, stage); clicking a result navigates to the detail page
- Results panel is capped in height with long text truncated to keep the UI compact
- API endpoint:
GET /api/search?q=<term>— returns{ contacts, accounts, deals, leads }arrays (auth required)
- Every contact and account has a single
owner_idthat defaults to the creating user - Owner is displayed as a resolved name (not UUID) in list and detail views
- Active users are fetched from
GET /api/users/active(auth required, no admin role needed) - Owner can be changed from the record's edit form; change is reflected immediately without page reload
The REST API is documented using OpenAPI 3.0 annotations in the Express route files, generated by swagger-jsdoc, and served via Swagger UI.
Swagger UI is available only in development and staging (disabled in production):
http://localhost:3001/api-docs
The raw OpenAPI JSON spec is available at:
http://localhost:3001/api-docs.json
All request/response schemas are defined in server/src/swagger.ts as componentSchemas, derived directly from the shared Zod schemas in /shared/schemas/. This means schemas are maintained in one place — the Zod definitions — and referenced via $ref in the OpenAPI annotations.
A CI step validates that the generated spec is well-formed on every pull request:
# Runs locally as:
npm run lint:api --workspace=minicrm-serverThis generates server/openapi.json from the annotations and runs @redocly/cli lint against it. The lint fails if annotations are malformed or reference undefined schemas.
eslint-plugin-jsdoc is also configured to require JSDoc on all route handlers in server/src/routes/, ensuring every endpoint has a documentation block.
npm run generate-spec --workspace=minicrm-server
# Writes: server/openapi.json- Two roles:
adminandrep - Sessions expire after 8 hours of inactivity
- JWT stored in httpOnly cookie
Prospecting → Qualification → Proposal → Negotiation → Closed Won / Closed Lost
All user-facing text supports English, Mandarin Chinese Simplified (zh-Hans), Spanish, French, and German via i18next.
Supported locale codes (BCP 47): en, zh-Hans, es, fr, de
The active language is resolved in this order (highest precedence first):
- User's stored personal preference (set via Profile Settings or the nav bar language dropdown)
- System-wide default set by an admin via Admin Settings
- English (hard-coded fallback)
The document dir attribute is updated automatically when the language changes. All layout classes in the client use Tailwind logical property utilities (ps-/pe-, ms-/me-, text-start/text-end, rounded-s-/rounded-e-, etc.) so layout mirrors correctly under dir="rtl". To add an RTL locale (e.g. ar), add the locale to SUPPORTED_LOCALES in shared/schemas/settingsSchema.ts and to the RTL_LOCALES set in client/src/i18n.ts. Any new UI work must use logical property utilities — physical directional classes (pl-, pr-, ml-, mr-, text-left, text-right, etc.) are not permitted.
Pipeline stage names and currency values are formatted using the active locale (Intl.NumberFormat with style: 'currency'). i18n keys for pipeline stages use camelCase (e.g. pipeline.stages.closedWon) to remain compatible with TMS static-extraction tooling.
Contributions are welcome. For small fixes, open a pull request directly. For larger changes — new features, architectural changes, or anything that touches the E2E framework — please open a discussion first so the approach can be agreed on before work begins.
All commits must follow Conventional Commits
(feat:, fix:, chore:, docs:, test:). Pull requests are automatically reviewed
by Claude Code and must pass CI before merging.
MIT — see LICENSE.



