From 9dc080ddecbf0a5aef3e8f1ff61d4a2f65bfc69e Mon Sep 17 00:00:00 2001 From: Ashwin Date: Sun, 2 Aug 2026 08:49:06 +0530 Subject: [PATCH] feat(packs): introduce vertical pack functionality with sample data management; add is_sample field to Account and Case models, implement sample data logging, and create migration scripts for new features --- .../migrations/0008_account_is_sample.py | 18 + backend/accounts/models.py | 3 +- .../cases/migrations/0026_case_is_sample.py | 18 + backend/cases/models.py | 3 +- backend/common/audit_log.py | 20 + backend/common/base.py | 14 + backend/common/middleware/rls_context.py | 23 + .../common/migrations/0032_vertical_packs.py | 68 ++ .../0033_alter_securityauditlog_event_type.py | 36 + .../migrations/0034_rls_pipeline_tables.py | 86 +++ backend/common/models.py | 44 ++ backend/common/packs/__init__.py | 4 + backend/common/packs/applier.py | 639 ++++++++++++++++++ backend/common/packs/loader.py | 39 ++ backend/common/packs/schema.py | 482 +++++++++++++ backend/common/rls/__init__.py | 11 + backend/common/serializer.py | 22 +- backend/common/tests/test_custom_fields.py | 32 + backend/common/tests/test_organizations.py | 80 +++ backend/common/tests/test_packs_api.py | 149 ++++ backend/common/tests/test_packs_applier.py | 160 +++++ backend/common/tests/test_packs_content.py | 88 +++ backend/common/tests/test_packs_loader.py | 112 +++ backend/common/tests/test_packs_models.py | 28 + .../common/tests/test_packs_sample_data.py | 362 ++++++++++ .../common/tests/test_rls_pipeline_tables.py | 88 +++ backend/common/urls.py | 7 + backend/common/views/custom_field_views.py | 13 +- backend/common/views/pack_views.py | 89 +++ .../migrations/0013_contact_is_sample.py | 18 + backend/contacts/models.py | 3 +- backend/leads/admin.py | 10 +- .../leads/migrations/0014_lead_is_sample.py | 16 + .../migrations/0015_alter_lead_is_sample.py | 18 + backend/leads/models.py | 3 +- backend/leads/serializer.py | 7 + backend/leads/tests/test_leads_api.py | 47 ++ .../migrations/0014_opportunity_is_sample.py | 18 + backend/opportunity/models.py | 3 +- backend/packs/education-admissions.json | 324 +++++++++ backend/packs/professional-services.json | 323 +++++++++ backend/packs/real-estate.json | 294 ++++++++ .../tasks/migrations/0012_task_is_sample.py | 18 + backend/tasks/models.py | 3 +- frontend/src/hooks.server.js | 7 +- frontend/src/lib/server/log-safe.js | 39 ++ frontend/src/lib/server/packs.js | 94 +++ .../src/lib/server/v2/lead-custom-fields.js | 173 +++++ frontend/src/lib/server/v2/leads.js | 29 + frontend/src/lib/server/v2/organization.js | 21 + frontend/src/lib/terminology.js | 26 + frontend/src/lib/v2/components/Sidebar.svelte | 47 +- frontend/src/routes/(app)/+layout.server.js | 43 +- frontend/src/routes/(app)/+layout.svelte | 4 +- frontend/src/routes/(app)/leads/+page.svelte | 18 +- .../src/routes/(app)/leads/[id]/+page.svelte | 55 +- .../(app)/leads/[id]/edit/+page.server.js | 17 + .../routes/(app)/leads/[id]/edit/+page.svelte | 85 +++ .../settings/organization/+page.server.js | 63 +- .../(app)/settings/organization/+page.svelte | 246 ++++++- .../routes/(no-layout)/login/+page.server.js | 12 +- .../routes/(no-layout)/org/+page.server.js | 6 +- .../(no-layout)/org/new/+page.server.js | 95 ++- .../routes/(no-layout)/org/new/+page.svelte | 88 ++- 64 files changed, 4958 insertions(+), 53 deletions(-) create mode 100644 backend/accounts/migrations/0008_account_is_sample.py create mode 100644 backend/cases/migrations/0026_case_is_sample.py create mode 100644 backend/common/migrations/0032_vertical_packs.py create mode 100644 backend/common/migrations/0033_alter_securityauditlog_event_type.py create mode 100644 backend/common/migrations/0034_rls_pipeline_tables.py create mode 100644 backend/common/packs/__init__.py create mode 100644 backend/common/packs/applier.py create mode 100644 backend/common/packs/loader.py create mode 100644 backend/common/packs/schema.py create mode 100644 backend/common/tests/test_packs_api.py create mode 100644 backend/common/tests/test_packs_applier.py create mode 100644 backend/common/tests/test_packs_content.py create mode 100644 backend/common/tests/test_packs_loader.py create mode 100644 backend/common/tests/test_packs_models.py create mode 100644 backend/common/tests/test_packs_sample_data.py create mode 100644 backend/common/tests/test_rls_pipeline_tables.py create mode 100644 backend/common/views/pack_views.py create mode 100644 backend/contacts/migrations/0013_contact_is_sample.py create mode 100644 backend/leads/migrations/0014_lead_is_sample.py create mode 100644 backend/leads/migrations/0015_alter_lead_is_sample.py create mode 100644 backend/opportunity/migrations/0014_opportunity_is_sample.py create mode 100644 backend/packs/education-admissions.json create mode 100644 backend/packs/professional-services.json create mode 100644 backend/packs/real-estate.json create mode 100644 backend/tasks/migrations/0012_task_is_sample.py create mode 100644 frontend/src/lib/server/log-safe.js create mode 100644 frontend/src/lib/server/packs.js create mode 100644 frontend/src/lib/server/v2/lead-custom-fields.js create mode 100644 frontend/src/lib/terminology.js diff --git a/backend/accounts/migrations/0008_account_is_sample.py b/backend/accounts/migrations/0008_account_is_sample.py new file mode 100644 index 00000000..cb0102f6 --- /dev/null +++ b/backend/accounts/migrations/0008_account_is_sample.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-02 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_account_custom_fields'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + ), + ] diff --git a/backend/accounts/models.py b/backend/accounts/models.py index 05c850fe..531554d7 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -4,7 +4,7 @@ from django.db.models import Q from django.db.models.functions import Lower from django.utils.translation import gettext_lazy as _ -from common.base import AssignableMixin, BaseModel +from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from common.utils import COUNTRIES, CURRENCY_CODES, INDCHOICES from common.validators import flexible_phone_validator @@ -78,6 +78,7 @@ class Account(AssignableMixin, BaseModel): # System Fields is_active = models.BooleanField(default=True) + is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT) org = models.ForeignKey( Org, on_delete=models.CASCADE, diff --git a/backend/cases/migrations/0026_case_is_sample.py b/backend/cases/migrations/0026_case_is_sample.py new file mode 100644 index 00000000..cccd6ed4 --- /dev/null +++ b/backend/cases/migrations/0026_case_is_sample.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-02 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0025_inboundmailbox_topic_arn_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='case', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + ), + ] diff --git a/backend/cases/models.py b/backend/cases/models.py index 6f4aefa2..d6ee3c4a 100644 --- a/backend/cases/models.py +++ b/backend/cases/models.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import pgettext_lazy from accounts.models import Account -from common.base import AssignableMixin, BaseModel +from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from common.utils import CASE_TYPE, CURRENCY_CODES, PRIORITY_CHOICE, STATUS_CHOICE from contacts.models import Contact @@ -36,6 +36,7 @@ class Case(AssignableMixin, BaseModel): description = models.TextField(blank=True, null=True) assigned_to = models.ManyToManyField(Profile, related_name="case_assigned_users") is_active = models.BooleanField(default=True) + is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT) teams = models.ManyToManyField(Teams, related_name="cases_teams") tags = models.ManyToManyField(Tags, related_name="case_tags", blank=True) watchers = models.ManyToManyField( diff --git a/backend/common/audit_log.py b/backend/common/audit_log.py index bc0d0d6e..1f3e83cd 100644 --- a/backend/common/audit_log.py +++ b/backend/common/audit_log.py @@ -46,6 +46,7 @@ class SecurityAuditLog(BaseModel): ("API_KEY_INVALID", "Invalid API Key"), ("MEMBERSHIP_REVOKED", "Membership Revoked"), ("SUSPICIOUS_ACTIVITY", "Suspicious Activity"), + ("SAMPLE_DATA_CLEARED", "Vertical Pack Sample Data Cleared"), ) event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True) @@ -293,6 +294,25 @@ class AuditLogger: request=request, ) + def sample_data_cleared(self, user, org, deleted_count, request=None): + """Log a vertical-pack sample-data clear. + + This is the only destructive operation in the vertical-packs + feature — apply_pack only ever creates and records itself via + PackApplication, but clear_sample_data deletes rows and, before + this, recorded nothing. Any org admin can trigger it, so an audit + trail of who cleared what, and how many rows, matters for incident + review the same way the other destructive-adjacent events here do. + """ + self._log( + "SAMPLE_DATA_CLEARED", + user=user, + org=org, + description=f"Cleared {deleted_count} vertical-pack sample lead(s)", + metadata={"deleted_count": deleted_count}, + request=request, + ) + def suspicious_activity(self, user, org, activity_type, details, request=None): """Log suspicious activity for security review.""" self._log( diff --git a/backend/common/base.py b/backend/common/base.py index 931ecb85..1c770cf9 100644 --- a/backend/common/base.py +++ b/backend/common/base.py @@ -9,6 +9,20 @@ from django.db import models # Module imports from common.mixins import AuditModel +# Help text shared by every `is_sample` column. One string, because the rule it +# states is a security invariant rather than documentation: the flag is the sole +# key `clear_sample_data` deletes on, so any serializer that made it writable +# would let a user mark a real record as sample and have it destroyed. Stating +# that once, next to the definition all six models import, is what stops the +# sixth copy from quietly dropping the warning. +SAMPLE_DATA_HELP_TEXT = ( + "True only for demo rows created by common.packs.applier._apply_sample_data. " + "Server-set exclusively — never expose this as a writable field on any " + "serializer. It is the sole key common.packs.applier.clear_sample_data uses " + "to decide what to delete, so a client-writable path here would let a user " + "mark an arbitrary real record as sample and have it deleted." +) + class AssignableMixin(models.Model): """ diff --git a/backend/common/middleware/rls_context.py b/backend/common/middleware/rls_context.py index feccd21b..bd23c75f 100644 --- a/backend/common/middleware/rls_context.py +++ b/backend/common/middleware/rls_context.py @@ -143,6 +143,27 @@ class RequireOrgContext: "/api/public/estimate/", ] + # Paths exempt on an EXACT match only — never prefix-matched like + # EXEMPT_PATHS above. Mirrors the exact-match pattern already used in + # common.middleware.get_company.GetProfileAndOrg.process_request + # (`if request.path in auth_skip_paths`). + # + # GET /api/packs/ is static content read from JSON files in the repo — + # byte-identical for every tenant, no tenant data whatsoever. It backs + # the pack chooser on the org-creation page, where a user creating their + # *first* org has no org_id claim yet, so RequireOrgContext would 403 the + # exact audience the page serves. + # + # This MUST stay an exact match, not a prefix: POST + # /api/packs//apply/ (writes org-wide config, ADMIN only) and + # DELETE /api/packs/sample-data/ (deletes records, ADMIN only) both start + # with "/api/packs/" too. Adding "/api/packs/" to the prefix-matched + # EXEMPT_PATHS list instead of here would silently strip tenant-context + # enforcement from those write/destroy endpoints. Do not move it there. + EXEMPT_EXACT_PATHS = [ + "/api/packs/", + ] + def __init__(self, get_response): self.get_response = get_response @@ -176,6 +197,8 @@ class RequireOrgContext: def _is_exempt(self, path): """Check if path is exempt from org context requirement.""" + if path in self.EXEMPT_EXACT_PATHS: + return True return any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS) def _set_org_context(self, request): diff --git a/backend/common/migrations/0032_vertical_packs.py b/backend/common/migrations/0032_vertical_packs.py new file mode 100644 index 00000000..b11bf6a7 --- /dev/null +++ b/backend/common/migrations/0032_vertical_packs.py @@ -0,0 +1,68 @@ +# Generated by Django 6.0.7 on 2026-08-01 11:20 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import connection, migrations, models + +from common.rls import get_disable_policy_sql, get_enable_policy_sql + + +def enable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_enable_policy_sql("pack_application")) + + +def disable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_disable_policy_sql("pack_application")) + + +class Migration(migrations.Migration): + + # RLS policy creation can't run inside an atomic block. + atomic = False + + dependencies = [ + ('common', '0031_backfill_portal_tokens'), + ] + + operations = [ + migrations.AddField( + model_name='org', + name='terminology', + field=models.JSONField(blank=True, default=dict, help_text="Label overrides, e.g. {'lead.plural': 'Enquiries'}."), + ), + migrations.AddField( + model_name='org', + name='vertical', + field=models.CharField(blank=True, default='', help_text="Pack id applied to this org, e.g. 'real-estate'. Descriptive only.", max_length=64), + ), + migrations.CreateModel( + name='PackApplication', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('pack_id', models.CharField(max_length=64)), + ('pack_version', models.PositiveIntegerField()), + ('result', models.JSONField(default=dict, help_text="{'created': [...], 'skipped': [...], 'failed': [...]}")), + ('applied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pack_applications', to='common.profile')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('org', models.ForeignKey(help_text='Organization this record belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='common.org')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Pack Application', + 'verbose_name_plural': 'Pack Applications', + 'db_table': 'pack_application', + 'ordering': ('-created_at',), + 'indexes': [models.Index(fields=['org', '-created_at'], name='pack_applic_org_id_78eec5_idx')], + }, + ), + migrations.RunPython(enable_rls, reverse_code=disable_rls), + ] diff --git a/backend/common/migrations/0033_alter_securityauditlog_event_type.py b/backend/common/migrations/0033_alter_securityauditlog_event_type.py new file mode 100644 index 00000000..fe5648d5 --- /dev/null +++ b/backend/common/migrations/0033_alter_securityauditlog_event_type.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.7 on 2026-08-01 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0032_vertical_packs"), + ] + + operations = [ + migrations.AlterField( + model_name="securityauditlog", + name="event_type", + field=models.CharField( + choices=[ + ("LOGIN_SUCCESS", "Login Success"), + ("LOGIN_FAILURE", "Login Failure"), + ("LOGOUT", "Logout"), + ("ORG_SWITCH", "Organization Switch"), + ("TOKEN_REFRESH", "Token Refresh"), + ("TOKEN_REVOKED", "Token Revoked"), + ("PERMISSION_DENIED", "Permission Denied"), + ("CROSS_ORG_ATTEMPT", "Cross-Org Access Attempt"), + ("API_KEY_USED", "API Key Used"), + ("API_KEY_INVALID", "Invalid API Key"), + ("MEMBERSHIP_REVOKED", "Membership Revoked"), + ("SUSPICIOUS_ACTIVITY", "Suspicious Activity"), + ("SAMPLE_DATA_CLEARED", "Vertical Pack Sample Data Cleared"), + ], + db_index=True, + max_length=50, + ), + ), + ] diff --git a/backend/common/migrations/0034_rls_pipeline_tables.py b/backend/common/migrations/0034_rls_pipeline_tables.py new file mode 100644 index 00000000..917ac3b2 --- /dev/null +++ b/backend/common/migrations/0034_rls_pipeline_tables.py @@ -0,0 +1,86 @@ +# Stamp Row-Level Security (RLS) policies on the Kanban pipeline and stage +# tables. +# +# lead_pipeline, lead_stage, case_pipeline, case_stage, task_pipeline and +# task_stage all carry org_id directly, but they were absent from +# ORG_SCOPED_TABLES and no migration ever created their policies. The apps that +# created the tables (leads/0012, cases/0009, tasks/0009) contain no RLS SQL, +# and common/0028 only iterates RLS_CONFIG["tables"] — which did not list them. +# +# Deployed databases had the policies from an out-of-band step, so the gap was +# invisible there. A database built purely from migrations — CI, and any fresh +# deploy — got none, leaving these tables with no safety net beneath the ORM's +# org filters. Vertical packs write to all six on every apply, which is what +# made the gap load-bearing rather than theoretical. +# +# The explicit cross-app dependencies below matter: without them Django is free +# to run this before the tables exist, the existence guard would skip every one, +# and a fresh database would again end up with no policies. +# +# Idempotent: the generated SQL opens with DROP POLICY IF EXISTS, so re-running +# is safe. On databases carrying the out-of-band policies this also restamps +# them into the InitPlan form common/0028 introduced. +# +# atomic = False, matching common/0028: DROP/CREATE POLICY each take an ACCESS +# EXCLUSIVE lock, so committing per statement keeps each lock brief. +# +# RLS Configuration: See common/rls/__init__.py for centralized policy definitions + +from django.db import migrations + +from common.rls import get_check_table_exists_sql, get_enable_policy_sql + +PIPELINE_TABLES = [ + "lead_pipeline", + "lead_stage", + "case_pipeline", + "case_stage", + "task_pipeline", + "task_stage", +] + + +def stamp_pipeline_rls(apps, schema_editor): + """Create the isolation and insert-check policies on the pipeline tables.""" + if schema_editor.connection.vendor != "postgresql": + print("RLS is only supported on PostgreSQL. Skipping.") + return + + stamped = 0 + + with schema_editor.connection.cursor() as cursor: + for table in PIPELINE_TABLES: + cursor.execute(get_check_table_exists_sql(), [table]) + if not cursor.fetchone()[0]: + print(f" Skipping {table} (table does not exist)") + continue + + cursor.execute(get_enable_policy_sql(table)) + stamped += 1 + + print(f" Stamped RLS policies on {stamped} pipeline table(s)") + + +def noop_reverse(apps, schema_editor): + """ + No-op. + + Rolling back would mean dropping tenant-isolation policies, which is never + the safe direction. The policies are value-preserving for correctly scoped + queries, so leaving them in place breaks nothing on an older code revision. + """ + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("common", "0033_alter_securityauditlog_event_type"), + ("leads", "0012_lead_kanban_pipeline"), + ("cases", "0009_case_kanban"), + ("tasks", "0009_task_kanban"), + ] + + operations = [ + migrations.RunPython(stamp_pipeline_rls, noop_reverse), + ] diff --git a/backend/common/models.py b/backend/common/models.py index 77be2ee2..ce58a3dc 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -138,6 +138,20 @@ class Org(BaseModel): # explicit confirmation; this flag only controls the default state. auto_close_children_on_parent_close = models.BooleanField(default=False) + # Vertical packs. Descriptive only — records which pack was applied at + # signup. NEVER used in a permission check or a queryset filter. + vertical = models.CharField( + max_length=64, + blank=True, + default="", + help_text="Pack id applied to this org, e.g. 'real-estate'. Descriptive only.", + ) + terminology = models.JSONField( + default=dict, + blank=True, + help_text="Label overrides, e.g. {'lead.plural': 'Enquiries'}.", + ) + class Meta: verbose_name = "Organization" verbose_name_plural = "Organizations" @@ -954,3 +968,33 @@ class PortalAccessToken(models.Model): from common.audit_log import ( SecurityAuditLog, ) # noqa: F401,E402 # pylint: disable=unused-import + + +class PackApplication(BaseOrgModel): + """Audit record of one vertical-pack application to one org.""" + + pack_id = models.CharField(max_length=64) + pack_version = models.PositiveIntegerField() + applied_by = models.ForeignKey( + "common.Profile", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="pack_applications", + ) + result = models.JSONField( + default=dict, + help_text="{'created': [...], 'skipped': [...], 'failed': [...]}", + ) + + class Meta: + verbose_name = "Pack Application" + verbose_name_plural = "Pack Applications" + db_table = "pack_application" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["org", "-created_at"]), + ] + + def __str__(self): + return f"{self.pack_id} v{self.pack_version} → {self.org.name}" diff --git a/backend/common/packs/__init__.py b/backend/common/packs/__init__.py new file mode 100644 index 00000000..b531d3d7 --- /dev/null +++ b/backend/common/packs/__init__.py @@ -0,0 +1,4 @@ +from common.packs.loader import get_pack, get_registry +from common.packs.schema import PackValidationError, validate_manifest + +__all__ = ["get_pack", "get_registry", "validate_manifest", "PackValidationError"] diff --git a/backend/common/packs/applier.py b/backend/common/packs/applier.py new file mode 100644 index 00000000..abdfd7b3 --- /dev/null +++ b/backend/common/packs/applier.py @@ -0,0 +1,639 @@ +"""Apply a vertical-pack manifest to an org. + +The single invariant: create if missing, skip if present. This module never +calls .update(), never calls .save() on a row it did not just construct, and +never deletes. That is what makes re-applying safe and what stops a pack from +destroying tenant configuration. +""" + +from __future__ import annotations + +import logging + +from django.db import transaction + +from cases.models import CasePipeline, CaseStage +from common.models import CustomFieldDefinition, Org, PackApplication, Tags +from common.packs.schema import SAMPLE_ENTITY_KEYS +from invoices.models import Product +from leads.models import LeadPipeline, LeadStage +from tasks.models import TaskPipeline, TaskStage + +logger = logging.getLogger(__name__) + +# manifest key -> (pipeline model, stage model, stage supports win_probability) +PIPELINES = { + "lead_pipeline": (LeadPipeline, LeadStage, True), + "case_pipeline": (CasePipeline, CaseStage, False), + "task_pipeline": (TaskPipeline, TaskStage, False), +} + +SAMPLE_TAG_NAME = "Sample data" +SAMPLE_TAG_SLUG = "sample-data" + + +class _Report: + def __init__(self): + self.created: list[dict] = [] + self.skipped: list[dict] = [] + self.failed: list[dict] = [] + + def add_created(self, type_: str, name: str) -> None: + self.created.append({"type": type_, "name": name}) + + def add_skipped(self, type_: str, name: str, reason: str = "already exists") -> None: + self.skipped.append({"type": type_, "name": name, "reason": reason}) + + def as_dict(self) -> dict: + return {"created": self.created, "skipped": self.skipped, "failed": self.failed} + + +def _apply_pipelines(org, pack: dict, report: _Report) -> None: + for key, (PipelineModel, StageModel, allow_wp) in PIPELINES.items(): + spec = pack.get(key) + if not spec: + continue + name = spec["name"] + if PipelineModel.objects.filter(org=org, name=name).exists(): + # Stages are only created beneath a pipeline we just created, so a + # skipped pipeline takes its stages with it. This avoids + # half-populated pipelines and keeps stage matching trivial. + report.add_skipped(key, name) + continue + + # is_default is never set from a pack: all three pipeline models carry a + # one-default-per-org UniqueConstraint. The admin promotes a default. + pipeline = PipelineModel.objects.create( + org=org, + name=name, + description=spec.get("description", ""), + is_default=False, + ) + report.add_created(key, name) + + for order, stage in enumerate(spec.get("stages") or [], start=1): + fields = { + "pipeline": pipeline, + "org": org, + "name": stage["name"], + "order": stage.get("order", order), + "color": stage.get("color", "#6B7280"), + "stage_type": stage.get("stage_type", "open"), + "maps_to_status": stage.get("maps_to_status"), + "wip_limit": stage.get("wip_limit"), + } + if allow_wp and "win_probability" in stage: + fields["win_probability"] = stage["win_probability"] + StageModel.objects.create(**fields) + report.add_created(f"{key}_stage", stage["name"]) + + +def _apply_custom_fields(org, pack: dict, report: _Report) -> None: + for field in pack.get("custom_fields") or []: + exists = CustomFieldDefinition.objects.filter( + org=org, target_model=field["target"], key=field["key"] + ).exists() + if exists: + report.add_skipped("custom_field", field["key"]) + continue + CustomFieldDefinition.objects.create( + org=org, + target_model=field["target"], + key=field["key"], + label=field["label"], + field_type=field["field_type"], + options=field.get("options"), + is_required=field.get("is_required", False), + is_filterable=field.get("is_filterable", False), + display_order=field.get("display_order", 0), + ) + report.add_created("custom_field", field["key"]) + + +def _apply_tags(org, pack: dict, report: _Report) -> None: + from django.utils.text import slugify + + for tag in pack.get("tags") or []: + # Tags.save() derives slug from name; (slug, org) is the unique key. + if Tags.objects.filter(org=org, slug=slugify(tag["name"])).exists(): + report.add_skipped("tag", tag["name"]) + continue + Tags.objects.create( + org=org, + name=tag["name"], + color=tag.get("color", "blue"), + description=tag.get("description", ""), + ) + report.add_created("tag", tag["name"]) + + +def _apply_products(org, pack: dict, report: _Report) -> None: + for product in pack.get("products") or []: + # sku is mandatory in a manifest — see schema._validate_product. + if Product.objects.filter(org=org, sku=product["sku"]).exists(): + report.add_skipped("product", product["name"]) + continue + Product.objects.create( + org=org, + name=product["name"], + sku=product["sku"], + description=product.get("description", ""), + price=product.get("price", 0), + currency=product.get("currency") or org.default_currency, + category=product.get("category"), + ) + report.add_created("product", product["name"]) + + +def _apply_terminology(org, pack: dict, report: _Report) -> None: + # `org` is the caller's in-memory object, loaded before apply_pack opened + # its transaction — it can already be stale (e.g. a second apply of a + # different pack applied via the same object, or genuine concurrency). + # Deciding "already set?" from that snapshot and then saving it back + # would overwrite whatever the DB row actually holds. Re-read the row + # under a lock, INSIDE this transaction, and decide from the locked row + # instead — that is the only copy of the truth we write from. + locked = Org.objects.select_for_update().get(pk=org.pk) + + terminology = pack.get("terminology") or {} + current = dict(locked.terminology or {}) + added = {k: v for k, v in terminology.items() if k not in current} + for key in terminology: + if key in current: + report.add_skipped("terminology", key) + else: + report.add_created("terminology", key) + + changed_fields = [] + if added: + current.update(added) + locked.terminology = current + changed_fields.append("terminology") + # Set regardless of whether this pack carries any terminology — a + # pipeline-only or products-only pack still applied, and vertical is the + # field that records that. + if not locked.vertical: + locked.vertical = pack["id"] + changed_fields.append("vertical") + if changed_fields: + locked.save(update_fields=changed_fields) + # Keep the caller's object coherent with what was actually written, + # in case they read org.terminology/org.vertical after this call. + org.terminology, org.vertical = locked.terminology, locked.vertical + + +def _sample_models(): + """The six models sample data is created in, keyed by report type. + + Ordered as the applier creates them and, reversed, as clear_sample_data + deletes them: a referenced row exists before the row referencing it, and is + removed after. Defined once so the apply guard, the applier and the clear + can never disagree about what "sample data" covers — a seventh entity added + to one list but not the others is the failure mode this shape prevents. + """ + from accounts.models import Account + from cases.models import Case + from contacts.models import Contact + from leads.models import Lead + from opportunity.models import Opportunity + from tasks.models import Task + + return ( + ("sample_account", Account), + ("sample_contact", Contact), + ("sample_deal", Opportunity), + ("sample_ticket", Case), + ("sample_task", Task), + ("sample_lead", Lead), + ) + + +def _create_sample(report, type_: str, name: str, build): + """Run one sample insert inside its own savepoint. + + One savepoint per row, around the insert only: a pre-existing row colliding + on a unique constraint (a lead email, an account name) must skip that row, + not roll back every pipeline/tag/custom-field/product this apply already + wrote. transaction.atomic() nests as a savepoint here because apply_pack + already opened the outer transaction. + + The except covers *only* `build()`. It used to wrap the tags.add() / + assigned_to.add() follow-up too, and blamed every IntegrityError on a + duplicate email — which was wrong, and silent, for the one it actually + produced: assigned_to.add(None) when actor is None. Keeping the M2M work + outside is what stops this clause from misreporting an unrelated integrity + error as a collision. ValidationError is caught alongside because Task.save + calls full_clean, so a bad sample task raises Django's ValidationError + rather than an IntegrityError — uncaught, that is a 500 out of an endpoint + whose whole contract is "additive, never fatal". + """ + from django.core.exceptions import ValidationError + from django.db import IntegrityError + + try: + with transaction.atomic(): + obj = build() + except (IntegrityError, ValidationError) as exc: + reason = f"could not be created ({type(exc).__name__})" + report.add_skipped(type_, name, reason=reason) + return None + report.add_created(type_, name) + return obj + + +def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: + from accounts.models import Account + from cases.models import Case, CaseStage + from contacts.models import Contact + from leads.models import Lead + from opportunity.models import Opportunity + from tasks.models import Task, TaskStage + + sample = pack.get("sample_data") or {} + if not any(sample.get(key) for key in SAMPLE_ENTITY_KEYS): + return + + # is_sample, not the tag, is the re-apply guard — a tag is just a label a + # user can freely rename/delete/attach, so it must never gate anything this + # module decides from. See clear_sample_data for the matching deletion-side + # rule. The guard spans every sample model, not just Lead: a pack whose + # sample block is accounts-only must still be blocked from double-applying, + # and a second pack must not layer its demo rows onto the first pack's. + for _type, model in _sample_models(): + if model.objects.filter(org=org, is_sample=True).exists(): + report.add_skipped("sample_data", pack["id"]) + return + + # The tag is attached purely for visibility/filtering in the UI — it + # carries no authority over what apply/clear do. Adopting this tag onto a + # real record (or renaming another tag onto this slug) is harmless — + # nothing destructive keys off it. + sample_tag, _ = Tags.objects.get_or_create( + org=org, slug=SAMPLE_TAG_SLUG, defaults={"name": SAMPLE_TAG_NAME, "color": "gray"} + ) + + def finish(obj): + """Tag and assign a freshly created sample row.""" + if obj is None: + return + obj.tags.add(sample_tag) + # actor is optional (PackApplication.applied_by is nullable for the + # same reason — a caller may apply without an assigning admin, e.g. a + # future management command). Without an assignee the admin who applied + # the pack has no ordinary "my records" query that reaches these rows, + # but that is a lesser loss than silently mis-skipping every sample row, + # which is what happened before this guard. + if actor is not None: + obj.assigned_to.add(actor) + + # Every reference below resolves against these maps alone — rows this apply + # just created. Never against a queryset of the org's existing records: a + # sample contact that latched onto a tenant's real "Acme Corp" would put demo + # rows inside real customer data, and would then make that real account a + # deletion candidate when the sample data is cleared. + accounts: dict[str, Account] = {} + contacts: dict[str, Contact] = {} + deals: dict[str, Opportunity] = {} + tickets: dict[str, Case] = {} + + for spec in sample.get("accounts") or []: + obj = _create_sample( + report, "sample_account", spec["name"], + lambda s=spec: Account.objects.create( + org=org, + name=s["name"], + email=s.get("email"), + phone=s.get("phone"), + website=s.get("website"), + industry=s.get("industry"), + city=s.get("city"), + country=s.get("country"), + description=s.get("description"), + custom_fields=s.get("custom_fields") or {}, + is_sample=True, + ), + ) + if obj is not None: + accounts[spec["name"]] = obj + finish(obj) + + for spec in sample.get("contacts") or []: + name = f"{spec.get('first_name', '')} {spec.get('last_name', '')}".strip() + obj = _create_sample( + report, "sample_contact", name, + lambda s=spec: Contact.objects.create( + org=org, + first_name=s["first_name"], + last_name=s["last_name"], + email=s.get("email"), + phone=s.get("phone"), + title=s.get("title"), + department=s.get("department"), + organization=s.get("organization"), + account=accounts.get(s.get("account")), + city=s.get("city"), + country=s.get("country"), + description=s.get("description"), + custom_fields=s.get("custom_fields") or {}, + is_sample=True, + ), + ) + if obj is not None: + contacts[name] = obj + finish(obj) + # Account.contacts is the M2M the account page actually reads; the + # Contact.account FK alone leaves the account's contact list empty. + # See the "two account links on Contact" split — both are populated + # here so the demo looks right from either side. + if obj.account_id: + obj.account.contacts.add(obj) + + for spec in sample.get("deals") or []: + obj = _create_sample( + report, "sample_deal", spec["name"], + lambda s=spec: Opportunity.objects.create( + org=org, + name=s["name"], + account=accounts.get(s.get("account")), + stage=s.get("stage") or "PROSPECTING", + amount=s.get("amount"), + currency=s.get("currency") or org.default_currency, + probability=s.get("probability") or 0, + closed_on=s.get("closed_on"), + lead_source=s.get("lead_source"), + description=s.get("description"), + custom_fields=s.get("custom_fields") or {}, + is_sample=True, + ), + ) + if obj is not None: + deals[spec["name"]] = obj + finish(obj) + + case_stages = {s.name: s for s in CaseStage.objects.filter(org=org)} + for spec in sample.get("tickets") or []: + obj = _create_sample( + report, "sample_ticket", spec["name"], + lambda s=spec: Case.objects.create( + org=org, + name=s["name"], + status=s["status"], + priority=s["priority"], + case_type=s.get("case_type"), + account=accounts.get(s.get("account")), + stage=case_stages.get(s.get("stage")), + description=s.get("description"), + custom_fields=s.get("custom_fields") or {}, + is_sample=True, + ), + ) + if obj is not None: + tickets[spec["name"]] = obj + finish(obj) + for contact_name in spec.get("contacts") or []: + linked = contacts.get(contact_name) + if linked is not None: + obj.contacts.add(linked) + + lead_stages = {s.name: s for s in LeadStage.objects.filter(org=org)} + leads: dict[str, Lead] = {} + for spec in sample.get("leads") or []: + obj = _create_sample( + report, "sample_lead", spec["title"], + lambda s=spec: Lead.objects.create( + org=org, + title=s["title"], + first_name=s.get("first_name", ""), + last_name=s.get("last_name", ""), + email=s.get("email"), + phone=s.get("phone"), + source=s.get("source"), + status=s.get("status"), + stage=lead_stages.get(s.get("stage")), + opportunity_amount=s.get("opportunity_amount"), + currency=s.get("currency"), + custom_fields=s.get("custom_fields") or {}, + is_sample=True, + ), + ) + if obj is not None: + leads[spec["title"]] = obj + finish(obj) + + # Tasks last: they are the only entity that can point at any of the others, + # so every parent map is fully populated by the time this runs. + task_stages = {s.name: s for s in TaskStage.objects.filter(org=org)} + for spec in sample.get("tasks") or []: + obj = _create_sample( + report, "sample_task", spec["title"], + lambda s=spec: Task.objects.create( + org=org, + title=s["title"], + status=s["status"], + priority=s["priority"], + due_date=s.get("due_date"), + description=s.get("description"), + stage=task_stages.get(s.get("stage")), + # At most one of these is set — schema._validate_sample_data + # rejects a pack that names more, matching Task.clean. + account=accounts.get(s.get("account")), + opportunity=deals.get(s.get("deal")), + case=tickets.get(s.get("ticket")), + lead=leads.get(s.get("lead")), + custom_fields=s.get("custom_fields") or {}, + is_sample=True, + ), + ) + finish(obj) + + +def _referencing_relations(model): + """Every non-M2M reverse relation through which another row points at `model`. + + Derived from the model registry rather than hand-listed, because a list of + accessor names is exactly the kind of thing that silently rots: the next FK + someone adds to Account would not be in it, and the omission shows up as + deleted tenant data rather than as a failing import. + + All three on_delete behaviours matter here, which is why nothing is filtered + by kind: + + CASCADE deleting the sample row destroys the tenant's row with it + (Case.account, Opportunity.account, Order.account, and the five + children of Case including billable TimeEntry) + PROTECT deleting the sample row raises ProtectedError and aborts the + whole clear (Invoice/Estimate/RecurringInvoice on Account) + SET_NULL deleting the sample row silently unlinks the tenant's row + (Contact.account, Task.account, and friends) + + M2M relations are excluded on purpose: removing a through-row neither + deletes nor orphans the other side, so it is not a reason to retain. + """ + return [rel for rel in model._meta.related_objects if not rel.many_to_many] + + +def _ids_with_foreign_children(model, ids: list, retained_pks: set) -> set: + """Of `ids`, those still referenced by a row this clear is not going to remove. + + One query per relation rather than per row. A row carrying `is_sample=True` + normally does not count: it is the pack's own, and it has either already been + deleted (children are processed first) or is about to be. A sample task + pointing at a sample account is not a reason to keep that account, and a + sample case that is the `parent` of another sample case would otherwise make + the pair permanently undeletable. + + `retained_pks` is the exception, and it is what makes retention hold. A + sample ticket kept because it carries billable time is no longer going + anywhere — so its sample account must be kept too, or deleting the account + would cascade the ticket away and take that billable time with it, which is + exactly the loss the retention rule exists to prevent. Because the loop runs + children first, every retained child is already known by the time its parent + is considered. + """ + if not ids: + return set() + from django.db.models import Q + + blocked = set() + for rel in _referencing_relations(model): + related, field = rel.related_model, rel.field.name + qs = related.objects.filter(**{f"{field}__in": ids}) + if any(f.name == "is_sample" for f in related._meta.fields): + qs = qs.exclude(Q(is_sample=True) & ~Q(pk__in=retained_pks)) + blocked.update(pk for pk in qs.values_list(field, flat=True) if pk is not None) + return blocked + + +def clear_sample_data(org, actor=None) -> dict: + """Delete the demo records the applier created for THIS org, and nothing else. + + Keyed on `is_sample` alone — a server-set, non-serializer-writable boolean on + all six models — never on the "Sample data" tag. `Tags.save()` recomputes its + slug on every save and the tag CRUD endpoints have no reserved-slug guard, so + the tag can be renamed onto this slug, adopted from an existing tag, or + attached to any real record through the ordinary tag picker. None of that is + destructive here: the tag is not touched by this function at all, and + is_sample cannot be set through any client-facing serializer. + + Two rules make this safe to hand to an admin as a single button: + + 1. **Children before parents.** _sample_models is reversed, so sample tasks + and leads go before tickets, tickets before deals and contacts, and + accounts last. A sample ticket is therefore already gone by the time its + sample account is considered, and only the tenant's own rows are left to + reason about. + + 2. **Never take real data with it, and never fail because of it.** A user who + logs a real deal against a demo account, bills hours against a demo + ticket, or raises a real invoice on a demo account must lose none of it — + and in the invoice case a naive delete would not merely lose data, it + would raise ProtectedError and abort the entire clear. Any sample row + still referenced by a non-sample row is kept and reported as retained. + + Returns {"deleted": {type: n}, "retained": {type: n}} — retained is a normal + outcome, not an error, and the UI is expected to say so. + + `actor` is optional (mirrors apply_pack's PackApplication.applied_by) and is + used only to attribute the audit record — it plays no part in the delete, + which is already org-scoped. + """ + from common.audit_log import audit_log + + deleted: dict[str, int] = {} + retained: dict[str, int] = {} + # Accumulated across models so a kept child keeps its parent — see + # _ids_with_foreign_children. + retained_pks: set = set() + + with transaction.atomic(): + for type_, model in reversed(_sample_models()): + rows = model.objects.filter(org=org, is_sample=True) + ids = list(rows.values_list("pk", flat=True)) + blocked = _ids_with_foreign_children(model, ids, retained_pks) + if blocked: + retained[type_] = len(blocked) + retained_pks.update(blocked) + + safe_ids = [pk for pk in ids if pk not in blocked] + if not safe_ids: + continue + _total, by_model = model.objects.filter(pk__in=safe_ids).delete() + + # `_total` sums every row the collector removed, including the M2M + # through-table rows (tags/contacts/assigned_to/teams) cascaded off + # each deleted record — not the number of records deleted. Pull the + # model-specific count from the per-model breakdown instead of a + # second, racy count() query. + count = by_model.get(model._meta.label, 0) + if count: + deleted[type_] = count + + # This is the only destructive operation in the feature — apply_pack records + # itself via PackApplication, so a delete leaving no trace at all would be + # the odd one out. See AuditLogger.sample_data_cleared. + audit_log.sample_data_cleared( + actor.user if actor is not None else None, org, sum(deleted.values()) + ) + + return {"deleted": deleted, "retained": retained} + + +@transaction.atomic +def apply_pack(org, pack: dict, actor) -> dict: + """Apply `pack` to `org`. Additive-only. Returns the created/skipped report.""" + # actor must belong to org. _apply_sample_data does + # lead.assigned_to.add(actor) with no further org check of its own, so + # without this guard a caller that passes a mismatched (org, actor) pair + # — e.g. an org-B apply given an org-A profile — creates cross-org + # assignments: an org-A profile ends up assigned to org-B's leads, and + # that profile can then read those leads through an ordinary "my leads" + # query, which is a tenant-isolation break. This is enforced here, not + # only in the view, because the view is not the only caller (management + # commands, tests, future callers) and a correct-by-construction view is + # not a substitute for the invariant actually being checked. + if actor is not None and actor.org_id != org.id: + raise ValueError("actor must belong to the org the pack is being applied to") + + # Serialize concurrent applies to the same org — keyed on org alone, not + # org+pack. Two of our match keys (pipeline name, invoice template name) + # are conventions rather than DB constraints, so without this two racing + # applies of the same pack could both pass the existence check and both + # create. But _apply_terminology also mutates the single shared Org row, + # and that row is not pack-scoped: two concurrent applies of *different* + # packs to the same org must serialize too, or one can clobber the + # other's terminology/vertical write. Locking on org id alone covers + # both cases. + # + # pg_advisory_xact_lock is Postgres-only. Production and every real + # deployment run Postgres (RLS requires it — see RLS_SETUP.md), but the + # test suite runs on SQLite (crm/test_settings.py), which has no such + # function and would raise OperationalError. Guard on connection.vendor, + # matching the existing pattern in common/tasks.py's set_rls_context and + # common/management/commands/manage_rls.py: skip the Postgres-only + # statement on other backends instead of trying to emulate it. This is a + # real no-op only on SQLite, which is test-only in this codebase — on + # every backend that ships to production, the lock is taken. + from django.db import connection + + if connection.vendor == "postgresql": + with connection.cursor() as cursor: + cursor.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s))", [str(org.id)] + ) + + report = _Report() + _apply_pipelines(org, pack, report) + _apply_custom_fields(org, pack, report) + _apply_tags(org, pack, report) + _apply_products(org, pack, report) + _apply_terminology(org, pack, report) + _apply_sample_data(org, pack, actor, report) + + PackApplication.objects.create( + org=org, + pack_id=pack["id"], + pack_version=pack["version"], + applied_by=actor, + result=report.as_dict(), + ) + return report.as_dict() diff --git a/backend/common/packs/loader.py b/backend/common/packs/loader.py new file mode 100644 index 00000000..3cb6d22f --- /dev/null +++ b/backend/common/packs/loader.py @@ -0,0 +1,39 @@ +"""Registry of vertical packs, built once from backend/packs/*.json. + +Pack ids are dict keys, never filesystem paths — a caller-supplied id can only +hit or miss this dict, so there is no traversal or SSRF surface. +""" + +from __future__ import annotations + +import json +import logging +from functools import lru_cache +from pathlib import Path + +from common.packs.schema import PackValidationError, validate_manifest + +logger = logging.getLogger(__name__) + +PACKS_DIR = Path(__file__).resolve().parents[2] / "packs" + + +@lru_cache(maxsize=1) +def get_registry() -> dict[str, dict]: + registry: dict[str, dict] = {} + if not PACKS_DIR.is_dir(): + return registry + for path in sorted(PACKS_DIR.glob("*.json")): + raw = json.loads(path.read_text()) + manifest = validate_manifest(raw) + if manifest["id"] != path.stem: + raise PackValidationError( + f"{path.name}: manifest id {manifest['id']!r} must match the filename" + ) + registry[manifest["id"]] = manifest + return registry + + +def get_pack(pack_id: str) -> dict | None: + """Look up a pack by id. Unknown ids — including anything path-shaped — return None.""" + return get_registry().get(pack_id) diff --git a/backend/common/packs/schema.py b/backend/common/packs/schema.py new file mode 100644 index 00000000..a442074a --- /dev/null +++ b/backend/common/packs/schema.py @@ -0,0 +1,482 @@ +"""Strict validation for vertical-pack manifests. + +Unknown keys are errors, not ignored — a typo in a pack file must fail in CI, +not silently drop a stage. See docs/superpowers/specs/2026-08-01-vertical-packs-design.md §6. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation + +from common.custom_fields import SUPPORTED_TARGETS, validate_definition_options + +TOP_LEVEL_KEYS = { + "id", "version", "name", "description", "terminology", + "lead_pipeline", "case_pipeline", "task_pipeline", + "custom_fields", "tags", "products", "sample_data", +} + +# is_default is a recognised key on both — not a typo — but is rejected by +# _reject_is_default with a specific message rather than lumped in with +# genuinely unknown keys. See _reject_is_default for why this matters. +PIPELINE_KEYS = {"name", "description", "stages", "is_default"} +STAGE_KEYS = { + "name", "order", "color", "stage_type", "maps_to_status", "wip_limit", + "is_default", +} +# win_probability exists on LeadStage only (leads/models.py:296). +LEAD_STAGE_KEYS = STAGE_KEYS | {"win_probability"} + +CUSTOM_FIELD_KEYS = { + "target", "key", "label", "field_type", "options", + "is_required", "is_filterable", "display_order", +} +FIELD_TYPES = {"text", "textarea", "number", "dropdown", "date", "checkbox"} + +TAG_KEYS = {"name", "color", "description"} +PRODUCT_KEYS = {"name", "sku", "price", "currency", "category", "description"} + +SAMPLE_LEAD_KEYS = { + "title", "first_name", "last_name", "email", "phone", + "source", "status", "stage", "custom_fields", + "opportunity_amount", "currency", +} +SAMPLE_ACCOUNT_KEYS = { + "name", "email", "phone", "website", "industry", "city", "country", + "description", "custom_fields", +} +SAMPLE_CONTACT_KEYS = { + "first_name", "last_name", "email", "phone", "title", "department", + "organization", "account", "city", "country", "description", "custom_fields", +} +SAMPLE_DEAL_KEYS = { + "name", "account", "stage", "amount", "currency", "probability", + "closed_on", "lead_source", "description", "custom_fields", +} +SAMPLE_TICKET_KEYS = { + "name", "status", "priority", "case_type", "account", "contacts", + "stage", "description", "custom_fields", +} +SAMPLE_TASK_KEYS = { + "title", "status", "priority", "due_date", "description", "stage", + "account", "deal", "ticket", "lead", "custom_fields", +} + +# Every entity `sample_data` understands, in the order the applier creates them +# — a later entity may reference an earlier one by name, never the reverse. +# Tasks are last because a task is the only row that can point at any of the +# other five. common.packs.applier imports this rather than restating it. +SAMPLE_ENTITY_KEYS = ("accounts", "contacts", "deals", "tickets", "leads", "tasks") + +# Task.clean (tasks/models.py) rejects a Task linked to more than one parent, and +# Task.save calls full_clean, so the DB write raises rather than truncating. The +# schema enforces the same rule so a bad pack fails in CI instead of at apply. +SAMPLE_TASK_PARENTS = ("account", "deal", "ticket", "lead") + + +class PackValidationError(Exception): + """Raised when a manifest is malformed. Message names the offending key.""" + + +def _reject_unknown(where: str, obj: dict, allowed: set[str]) -> None: + if not isinstance(obj, dict): + raise PackValidationError(f"{where}: expected an object") + extra = set(obj) - allowed + if extra: + raise PackValidationError(f"{where}: unknown key(s) {sorted(extra)}") + + +def _reject_is_default(where: str, obj: dict) -> None: + # Two independent reasons, one rule. All three pipeline models have a + # one-default-per-org UniqueConstraint (IntegrityError), and + # InvoiceTemplate.save() silently demotes other defaults (a disguised + # update). Packs create; admins promote. + # + # Called BEFORE _reject_unknown at every site that uses it, so a pack + # containing is_default gets this specific, actionable message instead of + # a generic unknown-key error. is_default is deliberately INCLUDED in + # PIPELINE_KEYS/STAGE_KEYS (a recognised key, not a typo) precisely so + # _reject_unknown does not also flag it — this guard must be the sole + # gatekeeper, or a stub-to-no-op could hide behind _reject_unknown's + # "unknown key(s) [...]" message, which also contains the substring + # "is_default" and would let a loose regex match pass for the wrong + # reason. + if isinstance(obj, dict) and "is_default" in obj: + raise PackValidationError( + f"{where}: is_default is not allowed in a pack — the admin promotes a default" + ) + + +def _stage_type_choices(pipeline_key: str) -> set[str]: + """STAGE_TYPE_CHOICES differs per pipeline model: + LeadStage = open/won/lost (leads/models.py:271), + CaseStage = open/closed/rejected (cases/models.py:531), + TaskStage = open/in_progress/completed (tasks/models.py:240). + Lazy-imported so schema.py stays importable before the Django app + registry is configured — same reasoning as _validate_tag's lazy import + of Tags. + """ + from cases.models import CaseStage + from leads.models import LeadStage + from tasks.models import TaskStage + + model = { + "lead_pipeline": LeadStage, + "case_pipeline": CaseStage, + "task_pipeline": TaskStage, + }[pipeline_key] + return {choice for choice, _label in model.STAGE_TYPE_CHOICES} + + +def _maps_to_status_choices(pipeline_key: str) -> set[str] | None: + """maps_to_status choices also differ per pipeline model. LeadStage maps + to LEAD_STATUS and CaseStage maps to STATUS_CHOICE (leads/models.py:293, + cases/models.py:551) — both plain tuples in common/utils.py, lazy-imported + for consistency with _stage_type_choices even though they don't touch the + app registry. TaskStage.maps_to_status has no `choices` at all + (tasks/models.py:257 — a freeform CharField), so there is no vocabulary to + validate task-pipeline stages against: this returns None and the caller + skips the check rather than inventing a restriction the model doesn't + have. + """ + from common.utils import LEAD_STATUS, STATUS_CHOICE + + choices = { + "lead_pipeline": LEAD_STATUS, + "case_pipeline": STATUS_CHOICE, + "task_pipeline": None, + }[pipeline_key] + return None if choices is None else {choice for choice, _label in choices} + + +def _validate_stage( + where: str, + stage: dict, + *, + allow_win_probability: bool, + valid_stage_types: set[str], + valid_maps_to_status: set[str] | None, +) -> None: + _reject_is_default(where, stage) + allowed = LEAD_STAGE_KEYS if allow_win_probability else STAGE_KEYS + _reject_unknown(where, stage, allowed) + if not stage.get("name"): + raise PackValidationError(f"{where}: name is required") + stage_type = stage.get("stage_type", "open") + if stage_type not in valid_stage_types: + raise PackValidationError(f"{where}: stage_type {stage_type!r} is not valid") + maps_to_status = stage.get("maps_to_status") + if maps_to_status and valid_maps_to_status is not None: + if maps_to_status not in valid_maps_to_status: + raise PackValidationError( + f"{where}: maps_to_status {maps_to_status!r} is not valid" + ) + + +def _validate_pipeline( + where: str, + pipeline: dict, + *, + pipeline_key: str, + allow_win_probability: bool, +) -> None: + _reject_is_default(where, pipeline) + _reject_unknown(where, pipeline, PIPELINE_KEYS) + if not pipeline.get("name"): + raise PackValidationError(f"{where}: name is required") + valid_stage_types = _stage_type_choices(pipeline_key) + valid_maps_to_status = _maps_to_status_choices(pipeline_key) + for i, stage in enumerate(pipeline.get("stages") or []): + _validate_stage( + f"{where}.stages[{i}]", + stage, + allow_win_probability=allow_win_probability, + valid_stage_types=valid_stage_types, + valid_maps_to_status=valid_maps_to_status, + ) + + +def _validate_custom_field(where: str, field: dict) -> None: + _reject_unknown(where, field, CUSTOM_FIELD_KEYS) + target = field.get("target") + if target not in SUPPORTED_TARGETS: + raise PackValidationError(f"{where}: target {target!r} is not a supported entity") + if not field.get("key"): + raise PackValidationError(f"{where}: key is required") + if not field.get("label"): + raise PackValidationError(f"{where}: label is required") + field_type = field.get("field_type") + if field_type not in FIELD_TYPES: + raise PackValidationError(f"{where}: field_type {field_type!r} is not valid") + # Reuse the exact validator the API uses, so a pack can never create a + # definition the API would reject. + try: + validate_definition_options(field_type, field.get("options")) + except Exception as exc: # DRF ValidationError + raise PackValidationError(f"{where}: {exc}") from exc + + +def _validate_tag(where: str, tag: dict) -> None: + from common.models import Tags + + _reject_unknown(where, tag, TAG_KEYS) + if not tag.get("name"): + raise PackValidationError(f"{where}: name is required") + color = tag.get("color", "blue") + valid = {choice for choice, _label in Tags.COLOR_CHOICES} + if color not in valid: + raise PackValidationError(f"{where}: color {color!r} is not a valid tag colour") + + +def _validate_product(where: str, product: dict) -> None: + _reject_unknown(where, product, PRODUCT_KEYS) + if not product.get("name"): + raise PackValidationError(f"{where}: name is required") + # Product.sku is nullable and Postgres allows unlimited NULLs in a unique + # index, so a sku-less product would double-create on every re-apply. + if not product.get("sku"): + raise PackValidationError(f"{where}: sku is required for pack products") + if "price" in product: + try: + Decimal(str(product["price"])) + except (InvalidOperation, TypeError): + raise PackValidationError(f"{where}: price is not a number") from None + + +def _require(where: str, obj: dict, *keys: str) -> None: + for key in keys: + if not obj.get(key): + raise PackValidationError(f"{where}: {key} is required") + + +def _validate_choice(where: str, value, field: str, allowed: set[str]) -> None: + """Reject a value the model's `choices` would reject. + + Django does not enforce `choices` on .create() — only full_clean() does, and + of the six sample models only Task calls it. So without this check a pack + could write a stage or status no UI can render and no filter can find, and + the row would look fine in the DB. Skips None/absent: every one of these + fields is optional unless _require said otherwise. + """ + if value is None: + return + if value not in allowed: + raise PackValidationError( + f"{where}: {field} {value!r} is not valid (expected one of {sorted(allowed)})" + ) + + +def _validate_date(where: str, value, field: str) -> None: + if value is None: + return + from datetime import date + + try: + date.fromisoformat(str(value)) + except ValueError: + raise PackValidationError( + f"{where}: {field} {value!r} is not an ISO date (YYYY-MM-DD)" + ) from None + + +def _reference_index(where: str, rows: list, key_of, label: str) -> set[str]: + """Build the set of names later entities may reference, rejecting duplicates. + + A reference is resolved by name, so two sample accounts called "Northwind" + would make `"account": "Northwind"` ambiguous — and the applier would pick + one arbitrarily. Catching it here keeps the reference scheme honest. + """ + seen: set[str] = set() + for i, row in enumerate(rows): + name = key_of(row) + if name in seen: + raise PackValidationError( + f"{where}[{i}]: duplicate {label} {name!r} — sample references are by name, " + "so each must be unique within the pack" + ) + seen.add(name) + return seen + + +def _check_reference(where: str, value, field: str, known: set[str], target: str) -> None: + if value is None: + return + if value not in known: + raise PackValidationError( + f"{where}: {field} {value!r} does not match any {target} in this pack" + ) + + +def _pipeline_stage_names(raw: dict, pipeline_key: str) -> set[str]: + spec = raw.get(pipeline_key) or {} + return {s.get("name") for s in (spec.get("stages") or []) if s.get("name")} + + +def _validate_sample_data(where: str, sample: dict, raw: dict) -> None: + """Validate sample_data, including every cross-entity name reference. + + References are checked against the pack's own lists, not the database, + because that is the only thing true at authoring time — and because the + applier deliberately resolves them the same way. A sample contact must + never attach itself to a tenant's real account (see _apply_sample_data), + so "resolvable" here means "declared in this pack" and nothing wider. + """ + from common.utils import ( + CASE_TYPE, + COUNTRIES, + CURRENCY_CODES, + INDCHOICES, + PRIORITY_CHOICE, + SOURCES, + STAGES, + STATUS_CHOICE, + ) + from tasks.models import Task + + _reject_unknown(where, sample, set(SAMPLE_ENTITY_KEYS)) + + countries = {c for c, _label in COUNTRIES} + currencies = {c for c, _label in CURRENCY_CODES} + industries = {c for c, _label in INDCHOICES} + + accounts = sample.get("accounts") or [] + contacts = sample.get("contacts") or [] + deals = sample.get("deals") or [] + tickets = sample.get("tickets") or [] + tasks = sample.get("tasks") or [] + leads = sample.get("leads") or [] + + def contact_name(row): + return f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() + + known_accounts = _reference_index( + f"{where}.accounts", accounts, lambda r: r.get("name"), "account name" + ) + known_contacts = _reference_index( + f"{where}.contacts", contacts, contact_name, "contact name" + ) + known_deals = _reference_index( + f"{where}.deals", deals, lambda r: r.get("name"), "deal name" + ) + known_tickets = _reference_index( + f"{where}.tickets", tickets, lambda r: r.get("name"), "ticket name" + ) + known_leads = _reference_index( + f"{where}.leads", leads, lambda r: r.get("title"), "lead title" + ) + + lead_stages = _pipeline_stage_names(raw, "lead_pipeline") + case_stages = _pipeline_stage_names(raw, "case_pipeline") + task_stages = _pipeline_stage_names(raw, "task_pipeline") + + for i, row in enumerate(accounts): + at = f"{where}.accounts[{i}]" + _reject_unknown(at, row, SAMPLE_ACCOUNT_KEYS) + _require(at, row, "name") + _validate_choice(at, row.get("industry"), "industry", industries) + _validate_choice(at, row.get("country"), "country", countries) + + for i, row in enumerate(contacts): + at = f"{where}.contacts[{i}]" + _reject_unknown(at, row, SAMPLE_CONTACT_KEYS) + _require(at, row, "first_name", "last_name") + _validate_choice(at, row.get("country"), "country", countries) + _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + + for i, row in enumerate(deals): + at = f"{where}.deals[{i}]" + _reject_unknown(at, row, SAMPLE_DEAL_KEYS) + _require(at, row, "name") + _validate_choice(at, row.get("stage"), "stage", {c for c, _l in STAGES}) + _validate_choice(at, row.get("lead_source"), "lead_source", {c for c, _l in SOURCES}) + _validate_choice(at, row.get("currency"), "currency", currencies) + _validate_date(at, row.get("closed_on"), "closed_on") + _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + + for i, row in enumerate(tickets): + at = f"{where}.tickets[{i}]" + _reject_unknown(at, row, SAMPLE_TICKET_KEYS) + # status and priority have no model default and are not nullable. + _require(at, row, "name", "status", "priority") + _validate_choice(at, row.get("status"), "status", {c for c, _l in STATUS_CHOICE}) + _validate_choice(at, row.get("priority"), "priority", {c for c, _l in PRIORITY_CHOICE}) + _validate_choice(at, row.get("case_type"), "case_type", {c for c, _l in CASE_TYPE}) + _validate_choice(at, row.get("stage"), "stage", case_stages) + _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + for name in row.get("contacts") or []: + _check_reference(at, name, "contacts", known_contacts, "sample contact") + + for i, row in enumerate(tasks): + at = f"{where}.tasks[{i}]" + _reject_unknown(at, row, SAMPLE_TASK_KEYS) + _require(at, row, "title", "status", "priority") + _validate_choice(at, row.get("status"), "status", {c for c, _l in Task.STATUS_CHOICES}) + _validate_choice( + at, row.get("priority"), "priority", {c for c, _l in Task.PRIORITY_CHOICES} + ) + _validate_choice(at, row.get("stage"), "stage", task_stages) + _validate_date(at, row.get("due_date"), "due_date") + parents = [p for p in SAMPLE_TASK_PARENTS if row.get(p)] + if len(parents) > 1: + raise PackValidationError( + f"{at}: a task may link to at most one parent, got {parents} " + "(tasks.Task.clean rejects more, and Task.save runs full_clean)" + ) + _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + _check_reference(at, row.get("deal"), "deal", known_deals, "sample deal") + _check_reference(at, row.get("ticket"), "ticket", known_tickets, "sample ticket") + _check_reference(at, row.get("lead"), "lead", known_leads, "sample lead") + + for i, row in enumerate(leads): + at = f"{where}.leads[{i}]" + _reject_unknown(at, row, SAMPLE_LEAD_KEYS) + _require(at, row, "title") + _validate_choice(at, row.get("stage"), "stage", lead_stages) + _validate_choice(at, row.get("currency"), "currency", currencies) + + +def validate_manifest(raw: dict) -> dict: + """Validate a manifest. Returns it unchanged, or raises PackValidationError.""" + _reject_unknown("manifest", raw, TOP_LEVEL_KEYS) + + for key in ("id", "name"): + if not raw.get(key): + raise PackValidationError(f"manifest: {key} is required") + if not isinstance(raw.get("version"), int): + raise PackValidationError("manifest: version must be an integer") + + terminology = raw.get("terminology") or {} + if not isinstance(terminology, dict): + raise PackValidationError("manifest.terminology: expected an object") + for k, v in terminology.items(): + if not isinstance(v, str) or len(v) > 64: + raise PackValidationError( + f"manifest.terminology[{k!r}]: must be a string of at most 64 characters" + ) + + for key, allow_wp in ( + ("lead_pipeline", True), + ("case_pipeline", False), + ("task_pipeline", False), + ): + if key in raw: + _validate_pipeline( + f"manifest.{key}", + raw[key], + pipeline_key=key, + allow_win_probability=allow_wp, + ) + + for i, field in enumerate(raw.get("custom_fields") or []): + _validate_custom_field(f"manifest.custom_fields[{i}]", field) + for i, tag in enumerate(raw.get("tags") or []): + _validate_tag(f"manifest.tags[{i}]", tag) + for i, product in enumerate(raw.get("products") or []): + _validate_product(f"manifest.products[{i}]", product) + + if "sample_data" in raw: + # Passes the whole manifest: sample rows name stages that live in the + # pipeline sections, so the reference check needs both halves. + _validate_sample_data("manifest.sample_data", raw["sample_data"], raw) + + return raw diff --git a/backend/common/rls/__init__.py b/backend/common/rls/__init__.py index fc57bdea..e4233999 100644 --- a/backend/common/rls/__init__.py +++ b/backend/common/rls/__init__.py @@ -64,6 +64,16 @@ ORG_SCOPED_TABLES = [ "email_message", "routing_rule", "routing_rule_state", + # Kanban pipelines and their stages. All six carry org_id directly. + # These were stamped out-of-band on existing databases but were absent from + # this list and from every migration, so a database built purely from + # migrations got no policies at all. common/0034 stamps them from source. + "lead_pipeline", + "lead_stage", + "case_pipeline", + "case_stage", + "task_pipeline", + "task_stage", # Boards (Kanban) "board", "board_column", @@ -71,6 +81,7 @@ ORG_SCOPED_TABLES = [ "board_member", # Settings "apiSettings", # Note: camelCase + "pack_application", # Email & Invoicing "account_email", "emailLogs", diff --git a/backend/common/serializer.py b/backend/common/serializer.py index 58960551..d47fe7be 100644 --- a/backend/common/serializer.py +++ b/backend/common/serializer.py @@ -121,6 +121,13 @@ class OrgSettingsSerializer(serializers.ModelSerializer): `csat_enabled` and `auto_close_children_on_parent_close` are real `Org` columns that previously had NO write path through any serializer; this is where they are edited. `member_count` and `created_at` are read-only header context. + + `vertical` and `terminology` are read-only here too: they are written exclusively + by the vertical-pack applier (`common/packs/applier.py`), never by a client. A + writable path would let any admin PATCH `terminology` to arbitrary tenant-supplied + text (still safe to render -- see `$lib/terminology.js` on the frontend -- but not + something this endpoint is meant to let anyone set by hand) or overwrite `vertical`, + which is meant to record which pack was actually applied. """ currency_symbol = serializers.SerializerMethodField() @@ -152,6 +159,9 @@ class OrgSettingsSerializer(serializers.ModelSerializer): # Case-handling behaviour (org-wide switches). Editable only here. "csat_enabled", "auto_close_children_on_parent_close", + # Vertical pack. Read-only -- see the class docstring. + "vertical", + "terminology", # Read-only context for the settings page header. "member_count", "created_at", @@ -162,6 +172,8 @@ class OrgSettingsSerializer(serializers.ModelSerializer): "logo_url", "member_count", "created_at", + "vertical", + "terminology", ] @extend_schema_field(str) @@ -404,7 +416,15 @@ class OrgProfileCreateSerializer(serializers.ModelSerializer): class Meta: model = Org - fields = ["name"] + # "id" is Org's UUIDField(editable=False) primary key -- DRF makes it + # read-only automatically because of `editable=False`, so listing it + # here only exposes the created org's id in the response; it does not + # open a new mass-assignment surface (a client-supplied "id" in the + # request body is still ignored, same as before this field existed). + # Callers (org/new/+page.server.js) need this id to switch the + # session into the org they just created and to apply a vertical + # pack to it -- see the C1 fix in the vertical-packs final review. + fields = ["id", "name"] extra_kwargs = {"name": {"required": True}} def validate_name(self, name): diff --git a/backend/common/tests/test_custom_fields.py b/backend/common/tests/test_custom_fields.py index afef7de7..3def9d19 100644 --- a/backend/common/tests/test_custom_fields.py +++ b/backend/common/tests/test_custom_fields.py @@ -202,6 +202,38 @@ class TestCustomFieldDefinitionAPI: keys = [d["key"] for d in response.json()["definitions"]] assert keys == ["a"] + def test_get_include_counts_false_omits_the_expensive_counts( + self, admin_client, org_a + ): + """`records_missing_value` is one COUNT over every record of the target + model per definition. Record pages fetch definitions only for labels and + types, so they opt out; the payload must then carry neither the per-row + count nor the totals block.""" + CustomFieldDefinition.objects.create( + org=org_a, target_model="Lead", key="budget", label="Budget", field_type="number" + ) + response = admin_client.get( + self.URL + "?target_model=Lead&include_counts=false" + ) + assert response.status_code == 200 + body = response.json() + assert len(body["definitions"]) == 1 + assert body["definitions"][0]["key"] == "budget" + assert "records_missing_value" not in body["definitions"][0] + assert body["totals"] is None + + def test_get_counts_included_by_default(self, admin_client, org_a): + """The opt-out must be opt-in: an unqualified GET, and an explicit + anything-other-than-"false", keep the settings page's numbers.""" + CustomFieldDefinition.objects.create( + org=org_a, target_model="Lead", key="budget", label="Budget", field_type="number" + ) + for query in ("?target_model=Lead", "?target_model=Lead&include_counts=true"): + body = admin_client.get(self.URL + query).json() + assert "records_missing_value" in body["definitions"][0], query + assert body["totals"] is not None, query + assert body["totals"]["count"] == 1, query + def test_get_allowed_for_non_admin(self, user_client, org_a): # Non-admins read definitions so the case detail page can render fields. CustomFieldDefinition.objects.create( diff --git a/backend/common/tests/test_organizations.py b/backend/common/tests/test_organizations.py index 905a4789..f180b199 100644 --- a/backend/common/tests/test_organizations.py +++ b/backend/common/tests/test_organizations.py @@ -32,6 +32,50 @@ class TestOrgProfileCreateView: user=admin_user, org__name="New Organization", role="ADMIN" ).exists() + def test_create_org_response_includes_id(self, admin_client, admin_user): + """The created org's id must be in the response. + + The frontend (org/new/+page.server.js) reads `org.id` off this + response to set the `org` cookie and to mint an org-scoped token for + the optional vertical-pack apply call. Before this field was added + to OrgProfileCreateSerializer.Meta.fields, `org.id` was `undefined` + here and every downstream use of it silently no-opped (C1). + """ + response = admin_client.post( + self.url, + {"name": "Org With Id"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert "id" in response.data["org"] + assert response.data["org"]["id"] is not None + created = Profile.objects.get( + user=admin_user, org__name="Org With Id" + ).org + assert str(response.data["org"]["id"]) == str(created.id) + + def test_create_org_ignores_client_supplied_id(self, admin_client, admin_user): + """A client-supplied "id" in the request body must not be honoured. + + `id` is a UUIDField(editable=False), which DRF marks read-only + automatically -- this pins that guarantee so adding "id" to the + serializer's fields for C1 never regresses into a mass-assignment + hole (client picking its own org id / colliding with another org). + """ + spoofed_id = "11111111-1111-1111-1111-111111111111" + response = admin_client.post( + self.url, + {"name": "Org Ignoring Spoofed Id", "id": spoofed_id}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["org"]["id"] != spoofed_id + created = Profile.objects.get( + user=admin_user, org__name="Org Ignoring Spoofed Id" + ).org + assert str(created.id) != spoofed_id + assert str(response.data["org"]["id"]) == str(created.id) + def test_create_org_special_chars(self, admin_client): """Organization name with special characters should fail.""" response = admin_client.post( @@ -355,6 +399,20 @@ class TestOrgSettingsView: # admin_profile + user_profile are both active in org_a. assert response.data["member_count"] == 2 + def test_get_org_settings_includes_vertical_and_terminology( + self, admin_client, org_a + ): + """The frontend shell reads terminology/vertical off this same endpoint + (no second fetch) -- both must be present in the payload, and reflect + whatever the pack applier actually wrote.""" + org_a.vertical = "real-estate" + org_a.terminology = {"lead.plural": "Enquiries"} + org_a.save(update_fields=["vertical", "terminology"]) + response = admin_client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data["vertical"] == "real-estate" + assert response.data["terminology"] == {"lead.plural": "Enquiries"} + # ── Writes: the behaviour toggles that had no API write path before ────── def test_patch_org_settings_behaviour_toggles(self, admin_client, org_a): @@ -405,6 +463,28 @@ class TestOrgSettingsView: # The legitimate field in the same request still applied. assert org_a.company_name == "Legit Co" + def test_patch_org_settings_cannot_set_terminology_or_vertical( + self, admin_client, org_a + ): + """vertical and terminology are written exclusively by the pack applier. + A PATCH naming them -- even from an admin -- must be silently ignored, + not honoured: this is mass assignment on fields the server derives.""" + response = admin_client.patch( + self.url, + { + "vertical": "attacker-chosen-vertical", + "terminology": {"lead.plural": "Hacked"}, + "company_name": "Legit Co", + }, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + org_a.refresh_from_db() + assert org_a.vertical == "" + assert org_a.terminology == {} + # The legitimate field in the same request still applied. + assert org_a.company_name == "Legit Co" + def test_patch_org_settings_cannot_deactivate_org(self, admin_client, org_a): """is_active (the org kill switch) is not editable through the settings form -- an org cannot deactivate itself here.""" diff --git a/backend/common/tests/test_packs_api.py b/backend/common/tests/test_packs_api.py new file mode 100644 index 00000000..822c7ce5 --- /dev/null +++ b/backend/common/tests/test_packs_api.py @@ -0,0 +1,149 @@ +import pytest + +from common.middleware.rls_context import RequireOrgContext +from common.models import User + + +@pytest.fixture +def orgless_user(): + return User.objects.create_user(email="noorg@test.com", password="testpass123") + + +@pytest.fixture +def orgless_client(orgless_user): + """ + An authenticated JWT client whose token carries NO org_id claim — the + shape of a user who has signed up but not yet created or joined an org. + This is the exact audience GET /api/packs/ must serve (the pack chooser + lives on the org-creation page); it's also the audience the write and + destroy pack endpoints must keep rejecting. + """ + from conftest import _make_authenticated_client + + return _make_authenticated_client(orgless_user, None, None) + + +@pytest.mark.django_db +class TestPackAPI: + def test_list_is_available_to_any_member(self, user_client): + res = user_client.get("/api/packs/") + assert res.status_code == 200 + assert {p["id"] for p in res.json()["packs"]} >= {"real-estate"} + + def test_admin_can_apply(self, admin_client, org_a): + res = admin_client.post("/api/packs/real-estate/apply/") + assert res.status_code == 200 + assert res.json()["report"]["created"] + + def test_non_admin_cannot_apply(self, user_client): + res = user_client.post("/api/packs/real-estate/apply/") + assert res.status_code == 403 + + def test_non_admin_cannot_clear_sample_data(self, user_client): + res = user_client.delete("/api/packs/sample-data/") + assert res.status_code == 403 + + def test_unknown_pack_is_404(self, admin_client): + assert admin_client.post("/api/packs/nope/apply/").status_code == 404 + + def test_traversal_pack_id_is_404_not_a_file_read(self, admin_client): + # %2F decodes to "/" before URL resolution, so this never reaches + # PackApplyView at all — it 404s at the Django router, as a plain + # HTML page, not the view's {"error": ..., "errors": "Unknown pack."} + # JSON shape. That still proves no file read happened, but it does + # NOT exercise get_pack()/the registry lookup this test is named for. + res = admin_client.post("/api/packs/..%2F..%2Fetc%2Fpasswd/apply/") + assert res.status_code == 404 + + # A slash-free traversal-shaped id (backslash-encoded, so the router + # treats it as one path segment) DOES reach the view and get_pack(). + # This is what actually proves the registry lookup — a dict get() — + # is what turns a path-shaped id into a 404, not a filesystem call. + res = admin_client.post("/api/packs/..%5C..%5Cwindows%5Cwin.ini/apply/") + assert res.status_code == 404 + assert res.json() == {"error": True, "errors": "Unknown pack."} + + def test_org_in_body_is_ignored(self, admin_client, org_a, org_b): + res = admin_client.post( + "/api/packs/real-estate/apply/", {"org": str(org_b.id)} + ) + assert res.status_code == 200 + from leads.models import LeadPipeline + + assert LeadPipeline.objects.filter(org=org_b).count() == 0 + assert LeadPipeline.objects.filter(org=org_a).count() == 1 + + def test_unauthenticated_is_rejected(self, unauthenticated_client): + assert unauthenticated_client.get("/api/packs/").status_code in (401, 403) + + def test_admin_can_clear_sample_data(self, admin_client, org_a): + admin_client.post("/api/packs/real-estate/apply/") + res = admin_client.delete("/api/packs/sample-data/") + assert res.status_code == 200 + assert res.json()["deleted"] >= 8 + + def test_clear_sample_data_does_not_touch_another_org( + self, admin_client, org_b_client, org_a, org_b + ): + # org_b_client is org_b's own ADMIN (profile_b, role=ADMIN — see + # conftest.py), not admin_client's org_a profile: applying to org_b + # must use an org_b actor now that apply_pack rejects a mismatched + # (org, actor) pair. + admin_client.post("/api/packs/real-estate/apply/") + org_b_client.post("/api/packs/real-estate/apply/") + + res = org_b_client.delete("/api/packs/sample-data/") + assert res.status_code == 200 + + from leads.models import Lead + + assert Lead.objects.filter(org=org_b, is_sample=True).count() == 0 + assert Lead.objects.filter(org=org_a, is_sample=True).count() >= 8 + + def test_org_in_delete_body_is_ignored(self, admin_client, org_a, org_b): + admin_client.post("/api/packs/real-estate/apply/") + + res = admin_client.delete( + "/api/packs/sample-data/", {"org": str(org_b.id)}, format="json" + ) + assert res.status_code == 200 + + from leads.models import Lead + + # The delete acted on request.profile.org (org_a) regardless of the + # org id smuggled into the body. + assert Lead.objects.filter(org=org_a, is_sample=True).count() == 0 + + +@pytest.mark.django_db +class TestPackListNoOrgContext: + """ + GET /api/packs/ must render for a user who has no org yet (the pack + chooser lives on the org-creation page, before any org_id claim exists), + while POST .../apply/ and DELETE .../sample-data/ must keep requiring + org context for that same user. This is the RequireOrgContext exact-match + exemption (EXEMPT_EXACT_PATHS) plus PackListView dropping HasOrgContext. + """ + + def test_list_available_with_no_org_context(self, orgless_client): + res = orgless_client.get("/api/packs/") + assert res.status_code == 200 + assert {p["id"] for p in res.json()["packs"]} >= {"real-estate"} + + def test_apply_still_requires_org_context(self, orgless_client): + # Must NOT have become reachable just because list did. + res = orgless_client.post("/api/packs/real-estate/apply/") + assert res.status_code == 403 + + def test_sample_data_delete_still_requires_org_context(self, orgless_client): + res = orgless_client.delete("/api/packs/sample-data/") + assert res.status_code == 403 + + def test_packs_is_exempt_by_exact_match_only(self): + # Pins the exemption to the exact-match list. If a later refactor + # moves "/api/packs/" into the prefix-matched EXEMPT_PATHS, this + # fails — and so would the two tests above, since a prefix match + # would also exempt /api/packs//apply/ and + # /api/packs/sample-data/ from tenant-context enforcement. + assert "/api/packs/" in RequireOrgContext.EXEMPT_EXACT_PATHS + assert "/api/packs/" not in RequireOrgContext.EXEMPT_PATHS diff --git a/backend/common/tests/test_packs_applier.py b/backend/common/tests/test_packs_applier.py new file mode 100644 index 00000000..d5b06ab0 --- /dev/null +++ b/backend/common/tests/test_packs_applier.py @@ -0,0 +1,160 @@ +import pytest + +from common.models import CustomFieldDefinition, Tags +from common.packs.applier import apply_pack +from common.packs.loader import get_pack +from leads.models import LeadPipeline, LeadStage + + +@pytest.mark.django_db +def test_apply_creates_every_element_from_the_manifest(org_a, admin_profile): + pack = get_pack("real-estate") + report = apply_pack(org_a, pack, admin_profile) + + # Manifest↔database equivalence: counts per type must match the manifest. + assert LeadPipeline.objects.filter(org=org_a).count() == 1 + assert LeadStage.objects.filter(org=org_a).count() == len(pack["lead_pipeline"]["stages"]) + assert CustomFieldDefinition.objects.filter(org=org_a).count() == len(pack["custom_fields"]) + # +1 for the reserved "Sample data" tag the applier creates alongside any + # pack that carries a sample_data block (common/packs/applier.py). + sample_tag_count = 1 if (pack.get("sample_data") or {}).get("leads") else 0 + assert Tags.objects.filter(org=org_a).count() == len(pack["tags"]) + sample_tag_count + assert report["failed"] == [] + + +@pytest.mark.django_db +def test_applied_stage_attributes_match_the_manifest(org_a, admin_profile): + pack = get_pack("real-estate") + apply_pack(org_a, pack, admin_profile) + for spec in pack["lead_pipeline"]["stages"]: + stage = LeadStage.objects.get(org=org_a, name=spec["name"]) + assert stage.order == spec["order"] + assert stage.stage_type == spec["stage_type"] + assert stage.color == spec["color"] + + +@pytest.mark.django_db +def test_second_apply_creates_nothing_and_mutates_nothing(org_a, admin_profile): + pack = get_pack("real-estate") + apply_pack(org_a, pack, admin_profile) + org_a.refresh_from_db() + + before = { + (t.__name__, str(row.id)): row.updated_at + for t in (LeadPipeline, LeadStage, CustomFieldDefinition, Tags) + for row in t.objects.filter(org=org_a) + } + # Org is the one row the applier IS allowed to write — the invariant only + # holds if a no-op second apply leaves it untouched too. + before_org = (org_a.vertical, dict(org_a.terminology)) + + report = apply_pack(org_a, pack, admin_profile) + + after = { + (t.__name__, str(row.id)): row.updated_at + for t in (LeadPipeline, LeadStage, CustomFieldDefinition, Tags) + for row in t.objects.filter(org=org_a) + } + org_a.refresh_from_db() + after_org = (org_a.vertical, dict(org_a.terminology)) + + assert report["created"] == [] + assert report["skipped"] + # A report saying "skipped" does not prove nothing was written. This does. + assert before == after + assert before_org == after_org + + +@pytest.mark.django_db +def test_stale_org_reference_does_not_clobber_a_prior_apply(org_a, admin_profile): + """No concurrency needed to reproduce this — just two Org references. + + `stale` is loaded before either apply_pack call, so it never observes + real-estate's terminology/vertical write. If _apply_terminology decides + from that stale in-memory snapshot instead of re-reading the row inside + its own transaction, applying education-admissions through `stale` + overwrites (not merges) real-estate's already-persisted terminology and + clobbers org.vertical back to "" -> "education-admissions". + """ + from common.models import Org + + stale = Org.objects.get(pk=org_a.pk) + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + apply_pack(stale, get_pack("education-admissions"), admin_profile) + + org_a.refresh_from_db() + assert org_a.vertical == "real-estate" # first pack's id, not clobbered + # real-estate's keys must survive the second (stale-object) apply. + assert org_a.terminology["opportunity.singular"] == "Deal" + assert org_a.terminology["opportunity.plural"] == "Deals" + # education-admissions' own keys were still added. + assert org_a.terminology["contact.singular"] == "Student" + + +@pytest.mark.django_db +def test_existing_pipeline_of_same_name_skips_its_stages_too(org_a, admin_profile): + LeadPipeline.objects.create(org=org_a, name="Sales") + report = apply_pack(org_a, get_pack("real-estate"), admin_profile) + + assert LeadStage.objects.filter(org=org_a).count() == 0 + assert any(e["type"] == "lead_pipeline" for e in report["skipped"]) + + +@pytest.mark.django_db +def test_applier_never_sets_a_default_pipeline(org_a, admin_profile): + apply_pack(org_a, get_pack("real-estate"), admin_profile) + assert not LeadPipeline.objects.filter(org=org_a, is_default=True).exists() + + +@pytest.mark.django_db +def test_apply_writes_terminology_and_vertical(org_a, admin_profile): + pack = get_pack("real-estate") + apply_pack(org_a, pack, admin_profile) + org_a.refresh_from_db() + assert org_a.vertical == "real-estate" + assert org_a.terminology["lead.plural"] == "Enquiries" + + +@pytest.mark.django_db +def test_terminology_is_not_overwritten_on_reapply(org_a, admin_profile): + apply_pack(org_a, get_pack("real-estate"), admin_profile) + org_a.terminology["lead.plural"] = "Leads" + org_a.save(update_fields=["terminology"]) + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + org_a.refresh_from_db() + assert org_a.terminology["lead.plural"] == "Leads" # admin's edit wins + + +@pytest.mark.django_db +def test_apply_records_a_pack_application(org_a, admin_profile): + from common.models import PackApplication + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + record = PackApplication.objects.get(org=org_a, pack_id="real-estate") + assert record.applied_by_id == admin_profile.id + assert record.result["created"] + + +@pytest.mark.django_db +def test_apply_pack_rejects_an_actor_from_a_different_org(org_a, org_b, admin_profile): + """admin_profile belongs to org_a. Applying to org_b with that actor must + be rejected outright, not silently create cross-org lead assignments — + _apply_sample_data does lead.assigned_to.add(actor) with no org check of + its own, so before this guard existed apply_pack(org_b, pack, admin_profile) + assigned every sample lead it created in org_b to an org_a profile. + """ + from leads.models import Lead + + with pytest.raises(ValueError): + apply_pack(org_b, get_pack("real-estate"), admin_profile) + + assert not Lead.objects.filter(org=org_b).exists() + + +@pytest.mark.django_db +def test_apply_pack_allows_actor_from_the_same_org(org_a, admin_profile): + # Sanity check for the guard above: it must not reject the ordinary case. + report = apply_pack(org_a, get_pack("real-estate"), admin_profile) + assert report["created"] diff --git a/backend/common/tests/test_packs_content.py b/backend/common/tests/test_packs_content.py new file mode 100644 index 00000000..32e01c37 --- /dev/null +++ b/backend/common/tests/test_packs_content.py @@ -0,0 +1,88 @@ +import pytest + +from common.packs.loader import get_registry +from common.packs.schema import SAMPLE_ENTITY_KEYS + +SHIPPED = {"real-estate", "education-admissions", "professional-services"} + + +def test_all_shipped_packs_load_and_validate(): + # get_registry() validates every file; this is the CI gate that a malformed + # pack fails here rather than at a user's signup. + assert SHIPPED <= set(get_registry()) + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_pack_has_terminology_and_a_lead_pipeline(pack_id): + pack = get_registry()[pack_id] + assert pack["terminology"], f"{pack_id} must rename at least one label" + assert pack["lead_pipeline"]["stages"], f"{pack_id} needs lead stages" + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_lead_pipeline_has_exactly_one_won_and_one_lost_stage(pack_id): + stages = get_registry()[pack_id]["lead_pipeline"]["stages"] + types = [s.get("stage_type", "open") for s in stages] + assert types.count("won") == 1 + assert types.count("lost") == 1 + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_sample_data_has_a_plausible_number_of_leads(pack_id): + # Enough to fill a list page and make the kanban look worked-in, without + # making the demo org feel like a data dump the user has to clear out. + leads = get_registry()[pack_id]["sample_data"]["leads"] + assert 20 <= len(leads) <= 40, f"{pack_id} has {len(leads)} sample leads" + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_pack_ships_all_three_pipelines(pack_id): + """A pack that configures only leads leaves the tickets and tasks boards + empty of columns, which reads as a broken feature rather than an unused one. + """ + pack = get_registry()[pack_id] + for key in ("lead_pipeline", "case_pipeline", "task_pipeline"): + assert pack.get(key, {}).get("stages"), f"{pack_id} is missing {key}" + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_products_have_unique_skus(pack_id): + # (org, sku) is the applier's match key. Two products sharing a sku inside + # one pack would make the second a permanent skip. + skus = [p["sku"] for p in get_registry()[pack_id].get("products") or []] + assert skus, f"{pack_id} ships no products" + assert len(skus) == len(set(skus)), f"{pack_id} has duplicate skus" + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_sample_data_covers_every_entity_type(pack_id): + """Every module the nav offers should have something in it after an apply. + + A pack with leads but no accounts leaves most of the CRM empty, which is the + gap this content set exists to close. + """ + sample = get_registry()[pack_id]["sample_data"] + for key in SAMPLE_ENTITY_KEYS: + assert sample.get(key), f"{pack_id} has no sample {key}" + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_every_sample_task_hangs_off_something(pack_id): + """An unparented task is legal but reads as orphaned in the UI.""" + pack = get_registry()[pack_id] + for task in pack["sample_data"]["tasks"]: + parents = [p for p in ("account", "deal", "ticket", "lead") if task.get(p)] + assert len(parents) == 1, ( + f"{pack_id}: sample task {task['title']!r} has parents {parents}, expected exactly one" + ) + + +@pytest.mark.parametrize("pack_id", sorted(SHIPPED)) +def test_every_sample_lead_stage_matches_the_packs_own_pipeline(pack_id): + pack = get_registry()[pack_id] + stage_names = {s["name"] for s in pack["lead_pipeline"]["stages"]} + for lead in pack["sample_data"]["leads"]: + assert lead.get("stage") in stage_names, ( + f"{pack_id}: sample lead {lead['title']!r} has stage " + f"{lead.get('stage')!r}, not one of {sorted(stage_names)}" + ) diff --git a/backend/common/tests/test_packs_loader.py b/backend/common/tests/test_packs_loader.py new file mode 100644 index 00000000..38689737 --- /dev/null +++ b/backend/common/tests/test_packs_loader.py @@ -0,0 +1,112 @@ +import pytest + +from common.packs.loader import get_pack, get_registry +from common.packs.schema import PackValidationError, validate_manifest + + +def _minimal(): + return {"id": "demo", "version": 1, "name": "Demo"} + + +def test_minimal_manifest_validates(): + assert validate_manifest(_minimal())["id"] == "demo" + + +def test_unknown_top_level_key_is_rejected(): + raw = _minimal() | {"wat": 1} + with pytest.raises(PackValidationError, match="wat"): + validate_manifest(raw) + + +def test_is_default_is_rejected_anywhere(): + raw = _minimal() | { + "lead_pipeline": {"name": "Sales", "is_default": True, "stages": []} + } + with pytest.raises(PackValidationError, match="is_default"): + validate_manifest(raw) + + +def test_product_without_sku_is_rejected(): + raw = _minimal() | {"products": [{"name": "Course", "price": "100.00"}]} + with pytest.raises(PackValidationError, match="sku"): + validate_manifest(raw) + + +def test_unknown_custom_field_target_is_rejected(): + raw = _minimal() | { + "custom_fields": [ + {"target": "Property", "key": "x", "label": "X", "field_type": "text"} + ] + } + with pytest.raises(PackValidationError, match="Property"): + validate_manifest(raw) + + +def test_win_probability_rejected_on_non_lead_stage(): + raw = _minimal() | { + "case_pipeline": { + "name": "Support", + "stages": [{"name": "New", "win_probability": 50}], + } + } + with pytest.raises(PackValidationError, match="win_probability"): + validate_manifest(raw) + + +def test_unknown_tag_color_is_rejected(): + raw = _minimal() | {"tags": [{"name": "Hot", "color": "chartreuse"}]} + with pytest.raises(PackValidationError, match="chartreuse"): + validate_manifest(raw) + + +def test_lead_stage_accepts_won(): + raw = _minimal() | { + "lead_pipeline": { + "name": "Sales", + "stages": [{"name": "Closed Won", "stage_type": "won"}], + } + } + stages = validate_manifest(raw)["lead_pipeline"]["stages"] + assert stages[0]["stage_type"] == "won" + + +def test_case_stage_accepts_closed(): + raw = _minimal() | { + "case_pipeline": { + "name": "Support", + "stages": [{"name": "Resolved", "stage_type": "closed"}], + } + } + stages = validate_manifest(raw)["case_pipeline"]["stages"] + assert stages[0]["stage_type"] == "closed" + + +def test_case_stage_rejects_won(): + raw = _minimal() | { + "case_pipeline": { + "name": "Support", + "stages": [{"name": "Resolved", "stage_type": "won"}], + } + } + with pytest.raises(PackValidationError, match="stage_type"): + validate_manifest(raw) + + +def test_task_stage_accepts_in_progress(): + raw = _minimal() | { + "task_pipeline": { + "name": "Delivery", + "stages": [{"name": "Doing", "stage_type": "in_progress"}], + } + } + stages = validate_manifest(raw)["task_pipeline"]["stages"] + assert stages[0]["stage_type"] == "in_progress" + + +def test_registry_lookup_of_unknown_id_returns_none(): + assert get_pack("no-such-pack") is None + + +def test_registry_cannot_be_traversed(): + assert get_pack("../../etc/passwd") is None + assert get_pack("real-estate/../demo") is None diff --git a/backend/common/tests/test_packs_models.py b/backend/common/tests/test_packs_models.py new file mode 100644 index 00000000..1a6719f4 --- /dev/null +++ b/backend/common/tests/test_packs_models.py @@ -0,0 +1,28 @@ +import pytest + +from common.models import Org, PackApplication +from common.rls import ORG_SCOPED_TABLES + + +@pytest.mark.django_db +def test_org_has_vertical_and_terminology_defaults(): + org = Org.objects.create(name="Acme") + assert org.vertical == "" + assert org.terminology == {} + + +@pytest.mark.django_db +def test_pack_application_records_result(org_a, admin_profile): + app = PackApplication.objects.create( + org=org_a, + pack_id="real-estate", + pack_version=1, + applied_by=admin_profile, + result={"created": [], "skipped": [], "failed": []}, + ) + assert app.org_id == org_a.id + assert app.result["created"] == [] + + +def test_pack_application_is_registered_for_rls(): + assert "pack_application" in ORG_SCOPED_TABLES diff --git a/backend/common/tests/test_packs_sample_data.py b/backend/common/tests/test_packs_sample_data.py new file mode 100644 index 00000000..ce19dcfd --- /dev/null +++ b/backend/common/tests/test_packs_sample_data.py @@ -0,0 +1,362 @@ +import pytest + +from common.models import Tags +from common.packs.applier import apply_pack, clear_sample_data +from common.packs.loader import get_pack +from leads.models import Lead, LeadPipeline + +SAMPLE_SLUG = "sample-data" + + +@pytest.mark.django_db +def test_apply_creates_sample_leads_tagged_as_sample(org_a, admin_profile): + apply_pack(org_a, get_pack("real-estate"), admin_profile) + leads = Lead.objects.filter(org=org_a) + assert leads.count() >= 8 + assert all(lead.tags.filter(slug=SAMPLE_SLUG).exists() for lead in leads) + assert all(lead.is_sample for lead in leads) + + +@pytest.mark.django_db +def test_clear_removes_only_sample_rows(org_a, admin_profile): + apply_pack(org_a, get_pack("real-estate"), admin_profile) + real = Lead.objects.create(org=org_a, title="A real enquiry", first_name="Real") + + result = clear_sample_data(org_a) + + assert result["deleted"]["sample_lead"] >= 8 + assert result["retained"] == {} + assert Lead.objects.filter(org=org_a).count() == 1 + assert Lead.objects.filter(org=org_a).first().id == real.id + # The reserved tag is a label only now — clear_sample_data must never + # delete it (Critical fix: it also must never be the *deletion key*; see + # test_clear_does_not_delete_a_real_lead_that_adopted_the_sample_tag + # below). This assertion is intentionally the inverse of the original + # brief's version, which had clear_sample_data delete the tag too. + assert Tags.objects.filter(org=org_a, slug=SAMPLE_SLUG).exists() + + +@pytest.mark.django_db +def test_clear_does_not_touch_another_org(org_a, org_b, admin_profile, profile_b): + # Each org is applied with an actor from that same org — apply_pack now + # rejects a mismatched (org, actor) pair (see the org guard in + # applier.py), so org_b's apply uses profile_b, not admin_profile. + apply_pack(org_a, get_pack("real-estate"), admin_profile) + apply_pack(org_b, get_pack("real-estate"), profile_b) + + clear_sample_data(org_a) + + assert Lead.objects.filter(org=org_b).count() >= 8 + + +@pytest.mark.django_db +def test_clear_does_not_delete_a_real_lead_that_adopted_the_sample_tag( + org_a, admin_profile +): + """Regression for the Critical finding: clear_sample_data must key off + Lead.is_sample, never the "Sample data" tag. Tags.save() recomputes its + slug on every save and the tag CRUD endpoints have no reserved-slug + guard, so a real lead can end up carrying this tag several ways — a + member attaching it from the ordinary tag picker (reproduced here), a + tag getting renamed onto this slug, or a user's own pre-existing + "Sample data" tag being adopted by an apply. None of those may destroy + the lead. + """ + apply_pack(org_a, get_pack("real-estate"), admin_profile) + sample_tag = Tags.objects.get(org=org_a, slug=SAMPLE_SLUG) + + real = Lead.objects.create(org=org_a, title="A real enquiry", first_name="Real") + real.tags.add(sample_tag) # the ordinary tag picker, not the applier + + result = clear_sample_data(org_a) + + # the genuine sample leads are still cleared + assert result["deleted"]["sample_lead"] >= 8 + assert Lead.objects.filter(org=org_a, id=real.id).exists() + real.refresh_from_db() + assert real.is_sample is False + + +@pytest.mark.django_db +def test_a_colliding_email_skips_only_that_sample_lead(org_a, admin_profile): + """Regression for Important 2: a pre-existing lead sharing a sample + lead's email must not roll back the whole apply. Without a savepoint per + lead, unique_lead_email_per_org's IntegrityError propagated out of + apply_pack's outer @transaction.atomic and rolled back every + pipeline/tag/custom-field write the same call had already made. + """ + pack = get_pack("real-estate") + colliding_email = pack["sample_data"]["leads"][0]["email"] + Lead.objects.create(org=org_a, title="Already here", email=colliding_email) + + report = apply_pack(org_a, pack, admin_profile) + + assert LeadPipeline.objects.filter(org=org_a).exists() # not rolled back + created = [e for e in report["created"] if e["type"] == "sample_lead"] + skipped = [e for e in report["skipped"] if e["type"] == "sample_lead"] + assert len(created) == len(pack["sample_data"]["leads"]) - 1 + assert len(skipped) == 1 + + +@pytest.mark.django_db +def test_apply_with_no_actor_still_creates_leads_unassigned(org_a): + """Regression: actor is optional (mirrors PackApplication.applied_by + being nullable). Before the fix, `lead.assigned_to.add(None)` on + Postgres raised a NOT NULL violation on the through row that the + per-lead except clause misdiagnosed as "duplicate email" and swallowed — + apply_pack(org, pack, None) created zero leads and reported all of them + skipped with a false reason. On SQLite (this whole suite) the same call + was a silent no-op, so this test could not have caught the Postgres bug + by itself — it only pins the now-intended behavior: creation must + succeed, and no lead ends up with a None entry in assigned_to. + """ + pack = get_pack("real-estate") + report = apply_pack(org_a, pack, None) + + created = [e for e in report["created"] if e["type"] == "sample_lead"] + assert len(created) == len(pack["sample_data"]["leads"]) + assert report["skipped"] == [] or all( + e["type"] != "sample_lead" for e in report["skipped"] + ) + + leads = Lead.objects.filter(org=org_a, is_sample=True) + assert leads.count() == len(pack["sample_data"]["leads"]) + for lead in leads: + assert lead.assigned_to.count() == 0 + + +@pytest.mark.django_db +def test_clear_sample_data_writes_an_audit_record(org_a, admin_profile): + from common.audit_log import SecurityAuditLog + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + result = clear_sample_data(org_a, admin_profile) + + record = SecurityAuditLog.objects.filter( + event_type="SAMPLE_DATA_CLEARED", org=org_a + ).latest("created_at") + assert record.user_id == admin_profile.user_id + # The audit record carries the total across every sample model, which is + # what the per-type breakdown sums to. + assert record.metadata["deleted_count"] == sum(result["deleted"].values()) + + +@pytest.mark.django_db +def test_sample_leads_are_assigned_to_the_applying_admin(org_a, admin_profile): + """Important 3: without an assignee, the admin who applied the pack has + no ordinary "my leads" query that reaches the rows they just created. + """ + apply_pack(org_a, get_pack("real-estate"), admin_profile) + leads = Lead.objects.filter(org=org_a, is_sample=True) + assert leads.count() >= 8 + assert all(admin_profile in lead.assigned_to.all() for lead in leads) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "pack_id", ["real-estate", "education-admissions", "professional-services"] +) +def test_sample_data_applies_cleanly_for_every_shipped_pack( + org_a, admin_profile, pack_id +): + """The only end-to-end coverage of education-admissions' and + professional-services' sample_data blocks — everything else in this file + exercises real-estate only. Both other packs carry opportunity_amount / + currency (Minor 9) and a full set of remapped `source` values (Important + 4), so this is what actually proves those two packs' sample rows insert + without a DecimalField/choices surprise, not just that the JSON is + schema-valid. + """ + pack = get_pack(pack_id) + report = apply_pack(org_a, pack, admin_profile) + + assert report["failed"] == [] + leads = Lead.objects.filter(org=org_a, is_sample=True) + assert leads.count() == len(pack["sample_data"]["leads"]) + for lead in leads: + assert admin_profile in lead.assigned_to.all() + assert lead.tags.filter(slug=SAMPLE_SLUG).exists() + + +# --------------------------------------------------------------------------- +# Sample data beyond leads: accounts, contacts, deals, tickets and tasks. +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "pack_id", ["real-estate", "education-admissions", "professional-services"] +) +def test_apply_creates_every_sample_entity_type(org_a, admin_profile, pack_id): + """Each pack's whole sample block inserts, not just its leads. + + Counting against the manifest rather than a literal catches the failure this + is really guarding: a row silently skipped by _create_sample's except clause + still leaves the apply "successful" with fewer records than the pack ships. + """ + from accounts.models import Account + from cases.models import Case + from contacts.models import Contact + from opportunity.models import Opportunity + from tasks.models import Task + + pack = get_pack(pack_id) + report = apply_pack(org_a, pack, admin_profile) + assert report["failed"] == [] + + sample = pack["sample_data"] + for key, model in ( + ("accounts", Account), + ("contacts", Contact), + ("deals", Opportunity), + ("tickets", Case), + ("tasks", Task), + ("leads", Lead), + ): + created = model.objects.filter(org=org_a, is_sample=True).count() + assert created == len(sample[key]), f"{pack_id}: expected {len(sample[key])} {key}, got {created}" + + +@pytest.mark.django_db +def test_sample_references_resolve_to_sample_rows(org_a, admin_profile): + """A referenced parent is wired up, and only ever to another sample row.""" + from accounts.models import Account + from cases.models import Case + from contacts.models import Contact + from opportunity.models import Opportunity + from tasks.models import Task + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + + dana = Contact.objects.get(org=org_a, email="dana.whitfield@example.com") + assert dana.account is not None + assert dana.account.name == "Harbourline Developments" + assert dana.account.is_sample + # Both sides of the account/contact link — the FK alone leaves the account + # page's contact list empty. + assert dana in dana.account.contacts.all() + + deal = Opportunity.objects.get(org=org_a, name="Marina District 3 bed — Whitfield") + assert deal.account == dana.account + + ticket = Case.objects.get(org=org_a, name="Parking allocation not confirmed") + assert ticket.account == dana.account + assert dana in ticket.contacts.all() + assert ticket.stage is not None and ticket.stage.name == "With agent" + + task = Task.objects.get(org=org_a, title="Confirm second viewing time") + assert task.opportunity == deal + assert task.account_id is None and task.case_id is None and task.lead_id is None + + # Nothing sample-created points at a non-sample row. + assert not Account.objects.filter(org=org_a, is_sample=False).exists() + + +@pytest.mark.django_db +def test_reapply_does_not_duplicate_sample_data(org_a, admin_profile): + """The guard spans every sample model, so a second apply adds nothing.""" + from accounts.models import Account + + pack = get_pack("real-estate") + apply_pack(org_a, pack, admin_profile) + before = Account.objects.filter(org=org_a).count() + + report = apply_pack(org_a, pack, admin_profile) + + assert Account.objects.filter(org=org_a).count() == before + assert any(s["type"] == "sample_data" for s in report["skipped"]) + + +@pytest.mark.django_db +def test_clear_removes_every_sample_entity_type(org_a, admin_profile): + from accounts.models import Account + from cases.models import Case + from contacts.models import Contact + from opportunity.models import Opportunity + from tasks.models import Task + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + result = clear_sample_data(org_a, admin_profile) + + assert result["retained"] == {} + for model in (Account, Contact, Opportunity, Case, Task, Lead): + assert not model.objects.filter(org=org_a).exists(), model + + +@pytest.mark.django_db +def test_clear_keeps_a_sample_account_carrying_a_real_deal(org_a, admin_profile): + """The data-loss case. Opportunity.account is CASCADE, so deleting a sample + account would take a real deal the user attached to it with no warning. + """ + from accounts.models import Account + from opportunity.models import Opportunity + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + account = Account.objects.get(org=org_a, name="Harbourline Developments") + real_deal = Opportunity.objects.create( + org=org_a, name="A real deal the user created", account=account + ) + assert real_deal.is_sample is False + + result = clear_sample_data(org_a, admin_profile) + + assert result["retained"]["sample_account"] == 1 + assert Account.objects.filter(pk=account.pk).exists() + assert Opportunity.objects.filter(pk=real_deal.pk).exists() + # Every other sample account had nothing attached and is gone. + assert Account.objects.filter(org=org_a, is_sample=True).count() == 1 + + +@pytest.mark.django_db +def test_clear_keeps_a_sample_ticket_carrying_billable_time(org_a, admin_profile): + """TimeEntry.case is CASCADE — billable hours logged against a demo ticket + must survive a clear. + """ + from django.utils import timezone + + from cases.models import Case, TimeEntry + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + ticket = Case.objects.get(org=org_a, name="Parking allocation not confirmed") + entry = TimeEntry.objects.create( + org=org_a, case=ticket, profile=admin_profile, started_at=timezone.now() + ) + + result = clear_sample_data(org_a, admin_profile) + + assert result["retained"]["sample_ticket"] == 1 + assert Case.objects.filter(pk=ticket.pk).exists() + assert TimeEntry.objects.filter(pk=entry.pk).exists() + # Retention has to propagate: Case.account is CASCADE, so keeping the ticket + # is worthless if its sample account is deleted a step later and takes the + # ticket — and the billable time — with it. + assert result["retained"]["sample_account"] == 1 + ticket.refresh_from_db() + assert ticket.account is not None + + +@pytest.mark.django_db +def test_clear_does_not_raise_when_a_sample_account_has_a_protected_invoice( + org_a, admin_profile +): + """Invoice.account is PROTECT: a naive delete raises ProtectedError and + aborts the whole clear, leaving every sample record in place with no + explanation. The retain rule has to cover PROTECT, not just CASCADE. + """ + from accounts.models import Account + from invoices.models import Invoice + + apply_pack(org_a, get_pack("real-estate"), admin_profile) + account = Account.objects.get(org=org_a, name="Cedar Grove Estates") + invoice = Invoice.objects.create( + org=org_a, invoice_title="A real invoice", invoice_number="INV-REAL-1", account=account + ) + + result = clear_sample_data(org_a, admin_profile) # must not raise + + assert result["retained"]["sample_account"] == 1 + assert Account.objects.filter(pk=account.pk).exists() + assert Invoice.objects.filter(pk=invoice.pk).exists() + # The rest of the sample data still cleared — one blocked row must not + # abort the whole operation. + assert result["deleted"]["sample_lead"] == 25 diff --git a/backend/common/tests/test_rls_pipeline_tables.py b/backend/common/tests/test_rls_pipeline_tables.py new file mode 100644 index 00000000..3620c6a6 --- /dev/null +++ b/backend/common/tests/test_rls_pipeline_tables.py @@ -0,0 +1,88 @@ +"""RLS coverage for the Kanban pipeline and stage tables. + +These six tables carry org_id directly but were absent from ORG_SCOPED_TABLES, +and neither leads/0012, cases/0009 nor tasks/0009 stamped a policy. Deployed +databases had the policies from an out-of-band step, so the gap only existed on +databases built purely from migrations — CI, and any fresh deploy. + +The pytest database is built by running migrations and nothing else, which is +precisely the environment that was broken. common/0034 closes it; these tests +keep it closed. +""" + +import pytest +from django.db import connection + +from common.rls import ORG_SCOPED_TABLES + +PIPELINE_TABLES = [ + "lead_pipeline", + "lead_stage", + "case_pipeline", + "case_stage", + "task_pipeline", + "task_stage", +] + + +def test_pipeline_tables_are_registered_as_org_scoped(): + """Registration is what makes the central tooling (manage_rls --status, + common/0028's re-stamp) able to see these tables at all.""" + missing = [t for t in PIPELINE_TABLES if t not in ORG_SCOPED_TABLES] + assert not missing, f"tables missing from ORG_SCOPED_TABLES: {missing}" + + +@pytest.mark.postgres_only +@pytest.mark.django_db +def test_pipeline_tables_have_rls_policies(): + """Every pipeline table has RLS enabled with both policies, from migrations + alone. Verified to fail without common/0034.""" + if connection.vendor != "postgresql": + pytest.skip("RLS requires PostgreSQL") + + broken = [] + with connection.cursor() as cur: + for table in PIPELINE_TABLES: + cur.execute( + """ + SELECT c.relrowsecurity, + (SELECT COUNT(*) FROM pg_policy p WHERE p.polrelid = c.oid) + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = %s + """, + [table], + ) + row = cur.fetchone() + if row is None: + broken.append((table, "table does not exist")) + elif not row[0]: + broken.append((table, "RLS not enabled")) + elif row[1] < 2: + broken.append((table, f"expected 2 policies, found {row[1]}")) + + assert not broken, f"pipeline tables without RLS: {broken}" + + +@pytest.mark.postgres_only +@pytest.mark.django_db +def test_pipeline_policies_use_the_org_context_variable(): + """The isolation policy must key on app.current_org, not something else.""" + if connection.vendor != "postgresql": + pytest.skip("RLS requires PostgreSQL") + + with connection.cursor() as cur: + for table in PIPELINE_TABLES: + cur.execute( + """ + SELECT pg_get_expr(p.polqual, p.polrelid) + FROM pg_policy p + JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = %s AND p.polname = 'org_isolation' + """, + [table], + ) + row = cur.fetchone() + assert row is not None, f"{table} has no org_isolation policy" + assert "app.current_org" in row[0], f"{table}: unexpected predicate {row[0]}" + assert "org_id" in row[0], f"{table}: predicate does not filter org_id" diff --git a/backend/common/urls.py b/backend/common/urls.py index 32567b1c..f7d1d538 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -31,6 +31,7 @@ from common.views.organization_views import ( ProfileDetailView, ProfileView, ) +from common.views.pack_views import PackApplyView, PackListView, PackSampleDataView from common.views.pat_views import ( OrgAccessTokenDetailView, OrgAccessTokenListView, @@ -164,4 +165,10 @@ urlpatterns = [ NotificationDetailView.as_view(), name="notifications_detail", ), + # Vertical packs — any member may list; apply/clear are ADMIN-only (see + # common/views/pack_views.py). sample-data/ must precede + # /apply/ so it is never captured as a pack id. + path("packs/", PackListView.as_view(), name="pack_list"), + path("packs/sample-data/", PackSampleDataView.as_view(), name="pack_sample_data"), + path("packs//apply/", PackApplyView.as_view(), name="pack_apply"), ] diff --git a/backend/common/views/custom_field_views.py b/backend/common/views/custom_field_views.py index 96433298..202aafad 100644 --- a/backend/common/views/custom_field_views.py +++ b/backend/common/views/custom_field_views.py @@ -120,13 +120,24 @@ class CustomFieldDefinitionListCreateView(APIView): if request.query_params.get("active_only") == "true": qs = qs.filter(is_active=True) + rows = CustomFieldDefinitionSerializer(qs, many=True).data + + # `records_missing_value` costs one COUNT over the org's whole record + # set per definition, plus one per target model — a `custom_fields ? + # key` scan of every Lead the org has, for each field. The settings + # page needs those numbers; a record page that only wants labels and + # types to render a form does not, and making it pay for the stat cards + # puts N+1 full scans on every lead view. `include_counts=false` opts + # out. The default is unchanged, so existing callers are unaffected. + if request.query_params.get("include_counts") == "false": + return Response({"definitions": rows, "totals": None}) + # records_missing_value + totals are computed over the org's FULL # definition set (independent of the target_model/active_only filters) # so the stat cards stay correct when the list is filtered. all_defs = list(CustomFieldDefinition.objects.filter(org=org)) missing_by_id = _records_missing_map(org, all_defs) - rows = CustomFieldDefinitionSerializer(qs, many=True).data for row in rows: row["records_missing_value"] = missing_by_id.get(row["id"], 0) diff --git a/backend/common/views/pack_views.py b/backend/common/views/pack_views.py new file mode 100644 index 00000000..d9237d8b --- /dev/null +++ b/backend/common/views/pack_views.py @@ -0,0 +1,89 @@ +"""Vertical-pack endpoints. + +Authorization: any authenticated member may list packs; only an org ADMIN may +apply one or clear sample data. The org is always request.profile.org — never a +body field. Org.vertical is descriptive and is never read here for access control. +""" + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.packs.applier import apply_pack, clear_sample_data +from common.packs.loader import get_pack, get_registry +from common.permissions import HasOrgContext + + +def _require_admin(request): + """Return a 403 Response if the caller is not an org admin, else None.""" + if getattr(request.profile, "role", None) != "ADMIN": + return Response( + {"error": True, "errors": "Only an organization admin can do this."}, + status=status.HTTP_403_FORBIDDEN, + ) + return None + + +class PackListView(APIView): + # No HasOrgContext: this list is static repo content (JSON files), + # byte-identical for every tenant, with no tenant data in the response. + # It backs the pack chooser on the org-creation page, where a user + # creating their first org has no org_id claim yet — HasOrgContext would + # 403 exactly that audience. /api/packs/ is exempted from + # RequireOrgContext by exact match (not prefix) in + # common/middleware/rls_context.py; see the comment there for why the + # write/destroy endpoints below must not be. IsAuthenticated still + # applies — this is not anonymous. + permission_classes = (IsAuthenticated,) + + def get(self, request): + packs = [ + { + "id": p["id"], + "name": p["name"], + "description": p.get("description", ""), + "version": p["version"], + } + for p in get_registry().values() + ] + return Response({"packs": sorted(packs, key=lambda p: p["name"])}) + + +class PackApplyView(APIView): + permission_classes = (IsAuthenticated, HasOrgContext) + + def post(self, request, pack_id): + denied = _require_admin(request) + if denied: + return denied + # Registry lookup only. pack_id is a dict key, never a path segment. + pack = get_pack(pack_id) + if pack is None: + return Response( + {"error": True, "errors": "Unknown pack."}, + status=status.HTTP_404_NOT_FOUND, + ) + report = apply_pack(request.profile.org, pack, request.profile) + return Response({"report": report}) + + +class PackSampleDataView(APIView): + permission_classes = (IsAuthenticated, HasOrgContext) + + def delete(self, request): + denied = _require_admin(request) + if denied: + return denied + result = clear_sample_data(request.profile.org, request.profile) + # `deleted` stays an integer for the existing client: it was the total + # count before sample data covered more than leads, and the settings page + # renders it directly. The per-type breakdown and the retained rows are + # added alongside it rather than replacing it. + return Response( + { + "deleted": sum(result["deleted"].values()), + "deleted_by_type": result["deleted"], + "retained_by_type": result["retained"], + } + ) diff --git a/backend/contacts/migrations/0013_contact_is_sample.py b/backend/contacts/migrations/0013_contact_is_sample.py new file mode 100644 index 00000000..b713f1d1 --- /dev/null +++ b/backend/contacts/migrations/0013_contact_is_sample.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-02 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0012_contact_custom_fields'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + ), + ] diff --git a/backend/contacts/models.py b/backend/contacts/models.py index f1c00ee5..d7c66e1f 100644 --- a/backend/contacts/models.py +++ b/backend/contacts/models.py @@ -3,7 +3,7 @@ from django.db.models import Q from django.db.models.functions import Lower from django.utils.translation import gettext_lazy as _ -from common.base import AssignableMixin, BaseModel +from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from common.utils import COUNTRIES from common.validators import flexible_phone_validator @@ -59,6 +59,7 @@ class Contact(AssignableMixin, BaseModel): # System Fields is_active = models.BooleanField(default=True) + is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT) auto_created = models.BooleanField( default=False, help_text="True when the contact was created by an inbound channel " diff --git a/backend/leads/admin.py b/backend/leads/admin.py index 3766c1a9..0bd8ec5e 100644 --- a/backend/leads/admin.py +++ b/backend/leads/admin.py @@ -2,4 +2,12 @@ from django.contrib import admin from leads.models import Lead -admin.site.register(Lead) + +@admin.register(Lead) +class LeadAdmin(admin.ModelAdmin): + # is_sample marks leads created by the vertical-pack applier's sample + # data and is the guard `clear_sample_data` uses to decide what it may + # delete (see common/packs/applier.py). It must never be editable here — + # a bare ModelAdmin would let a staff user flip it on a real lead, + # putting that lead in scope for the next clear-sample-data call. + readonly_fields = ("is_sample",) diff --git a/backend/leads/migrations/0014_lead_is_sample.py b/backend/leads/migrations/0014_lead_is_sample.py new file mode 100644 index 00000000..b2b982e6 --- /dev/null +++ b/backend/leads/migrations/0014_lead_is_sample.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('leads', '0013_lead_custom_fields'), + ] + + operations = [ + migrations.AddField( + model_name='lead', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real lead as sample and have it deleted.'), + ), + ] diff --git a/backend/leads/migrations/0015_alter_lead_is_sample.py b/backend/leads/migrations/0015_alter_lead_is_sample.py new file mode 100644 index 00000000..256fffc8 --- /dev/null +++ b/backend/leads/migrations/0015_alter_lead_is_sample.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-02 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('leads', '0014_lead_is_sample'), + ] + + operations = [ + migrations.AlterField( + model_name='lead', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + ), + ] diff --git a/backend/leads/models.py b/backend/leads/models.py index ed55a17e..4fec4d35 100644 --- a/backend/leads/models.py +++ b/backend/leads/models.py @@ -5,7 +5,7 @@ from django.db.models.functions import Lower from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from common.base import AssignableMixin, BaseModel +from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from common.utils import ( COUNTRIES, @@ -116,6 +116,7 @@ class Lead(AssignableMixin, BaseModel): # System Fields is_active = models.BooleanField(default=True) + is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT) tags = models.ManyToManyField(Tags, related_name="lead_tags", blank=True) contacts = models.ManyToManyField(Contact, related_name="lead_contacts") org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="leads") diff --git a/backend/leads/serializer.py b/backend/leads/serializer.py index 38e5826d..adff1722 100644 --- a/backend/leads/serializer.py +++ b/backend/leads/serializer.py @@ -69,6 +69,7 @@ class LeadSerializer(serializers.ModelSerializer): "created_at", "updated_at", "is_active", + "is_sample", "company_name", # Kanban "stage", @@ -76,6 +77,12 @@ class LeadSerializer(serializers.ModelSerializer): # Per-org custom fields (validated via common.custom_fields) "custom_fields", ) + # is_sample is server-set only (see leads/models.py) — read-only here + # even though this serializer is a read/list path today (writes go + # through LeadCreateSerializer, which never lists this field at all). + # Defense in depth: a future write path added to this serializer + # without checking Meta.fields first still can't make it writable. + read_only_fields = ("is_sample",) class LeadCreateSerializer(serializers.ModelSerializer): diff --git a/backend/leads/tests/test_leads_api.py b/backend/leads/tests/test_leads_api.py index 29cf97f0..c696f470 100644 --- a/backend/leads/tests/test_leads_api.py +++ b/backend/leads/tests/test_leads_api.py @@ -65,6 +65,28 @@ class TestLeadListView: assert data["error"] is False assert Lead.objects.filter(email="jane@example.com", org=org_a).exists() + def test_create_lead_ignores_is_sample_in_payload(self, admin_client, org_a): + """`is_sample` is server-set only (see leads/models.py) and it is the + sole key common.packs.applier.clear_sample_data uses to decide what + to delete. LeadCreateSerializer.Meta.fields never lists it, so DRF + silently drops it from validated_data rather than erroring — this + proves that drop actually holds and a user cannot mark their own + lead "sample" (and have it swept up by a future clear) by adding the + field to the request body. + """ + response = admin_client.post( + "/api/leads/", + { + "first_name": "Not", + "last_name": "Sample", + "email": "not.sample@example.com", + "is_sample": True, + }, + ) + assert response.status_code == 200 + lead = Lead.objects.get(email="not.sample@example.com", org=org_a) + assert lead.is_sample is False + def test_create_lead_invalid_data(self, admin_client): response = admin_client.post( "/api/leads/", @@ -656,6 +678,31 @@ class TestLeadDetailView: assert lead.first_name == "Updated" assert lead.last_name == "Lead" # unchanged + def test_patch_lead_ignores_is_sample_in_payload( + self, admin_client, admin_user, org_a + ): + """Mirrors test_create_lead_ignores_is_sample_in_payload for the + update path — PUT/PATCH share LeadCreateSerializer with create, so + this is the "and False" half of proving the mass-assignment door is + shut: an existing real lead cannot be flipped to is_sample=True and + then destroyed by clear_sample_data. + """ + lead = Lead.objects.create( + first_name="Real", + last_name="Lead", + email="real.lead@example.com", + created_by=admin_user, + org=org_a, + ) + response = admin_client.patch( + _detail_url(lead.id), + {"is_sample": True}, + format="json", + ) + assert response.status_code == 200 + lead.refresh_from_db() + assert lead.is_sample is False + def test_patch_lead_with_tags(self, admin_client, admin_user, org_a): """PATCH with tags replaces existing tags.""" _set_rls(org_a) diff --git a/backend/opportunity/migrations/0014_opportunity_is_sample.py b/backend/opportunity/migrations/0014_opportunity_is_sample.py new file mode 100644 index 00000000..55408e6a --- /dev/null +++ b/backend/opportunity/migrations/0014_opportunity_is_sample.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-02 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('opportunity', '0013_enable_rls_stage_aging_config'), + ] + + operations = [ + migrations.AddField( + model_name='opportunity', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + ), + ] diff --git a/backend/opportunity/models.py b/backend/opportunity/models.py index 6fdf9113..123e2933 100644 --- a/backend/opportunity/models.py +++ b/backend/opportunity/models.py @@ -10,7 +10,7 @@ from django.utils.timesince import timesince from django.utils.translation import gettext_lazy as _ from accounts.models import Account -from common.base import AssignableMixin, BaseModel +from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from common.utils import ( CURRENCY_CODES, @@ -127,6 +127,7 @@ class Opportunity(AssignableMixin, BaseModel): # System Fields is_active = models.BooleanField(default=True) + is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT) org = models.ForeignKey( Org, on_delete=models.CASCADE, diff --git a/backend/packs/education-admissions.json b/backend/packs/education-admissions.json new file mode 100644 index 00000000..cccd8d45 --- /dev/null +++ b/backend/packs/education-admissions.json @@ -0,0 +1,324 @@ +{ + "id": "education-admissions", + "version": 2, + "name": "Education admissions", + "description": "Coaching centres, training institutes and colleges managing admissions.", + "terminology": { + "lead.singular": "Enquiry", + "lead.plural": "Enquiries", + "contact.singular": "Student", + "contact.plural": "Students", + "invoice.singular": "Fee receipt", + "invoice.plural": "Fee receipts" + }, + "lead_pipeline": { + "name": "Admissions", + "description": "Enquiry through to admission.", + "stages": [ + { "name": "New enquiry", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "Counsellor assigned", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "Counselling done", "order": 3, "stage_type": "open", "color": "#6366F1" }, + { "name": "Demo class", "order": 4, "stage_type": "open", "color": "#8B5CF6" }, + { "name": "Application submitted", "order": 5, "stage_type": "open", "color": "#F59E0B" }, + { "name": "Admitted", "order": 6, "stage_type": "won", "color": "#10B981" }, + { "name": "Not joining", "order": 7, "stage_type": "lost", "color": "#EF4444" } + ] + }, + "case_pipeline": { + "name": "Student support", + "description": "Fee, timetable and academic queries from students and parents.", + "stages": [ + { "name": "New", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "With counsellor", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "With academics", "order": 3, "stage_type": "open", "color": "#8B5CF6" }, + { "name": "Awaiting parent", "order": 4, "stage_type": "open", "color": "#F59E0B" }, + { "name": "Resolved", "order": 5, "stage_type": "closed", "color": "#10B981" }, + { "name": "Declined", "order": 6, "stage_type": "rejected", "color": "#EF4444" } + ] + }, + "task_pipeline": { + "name": "Counsellor tasks", + "description": "Follow-ups that move an enquiry along.", + "stages": [ + { "name": "To call", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "Scheduled", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "In progress", "order": 3, "stage_type": "in_progress", "color": "#8B5CF6" }, + { "name": "Done", "order": 4, "stage_type": "completed", "color": "#10B981" } + ] + }, + "custom_fields": [ + { "target": "Lead", "key": "course_interest", "label": "Course of interest", "field_type": "text", "is_filterable": true, "display_order": 1 }, + { "target": "Lead", "key": "intake", "label": "Intake", "field_type": "text", "display_order": 2 }, + { "target": "Lead", "key": "parent_name", "label": "Parent / guardian", "field_type": "text", "display_order": 3 }, + { "target": "Lead", "key": "parent_phone", "label": "Parent phone", "field_type": "text", "display_order": 4 }, + { "target": "Lead", "key": "previous_qualification", "label": "Previous qualification", "field_type": "text", "display_order": 5 } + ], + "tags": [ + { "name": "Walk-in", "color": "sky" }, + { "name": "Referral", "color": "green" }, + { "name": "Scholarship", "color": "purple" }, + { "name": "Repeat family", "color": "amber" } + ], + "products": [ + { "name": "SAT Prep — 12 week", "sku": "ED-SAT-12W", "price": 1200, "category": "Test prep", + "description": "Twelve weeks, two sessions a week, four full mock tests." }, + { "name": "IELTS Coaching — 8 week", "sku": "ED-IELTS-8W", "price": 450, "category": "Test prep", + "description": "Eight weeks covering all four bands, with speaking practice." }, + { "name": "Full-Stack Web Development bootcamp", "sku": "ED-FSWD", "price": 6500, "category": "Bootcamp", + "description": "Sixteen-week intensive, includes placement support." }, + { "name": "Data Science Fundamentals", "sku": "ED-DS-FUND", "price": 3200, "category": "Bootcamp", + "description": "Ten weeks, Python and statistics, capstone project." }, + { "name": "CPA Exam Prep", "sku": "ED-CPA", "price": 2800, "category": "Professional", + "description": "All four sections, question bank access for twelve months." }, + { "name": "MBA Foundation", "sku": "ED-MBA-FND", "price": 4500, "category": "Professional", + "description": "Pre-MBA quantitative and case-method preparation." }, + { "name": "Spoken English — evening batch", "sku": "ED-SPEAK-EVE", "price": 180, "category": "Language", + "description": "Six weeks, conversational focus, small groups." }, + { "name": "Registration fee", "sku": "ED-REG", "price": 50, "category": "Administrative", + "description": "One-off, non-refundable, charged at admission." } + ], + "sample_data": { + "accounts": [ + { "name": "Lincoln High School", "industry": "EDUCATION", "email": "office@lincolnhigh.example.com", + "phone": "+1 470 555 0500", "city": "Atlanta", "country": "US", + "description": "Feeder school. Sends most of the SAT prep intake." }, + { "name": "Riverside Community College", "industry": "EDUCATION", "email": "advising@riversidecc.example.com", + "phone": "+1 512 555 0501", "website": "https://riversidecc.example.com", "city": "Austin", "country": "US", + "description": "Transfer students looking for bootcamp places." }, + { "name": "Northgate Tech Employers Forum", "industry": "TECHNOLOGY", "email": "skills@northgateforum.example.com", + "phone": "+1 206 555 0502", "city": "Seattle", "country": "US", + "description": "Employer group sponsoring data science places." }, + { "name": "St. Aidan's Academy", "industry": "EDUCATION", "email": "admin@staidans.example.com", + "phone": "+1 617 555 0503", "city": "Boston", "country": "US", + "description": "Independent school; parents fund privately." }, + { "name": "Meridian Accountancy Group", "industry": "FINANCE", "email": "training@meridianacc.example.com", + "phone": "+1 214 555 0504", "city": "Dallas", "country": "US", + "description": "Sponsors CPA prep seats for junior staff." }, + { "name": "Harbour Language Centre", "industry": "EDUCATION", "email": "partners@harbourlang.example.com", + "phone": "+1 305 555 0505", "city": "Miami", "country": "US", + "description": "Referral partner for IELTS and spoken English." } + ], + "contacts": [ + { "first_name": "Aisha", "last_name": "Bello", "email": "aisha.bello@example.com", "phone": "+1 470 555 0111", + "title": "Student", "account": "Lincoln High School", "city": "Atlanta", "country": "US", + "description": "SAT prep, Fall 2026 intake. Parent: Folake Bello." }, + { "first_name": "Ben", "last_name": "Carter", "email": "ben.carter@example.com", "phone": "+1 512 555 0122", + "title": "Student", "account": "Riverside Community College", "city": "Austin", "country": "US", + "description": "Transferring in; full-stack bootcamp, Winter 2027." }, + { "first_name": "Derek", "last_name": "Lindqvist", "email": "derek.lindqvist@example.com", "phone": "+1 206 555 0145", + "title": "Student", "account": "Northgate Tech Employers Forum", "city": "Seattle", "country": "US", + "description": "Employer-sponsored data science place." }, + { "first_name": "Elena", "last_name": "Papadakis", "email": "elena.papadakis@example.com", "phone": "+1 617 555 0156", + "title": "Student", "account": "Meridian Accountancy Group", "city": "Boston", "country": "US", + "description": "CPA prep, sponsored by her employer." }, + { "first_name": "Farhan", "last_name": "Iqbal", "email": "farhan.iqbal@example.com", "phone": "+1 214 555 0167", + "title": "Student", "account": "Meridian Accountancy Group", "city": "Dallas", "country": "US", + "description": "Admitted to MBA Foundation, Fall 2026." }, + { "first_name": "Chidinma", "last_name": "Okoye", "email": "chidinma.okoye@example.com", "phone": "+1 832 555 0134", + "title": "Student", "account": "Harbour Language Centre", "city": "Houston", "country": "US", + "description": "IELTS coaching. Parent: Ngozi Okoye." }, + { "first_name": "Grace", "last_name": "Mensah", "email": "grace.mensah@example.com", "phone": "+1 404 555 0179", + "title": "Student", "account": "St. Aidan's Academy", "city": "Atlanta", "country": "US", + "description": "Younger sibling of a past student. Parent: Kwame Mensah." }, + { "first_name": "Rachel", "last_name": "Osei", "email": "rachel.osei@example.com", "phone": "+1 470 555 0506", + "title": "Head of Sixth Form", "account": "Lincoln High School", "city": "Atlanta", "country": "US", + "description": "School-side contact for the annual SAT cohort." } + ], + "deals": [ + { "name": "Lincoln High SAT cohort — Fall 2026", "account": "Lincoln High School", "stage": "NEGOTIATION", + "amount": 21600, "currency": "USD", "probability": 70, "closed_on": "2026-08-31", "lead_source": "PARTNER", + "description": "Eighteen students at the group rate; school wants two on-site sessions." }, + { "name": "Northgate data science sponsorship", "account": "Northgate Tech Employers Forum", "stage": "PROPOSAL", + "amount": 32000, "currency": "USD", "probability": 55, "closed_on": "2026-09-30", "lead_source": "PARTNER", + "description": "Ten sponsored seats across two intakes." }, + { "name": "Meridian CPA prep seats", "account": "Meridian Accountancy Group", "stage": "CLOSED_WON", + "amount": 16800, "currency": "USD", "probability": 100, "closed_on": "2026-07-15", "lead_source": "EXISTING CUSTOMER", + "description": "Six seats confirmed and invoiced." }, + { "name": "Riverside bootcamp referral agreement", "account": "Riverside Community College", "stage": "QUALIFICATION", + "amount": 13000, "currency": "USD", "probability": 30, "closed_on": "2026-10-31", "lead_source": "WEBSITE", + "description": "Two-year referral arrangement; legal review pending." }, + { "name": "St. Aidan's spoken English batch", "account": "St. Aidan's Academy", "stage": "PROSPECTING", + "amount": 2160, "currency": "USD", "probability": 15, "closed_on": "2026-11-30", "lead_source": "EXISTING CUSTOMER", + "description": "Twelve students, evening batch. Early conversation." }, + { "name": "Harbour Language IELTS referrals", "account": "Harbour Language Centre", "stage": "CLOSED_LOST", + "amount": 5400, "currency": "USD", "probability": 0, "closed_on": "2026-06-20", "lead_source": "PARTNER", + "description": "They opened their own IELTS stream instead." } + ], + "tickets": [ + { "name": "Fee receipt not issued after payment", "status": "New", "priority": "Urgent", "case_type": "Problem", + "account": "Meridian Accountancy Group", "contacts": ["Elena Papadakis"], "stage": "New", + "description": "Payment cleared on the 14th, no receipt yet. Employer needs it for reimbursement." }, + { "name": "Request to switch to the evening batch", "status": "Assigned", "priority": "Normal", "case_type": "Question", + "account": "Riverside Community College", "contacts": ["Ben Carter"], "stage": "With counsellor", + "description": "Started a job; morning sessions no longer possible." }, + { "name": "Mock test score dispute", "status": "Pending", "priority": "High", "case_type": "Problem", + "account": "Lincoln High School", "contacts": ["Aisha Bello"], "stage": "With academics", + "description": "Section 3 marked down; asking for a re-check." }, + { "name": "Parent asking about the refund policy", "status": "Pending", "priority": "Normal", "case_type": "Question", + "account": "St. Aidan's Academy", "contacts": ["Grace Mensah"], "stage": "Awaiting parent", + "description": "Sent the policy; waiting on the parent's decision to withdraw." }, + { "name": "Certificate reissue after a name change", "status": "Closed", "priority": "Low", "case_type": "Question", + "account": "Harbour Language Centre", "contacts": ["Chidinma Okoye"], "stage": "Resolved", + "description": "Reissued with the updated legal name; original returned." }, + { "name": "Scholarship appeal after the deadline", "status": "Rejected", "priority": "Normal", "case_type": "Question", + "account": "Northgate Tech Employers Forum", "contacts": ["Derek Lindqvist"], "stage": "Declined", + "description": "Appeal received six weeks after the published cut-off." } + ], + "tasks": [ + { "title": "Issue the missing fee receipt", "status": "In Progress", "priority": "High", "due_date": "2026-08-03", + "stage": "In progress", "ticket": "Fee receipt not issued after payment", + "description": "Check the payment gateway export against the ledger first." }, + { "title": "Confirm the evening batch has a space", "status": "New", "priority": "Medium", "due_date": "2026-08-05", + "stage": "To call", "ticket": "Request to switch to the evening batch", + "description": "Batch caps at eighteen; currently sixteen." }, + { "title": "Re-check the Section 3 mock paper", "status": "In Progress", "priority": "High", "due_date": "2026-08-04", + "stage": "In progress", "ticket": "Mock test score dispute", + "description": "Second marker required per the academic policy." }, + { "title": "Prepare the Lincoln High group quote", "status": "New", "priority": "High", "due_date": "2026-08-06", + "stage": "Scheduled", "deal": "Lincoln High SAT cohort — Fall 2026", + "description": "Eighteen students, two on-site sessions, show the per-head rate." }, + { "title": "Draft the Northgate sponsorship agreement", "status": "In Progress", "priority": "Medium", + "due_date": "2026-08-12", "stage": "In progress", "deal": "Northgate data science sponsorship", + "description": "Ten seats split across the Spring and Summer intakes." }, + { "title": "Send the Meridian invoice pack", "status": "Completed", "priority": "Medium", "due_date": "2026-07-16", + "stage": "Done", "deal": "Meridian CPA prep seats", + "description": "Six seats, one consolidated invoice as requested." }, + { "title": "Annual planning call with Lincoln High", "status": "New", "priority": "Low", "due_date": "2026-09-15", + "stage": "To call", "account": "Lincoln High School", + "description": "Agree next year's cohort dates with Rachel." }, + { "title": "Call back the SAT prep enquiry", "status": "New", "priority": "Medium", "due_date": "2026-08-04", + "stage": "To call", "lead": "SAT prep enquiry", + "description": "Parent asked for an evening callback." }, + { "title": "Book a demo class for the data science enquiry", "status": "New", "priority": "Medium", + "due_date": "2026-08-07", "stage": "Scheduled", "lead": "Demo class scheduled for data science", + "description": "Thursday 6pm slot is free." } + ], + "leads": [ + { "title": "SAT prep enquiry", "first_name": "Aisha", "last_name": "Bello", + "email": "aisha.bello@example.com", "phone": "+1 470 555 0111", + "source": "other", "stage": "New enquiry", + "opportunity_amount": 1200, "currency": "USD", + "custom_fields": { "course_interest": "SAT Prep", "intake": "Fall 2026", "parent_name": "Folake Bello", "parent_phone": "+1 470 555 0110", "previous_qualification": "High school diploma" } }, + { "title": "Coding bootcamp interest", "first_name": "Ben", "last_name": "Carter", + "email": "ben.carter@example.com", "phone": "+1 512 555 0122", + "source": "existing customer", "stage": "Counsellor assigned", + "opportunity_amount": 6500, "currency": "USD", + "custom_fields": { "course_interest": "Full-Stack Web Development", "intake": "Winter 2027", "previous_qualification": "Associate degree" } }, + { "title": "IELTS coaching enquiry", "first_name": "Chidinma", "last_name": "Okoye", + "email": "chidinma.okoye@example.com", "phone": "+1 832 555 0134", + "source": "compaign", "stage": "Counselling done", + "opportunity_amount": 450, "currency": "USD", + "custom_fields": { "course_interest": "IELTS Coaching", "intake": "Fall 2026", "parent_name": "Ngozi Okoye", "parent_phone": "+1 832 555 0133", "previous_qualification": "Bachelor's degree" } }, + { "title": "Demo class scheduled for data science", "first_name": "Derek", "last_name": "Lindqvist", + "email": "derek.lindqvist@example.com", "phone": "+1 206 555 0145", + "source": "compaign", "stage": "Demo class", + "opportunity_amount": 3200, "currency": "USD", + "custom_fields": { "course_interest": "Data Science Fundamentals", "intake": "Spring 2027", "previous_qualification": "Bachelor's in Engineering" } }, + { "title": "Application submitted for CPA prep", "first_name": "Elena", "last_name": "Papadakis", + "email": "elena.papadakis@example.com", "phone": "+1 617 555 0156", + "source": "partner", "stage": "Application submitted", + "opportunity_amount": 2800, "currency": "USD", + "custom_fields": { "course_interest": "CPA Exam Prep", "intake": "Fall 2026", "previous_qualification": "Bachelor's in Accounting" } }, + { "title": "Admitted to MBA foundation course", "first_name": "Farhan", "last_name": "Iqbal", + "email": "farhan.iqbal@example.com", "phone": "+1 214 555 0167", + "source": "existing customer", "stage": "Admitted", + "opportunity_amount": 4500, "currency": "USD", + "custom_fields": { "course_interest": "MBA Foundation", "intake": "Fall 2026", "previous_qualification": "Bachelor's degree" } }, + { "title": "Repeat family, younger sibling enquiry", "first_name": "Grace", "last_name": "Mensah", + "email": "grace.mensah@example.com", "phone": "+1 404 555 0179", + "source": "existing customer", "stage": "New enquiry", + "opportunity_amount": 180, "currency": "USD", + "custom_fields": { "course_interest": "Spoken English", "intake": "Fall 2026", "parent_name": "Kwame Mensah", "parent_phone": "+1 404 555 0178", "previous_qualification": "Middle school" } }, + { "title": "Scholarship enquiry for coding bootcamp", "first_name": "Hiroshi", "last_name": "Tanaka", + "email": "hiroshi.tanaka@example.com", "phone": "+1 213 555 0189", + "source": "public relations", "stage": "Counsellor assigned", + "opportunity_amount": 3250, "currency": "USD", + "custom_fields": { "course_interest": "Full-Stack Web Development (50% scholarship)", "intake": "Winter 2027", "previous_qualification": "High school diploma" } }, + { "title": "Not joining after comparing fees", "first_name": "Isabela", "last_name": "Rocha", + "email": "isabela.rocha@example.com", "phone": "+1 305 555 0191", + "source": "other", "stage": "Not joining", + "opportunity_amount": 450, "currency": "USD", + "custom_fields": { "course_interest": "IELTS Coaching", "intake": "Fall 2026", "previous_qualification": "Bachelor's degree" } }, + { "title": "Demo class for data science", "first_name": "Jonah", "last_name": "Whitmore", + "email": "jonah.whitmore@example.com", "phone": "+1 720 555 0103", + "source": "partner", "stage": "Demo class", + "opportunity_amount": 3200, "currency": "USD", + "custom_fields": { "course_interest": "Data Science Fundamentals", "intake": "Spring 2027", "parent_name": "Karen Whitmore", "parent_phone": "+1 720 555 0102", "previous_qualification": "High school diploma" } }, + { "title": "Weekend spoken English batch", "first_name": "Lucia", "last_name": "Moreau", + "email": "lucia.moreau@example.com", "phone": "+1 305 555 0240", + "source": "website", "stage": "New enquiry", + "opportunity_amount": 180, "currency": "USD", + "custom_fields": { "course_interest": "Spoken English", "intake": "Fall 2026", "previous_qualification": "High school diploma" } }, + { "title": "Employer-sponsored CPA seat", "first_name": "Nathan", "last_name": "Achebe", + "email": "nathan.achebe@example.com", "phone": "+1 214 555 0241", + "source": "partner", "stage": "Application submitted", + "opportunity_amount": 2800, "currency": "USD", + "custom_fields": { "course_interest": "CPA Exam Prep", "intake": "Winter 2027", "previous_qualification": "Bachelor's in Accounting" } }, + { "title": "Walk-in asking about SAT dates", "first_name": "Olivia", "last_name": "Brennan", + "email": "olivia.brennan@example.com", "phone": "+1 470 555 0242", + "source": "other", "stage": "Counsellor assigned", + "opportunity_amount": 1200, "currency": "USD", + "custom_fields": { "course_interest": "SAT Prep", "intake": "Spring 2027", "parent_name": "Sean Brennan", "parent_phone": "+1 470 555 0243", "previous_qualification": "High school, year 11" } }, + { "title": "Career changer, data science", "first_name": "Ravi", "last_name": "Choudhury", + "email": "ravi.choudhury@example.com", "phone": "+1 206 555 0244", + "source": "compaign", "stage": "Counselling done", + "opportunity_amount": 3200, "currency": "USD", + "custom_fields": { "course_interest": "Data Science Fundamentals", "intake": "Spring 2027", "previous_qualification": "Bachelor's in Commerce" } }, + { "title": "MBA foundation, second attempt", "first_name": "Sofia", "last_name": "Kowalczyk", + "email": "sofia.kowalczyk@example.com", "phone": "+1 617 555 0245", + "source": "existing customer", "stage": "Counselling done", + "opportunity_amount": 4500, "currency": "USD", + "custom_fields": { "course_interest": "MBA Foundation", "intake": "Winter 2027", "previous_qualification": "Bachelor's degree" } }, + { "title": "IELTS for a visa application", "first_name": "Tariq", "last_name": "Rahman", + "email": "tariq.rahman@example.com", "phone": "+1 832 555 0246", + "source": "website", "stage": "Demo class", + "opportunity_amount": 450, "currency": "USD", + "custom_fields": { "course_interest": "IELTS Coaching", "intake": "Fall 2026", "previous_qualification": "Master's degree" } }, + { "title": "Bootcamp enquiry from a referral", "first_name": "Ursula", "last_name": "Klein", + "email": "ursula.klein@example.com", "phone": "+1 512 555 0247", + "source": "existing customer", "stage": "Application submitted", + "opportunity_amount": 6500, "currency": "USD", + "custom_fields": { "course_interest": "Full-Stack Web Development", "intake": "Winter 2027", "previous_qualification": "Associate degree" } }, + { "title": "Admitted to the SAT cohort", "first_name": "Victor", "last_name": "Anand", + "email": "victor.anand@example.com", "phone": "+1 470 555 0248", + "source": "partner", "stage": "Admitted", + "opportunity_amount": 1200, "currency": "USD", + "custom_fields": { "course_interest": "SAT Prep", "intake": "Fall 2026", "parent_name": "Meera Anand", "parent_phone": "+1 470 555 0249", "previous_qualification": "High school, year 12" } }, + { "title": "Chose a competitor's bootcamp", "first_name": "Wendy", "last_name": "Osborne", + "email": "wendy.osborne@example.com", "phone": "+1 206 555 0250", + "source": "compaign", "stage": "Not joining", + "opportunity_amount": 6500, "currency": "USD", + "custom_fields": { "course_interest": "Full-Stack Web Development", "intake": "Winter 2027", "previous_qualification": "Bachelor's in Design" } }, + { "title": "Corporate enquiry for ten CPA seats", "first_name": "Xavier", "last_name": "Dumont", + "email": "xavier.dumont@example.com", "phone": "+1 214 555 0251", + "source": "partner", "stage": "Counselling done", + "opportunity_amount": 28000, "currency": "USD", + "custom_fields": { "course_interest": "CPA Exam Prep", "intake": "Spring 2027", "previous_qualification": "Various — corporate cohort" } }, + { "title": "Scholarship applicant, spoken English", "first_name": "Yara", "last_name": "Haddad", + "email": "yara.haddad@example.com", "phone": "+1 305 555 0252", + "source": "public relations", "stage": "Counsellor assigned", + "opportunity_amount": 90, "currency": "USD", + "custom_fields": { "course_interest": "Spoken English (50% scholarship)", "intake": "Fall 2026", "previous_qualification": "High school diploma" } }, + { "title": "Parent enquiry for a year 10 student", "first_name": "Zainab", "last_name": "Faruq", + "email": "zainab.faruq@example.com", "phone": "+1 404 555 0253", + "source": "other", "stage": "New enquiry", + "opportunity_amount": 1200, "currency": "USD", + "custom_fields": { "course_interest": "SAT Prep", "intake": "Fall 2027", "parent_name": "Bilal Faruq", "parent_phone": "+1 404 555 0254", "previous_qualification": "High school, year 10" } }, + { "title": "Returning student, advanced data science", "first_name": "Andreas", "last_name": "Papp", + "email": "andreas.papp@example.com", "phone": "+1 720 555 0255", + "source": "existing customer", "stage": "Demo class", + "opportunity_amount": 3200, "currency": "USD", + "custom_fields": { "course_interest": "Data Science Fundamentals", "intake": "Spring 2027", "previous_qualification": "Completed our SAT prep in 2023" } }, + { "title": "Enquiry from the careers fair", "first_name": "Bethany", "last_name": "Cole", + "email": "bethany.cole@example.com", "phone": "+1 512 555 0256", + "source": "public relations", "stage": "New enquiry", + "opportunity_amount": 6500, "currency": "USD", + "custom_fields": { "course_interest": "Full-Stack Web Development", "intake": "Summer 2027", "previous_qualification": "High school diploma" } }, + { "title": "Withdrew before the demo class", "first_name": "Caleb", "last_name": "Nyong'o", + "email": "caleb.nyongo@example.com", "phone": "+1 470 555 0257", + "source": "website", "stage": "Not joining", + "opportunity_amount": 3200, "currency": "USD", + "custom_fields": { "course_interest": "Data Science Fundamentals", "intake": "Spring 2027", "previous_qualification": "Bachelor's in Physics" } } + ] + } +} diff --git a/backend/packs/professional-services.json b/backend/packs/professional-services.json new file mode 100644 index 00000000..904dfb80 --- /dev/null +++ b/backend/packs/professional-services.json @@ -0,0 +1,323 @@ +{ + "id": "professional-services", + "version": 2, + "name": "Professional services", + "description": "Agencies, consultancies and accounting firms selling billable work.", + "terminology": { + "lead.singular": "Enquiry", + "lead.plural": "Enquiries", + "opportunity.singular": "Engagement", + "opportunity.plural": "Engagements" + }, + "lead_pipeline": { + "name": "New business", + "description": "Enquiry through to signed engagement.", + "stages": [ + { "name": "New enquiry", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "Qualified", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "Scoping", "order": 3, "stage_type": "open", "color": "#6366F1" }, + { "name": "Proposal sent", "order": 4, "stage_type": "open", "color": "#8B5CF6" }, + { "name": "Won", "order": 5, "stage_type": "won", "color": "#10B981" }, + { "name": "Lost pitch", "order": 6, "stage_type": "lost", "color": "#EF4444" } + ] + }, + "case_pipeline": { + "name": "Client support", + "description": "Questions and issues raised by clients mid-engagement.", + "stages": [ + { "name": "New", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "With consultant", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "Awaiting client", "order": 3, "stage_type": "open", "color": "#F59E0B" }, + { "name": "Resolved", "order": 4, "stage_type": "closed", "color": "#10B981" }, + { "name": "Out of scope", "order": 5, "stage_type": "rejected", "color": "#EF4444" } + ] + }, + "task_pipeline": { + "name": "Delivery", + "description": "Billable work in flight.", + "stages": [ + { "name": "Backlog", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "This week", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "In progress", "order": 3, "stage_type": "in_progress", "color": "#8B5CF6" }, + { "name": "Delivered", "order": 4, "stage_type": "completed", "color": "#10B981" } + ] + }, + "custom_fields": [ + { "target": "Lead", "key": "service_line", "label": "Service line", "field_type": "text", "is_filterable": true, "display_order": 1 }, + { "target": "Lead", "key": "engagement_type", "label": "Engagement type", "field_type": "dropdown", "is_filterable": true, "display_order": 2, + "options": [ + { "value": "project", "label": "Fixed-scope project" }, + { "value": "retainer", "label": "Retainer" }, + { "value": "timeandmaterials", "label": "Time and materials" } + ] }, + { "target": "Lead", "key": "estimated_start", "label": "Estimated start", "field_type": "date", "display_order": 3 } + ], + "tags": [ + { "name": "Retainer", "color": "emerald" }, + { "name": "Inbound", "color": "sky" }, + { "name": "Referral", "color": "green" }, + { "name": "Rebid", "color": "amber" } + ], + "products": [ + { "name": "Discovery workshop", "sku": "PS-DISCOVERY", "price": 3500, "category": "Consulting", + "description": "Two-day facilitated workshop with a written findings summary." }, + { "name": "Brand strategy sprint", "sku": "PS-BRAND-SPRINT", "price": 18000, "category": "Consulting", + "description": "Six-week positioning, messaging and identity engagement." }, + { "name": "Website design & build", "sku": "PS-WEB-BUILD", "price": 12500, "category": "Design", + "description": "Fixed-scope marketing site, up to twelve templates." }, + { "name": "Monthly retainer — Growth", "sku": "PS-RET-GROWTH", "price": 4500, "category": "Retainer", + "description": "Ongoing strategy and delivery, forty hours a month." }, + { "name": "Bookkeeping retainer", "sku": "PS-RET-BOOKS", "price": 1500, "category": "Retainer", + "description": "Monthly reconciliation, payroll and management accounts." }, + { "name": "Tax advisory (hourly)", "sku": "PS-TAX-HR", "price": 240, "category": "Advisory", + "description": "Senior tax adviser time, billed hourly." }, + { "name": "Audit support (hourly)", "sku": "PS-AUDIT-HR", "price": 185, "category": "Advisory", + "description": "Audit preparation and fieldwork support, billed hourly." }, + { "name": "Interim CFO (day rate)", "sku": "PS-CFO-DAY", "price": 1200, "category": "Advisory", + "description": "Fractional CFO, minimum four days a month." } + ], + "sample_data": { + "accounts": [ + { "name": "Northwind Retail Group", "industry": "APPAREL & ACCESSORIES", "email": "ops@northwindretail.example.com", + "phone": "+1 646 555 0400", "website": "https://northwindretail.example.com", "city": "New York", "country": "US", + "description": "Fourteen-store chain, mid-market apparel. Brand refresh in progress." }, + { "name": "Lakeside Manufacturing", "industry": "MANUFACTURING", "email": "finance@lakesidemfg.example.com", + "phone": "+1 312 555 0401", "city": "Chicago", "country": "US", + "description": "Family-owned; bookkeeping and payroll on a monthly retainer." }, + { "name": "Meridian Health Partners", "industry": "HEALTHCARE", "email": "admin@meridianhealth.example.com", + "phone": "+1 617 555 0402", "website": "https://meridianhealth.example.com", "city": "Boston", "country": "US", + "description": "Twelve clinics. Compliance review and audit support." }, + { "name": "Brightpath Software", "industry": "SOFTWARE", "email": "hello@brightpath.example.com", + "phone": "+1 206 555 0403", "website": "https://brightpath.example.com", "city": "Seattle", "country": "US", + "description": "Series A SaaS; website redesign and positioning." }, + { "name": "Corvus Capital", "industry": "FINANCE", "email": "office@corvuscapital.example.com", + "phone": "+1 415 555 0404", "city": "San Francisco", "country": "US", + "description": "Boutique fund. Interim CFO engagement." }, + { "name": "Halcyon Hospitality", "industry": "ENTERTAINMENT & LEISURE", "email": "gm@halcyonhotels.example.com", + "phone": "+1 305 555 0405", "city": "Miami", "country": "US", + "description": "Six boutique hotels; digital marketing pitch lost on price." } + ], + "contacts": [ + { "first_name": "Nora", "last_name": "Fitzgerald", "email": "nora.fitzgerald@example.com", "phone": "+1 646 555 0112", + "title": "Marketing Director", "account": "Northwind Retail Group", "city": "New York", "country": "US", + "description": "Owns the brand refresh budget. Prefers written updates." }, + { "first_name": "Malik", "last_name": "Johnson", "email": "malik.johnson@example.com", "phone": "+1 312 555 0198", + "title": "Finance Manager", "account": "Lakeside Manufacturing", "city": "Chicago", "country": "US", + "description": "Day-to-day bookkeeping contact." }, + { "first_name": "Oliver", "last_name": "Bennett", "email": "oliver.bennett@example.com", "phone": "+1 617 555 0157", + "title": "Chief Operating Officer", "account": "Meridian Health Partners", "city": "Boston", "country": "US", + "description": "Signed the audit support engagement." }, + { "first_name": "Yuki", "last_name": "Sato", "email": "yuki.sato@example.com", "phone": "+1 206 555 0123", + "title": "Head of Product", "account": "Brightpath Software", "city": "Seattle", "country": "US", + "description": "Website redesign; wants the scoping session before committing." }, + { "first_name": "Ingrid", "last_name": "Larsen", "email": "ingrid.larsen@example.com", "phone": "+1 720 555 0189", + "title": "Managing Partner", "account": "Corvus Capital", "city": "San Francisco", "country": "US", + "description": "Looking for an interim CFO from November." }, + { "first_name": "Priyanka", "last_name": "Nair", "email": "priyanka.nair@example.com", "phone": "+1 972 555 0168", + "title": "Group Marketing Lead", "account": "Halcyon Hospitality", "city": "Miami", "country": "US", + "description": "Ran the digital marketing tender; open to a rebid next year." }, + { "first_name": "Carla", "last_name": "Mendes", "email": "carla.mendes@example.com", "phone": "+1 305 555 0145", + "title": "Financial Controller", "account": "Halcyon Hospitality", "city": "Miami", "country": "US", + "description": "Quarterly tax advisory; separate budget from marketing." }, + { "first_name": "Ethan", "last_name": "Zhou", "email": "ethan.zhou@example.com", "phone": "+1 415 555 0178", + "title": "Head of People", "account": "Corvus Capital", "city": "San Francisco", "country": "US", + "description": "HR consulting project, fixed scope." } + ], + "deals": [ + { "name": "Northwind brand refresh", "account": "Northwind Retail Group", "stage": "PROPOSAL", + "amount": 18000, "currency": "USD", "probability": 60, "closed_on": "2026-09-15", "lead_source": "PARTNER", + "description": "Proposal sent for a six-week sprint across fourteen stores." }, + { "name": "Lakeside bookkeeping retainer", "account": "Lakeside Manufacturing", "stage": "CLOSED_WON", + "amount": 18000, "currency": "USD", "probability": 100, "closed_on": "2026-07-01", "lead_source": "EXISTING CUSTOMER", + "description": "Twelve-month retainer, billed monthly." }, + { "name": "Meridian audit support", "account": "Meridian Health Partners", "stage": "CLOSED_WON", + "amount": 9500, "currency": "USD", "probability": 100, "closed_on": "2026-07-18", "lead_source": "EXISTING CUSTOMER", + "description": "Time and materials, capped at sixty hours." }, + { "name": "Brightpath website redesign", "account": "Brightpath Software", "stage": "QUALIFICATION", + "amount": 12500, "currency": "USD", "probability": 35, "closed_on": "2026-10-01", "lead_source": "CAMPAIGN", + "description": "Scoping call held; waiting on their design lead." }, + { "name": "Corvus interim CFO", "account": "Corvus Capital", "stage": "NEGOTIATION", + "amount": 57600, "currency": "USD", "probability": 65, "closed_on": "2026-11-15", "lead_source": "CAMPAIGN", + "description": "Four days a month for twelve months; negotiating notice terms." }, + { "name": "Halcyon digital marketing", "account": "Halcyon Hospitality", "stage": "CLOSED_LOST", + "amount": 9000, "currency": "USD", "probability": 0, "closed_on": "2026-06-30", "lead_source": "OTHER", + "description": "Lost on price to an in-house hire. Revisit at renewal." }, + { "name": "Corvus HR consulting project", "account": "Corvus Capital", "stage": "PROPOSAL", + "amount": 15000, "currency": "USD", "probability": 55, "closed_on": "2026-10-10", "lead_source": "PARTNER", + "description": "Fixed-scope handbook and performance framework rewrite." } + ], + "tickets": [ + { "name": "Payroll figures disagree with the ledger", "status": "New", "priority": "Urgent", "case_type": "Problem", + "account": "Lakeside Manufacturing", "contacts": ["Malik Johnson"], "stage": "New", + "description": "June payroll total is out by 2,140 against the management accounts." }, + { "name": "Extra audit sample requested", "status": "Assigned", "priority": "High", "case_type": "Question", + "account": "Meridian Health Partners", "contacts": ["Oliver Bennett"], "stage": "With consultant", + "description": "Auditors want twelve more purchase invoices from Q1." }, + { "name": "Waiting on brand assets from the client", "status": "Pending", "priority": "Normal", "case_type": "Incident", + "account": "Northwind Retail Group", "contacts": ["Nora Fitzgerald"], "stage": "Awaiting client", + "description": "Logo source files not yet supplied; sprint timeline at risk." }, + { "name": "Question on the retainer hour rollover", "status": "Closed", "priority": "Low", "case_type": "Question", + "account": "Lakeside Manufacturing", "contacts": ["Malik Johnson"], "stage": "Resolved", + "description": "Confirmed unused hours do not roll over; clause 4.2." }, + { "name": "Request to run their staff away-day", "status": "Rejected", "priority": "Low", "case_type": "Question", + "account": "Corvus Capital", "contacts": ["Ethan Zhou"], "stage": "Out of scope", + "description": "Outside the HR consulting scope; referred to a partner facilitator." } + ], + "tasks": [ + { "title": "Reconcile June payroll against the ledger", "status": "In Progress", "priority": "High", + "due_date": "2026-08-03", "stage": "In progress", "ticket": "Payroll figures disagree with the ledger", + "description": "Start from the bank feed, not the payroll export." }, + { "title": "Pull the twelve Q1 purchase invoices", "status": "New", "priority": "High", "due_date": "2026-08-05", + "stage": "This week", "ticket": "Extra audit sample requested", + "description": "Scan and index before sending to the audit team." }, + { "title": "Chase Northwind for the logo source files", "status": "New", "priority": "Medium", + "due_date": "2026-08-04", "stage": "This week", "ticket": "Waiting on brand assets from the client", + "description": "Third request — escalate to Nora if nothing by Friday." }, + { "title": "Draft the brand refresh statement of work", "status": "In Progress", "priority": "High", + "due_date": "2026-08-08", "stage": "In progress", "deal": "Northwind brand refresh", + "description": "Fourteen stores, phased rollout, two rounds of revision." }, + { "title": "Book the Brightpath scoping session", "status": "New", "priority": "Medium", "due_date": "2026-08-06", + "stage": "Backlog", "deal": "Brightpath website redesign", + "description": "Needs their design lead in the room." }, + { "title": "Send interim CFO notice terms", "status": "In Progress", "priority": "High", "due_date": "2026-08-07", + "stage": "In progress", "deal": "Corvus interim CFO", + "description": "They want ninety days; we opened at thirty." }, + { "title": "Prepare the Meridian audit summary", "status": "Completed", "priority": "Medium", + "due_date": "2026-07-25", "stage": "Delivered", "deal": "Meridian audit support", + "description": "Delivered with the fieldwork pack." }, + { "title": "Quarterly account review — Lakeside", "status": "New", "priority": "Low", "due_date": "2026-09-30", + "stage": "Backlog", "account": "Lakeside Manufacturing", + "description": "Retainer renewal conversation due in November." }, + { "title": "Follow up the bookkeeping retainer enquiry", "status": "New", "priority": "Medium", + "due_date": "2026-08-05", "stage": "This week", "lead": "Monthly bookkeeping retainer enquiry", + "description": "Send the retainer rate card and two references." } + ], + "leads": [ + { "title": "Brand refresh for a retail chain", "first_name": "Nora", "last_name": "Fitzgerald", + "email": "nora.fitzgerald@example.com", "phone": "+1 646 555 0112", + "source": "partner", "stage": "Proposal sent", + "opportunity_amount": 18000, "currency": "USD", + "custom_fields": { "service_line": "Brand Strategy", "engagement_type": "project", "estimated_start": "2026-09-15" } }, + { "title": "Monthly bookkeeping retainer enquiry", "first_name": "Malik", "last_name": "Johnson", + "email": "malik.johnson@example.com", "phone": "+1 312 555 0198", + "source": "compaign", "stage": "New enquiry", + "opportunity_amount": 1500, "currency": "USD", + "custom_fields": { "service_line": "Accounting & Bookkeeping", "engagement_type": "retainer", "estimated_start": "2026-10-01" } }, + { "title": "Website redesign scoping call", "first_name": "Yuki", "last_name": "Sato", + "email": "yuki.sato@example.com", "phone": "+1 206 555 0123", + "source": "compaign", "stage": "Scoping", + "opportunity_amount": 12500, "currency": "USD", + "custom_fields": { "service_line": "Web Design", "engagement_type": "project", "estimated_start": "2026-09-01" } }, + { "title": "Quarterly tax advisory", "first_name": "Carla", "last_name": "Mendes", + "email": "carla.mendes@example.com", "phone": "+1 305 555 0145", + "source": "existing customer", "stage": "Qualified", + "opportunity_amount": 2200, "currency": "USD", + "custom_fields": { "service_line": "Tax Advisory", "engagement_type": "retainer", "estimated_start": "2026-11-01" } }, + { "title": "Engagement signed for audit support", "first_name": "Oliver", "last_name": "Bennett", + "email": "oliver.bennett@example.com", "phone": "+1 617 555 0157", + "source": "existing customer", "stage": "Won", + "opportunity_amount": 9500, "currency": "USD", + "custom_fields": { "service_line": "Audit Support", "engagement_type": "timeandmaterials", "estimated_start": "2026-08-15" } }, + { "title": "Digital marketing pitch, lost", "first_name": "Priyanka", "last_name": "Nair", + "email": "priyanka.nair@example.com", "phone": "+1 972 555 0168", + "source": "other", "stage": "Lost pitch", + "opportunity_amount": 9000, "currency": "USD", + "custom_fields": { "service_line": "Digital Marketing", "engagement_type": "project", "estimated_start": "2026-09-20" } }, + { "title": "HR consulting fixed-scope project", "first_name": "Ethan", "last_name": "Zhou", + "email": "ethan.zhou@example.com", "phone": "+1 415 555 0178", + "source": "partner", "stage": "Proposal sent", + "opportunity_amount": 15000, "currency": "USD", + "custom_fields": { "service_line": "HR Consulting", "engagement_type": "project", "estimated_start": "2026-10-10" } }, + { "title": "Interim CFO retainer enquiry", "first_name": "Ingrid", "last_name": "Larsen", + "email": "ingrid.larsen@example.com", "phone": "+1 720 555 0189", + "source": "compaign", "stage": "New enquiry", + "opportunity_amount": 6500, "currency": "USD", + "custom_fields": { "service_line": "Interim CFO", "engagement_type": "retainer", "estimated_start": "2026-11-15" } }, + { "title": "Compliance review, time and materials", "first_name": "Kwame", "last_name": "Boateng", + "email": "kwame.boateng@example.com", "phone": "+1 470 555 0191", + "source": "other", "stage": "Qualified", + "opportunity_amount": 7200, "currency": "USD", + "custom_fields": { "service_line": "Compliance Review", "engagement_type": "timeandmaterials", "estimated_start": "2026-09-05" } }, + { "title": "Rebrand engagement scoping", "first_name": "Renata", "last_name": "Silva", + "email": "renata.silva@example.com", "phone": "+1 786 555 0102", + "source": "existing customer", "stage": "Scoping", + "opportunity_amount": 18000, "currency": "USD", + "custom_fields": { "service_line": "Brand Strategy", "engagement_type": "project", "estimated_start": "2026-10-20" } }, + { "title": "Payroll migration for a 200-person firm", "first_name": "Tobias", "last_name": "Werner", + "email": "tobias.werner@example.com", "phone": "+1 312 555 0220", + "source": "website", "stage": "Qualified", + "opportunity_amount": 8400, "currency": "USD", + "custom_fields": { "service_line": "Accounting & Bookkeeping", "engagement_type": "project", "estimated_start": "2026-10-05" } }, + { "title": "Go-to-market strategy for a hardware launch", "first_name": "Elena", "last_name": "Vasquez", + "email": "elena.vasquez@example.com", "phone": "+1 206 555 0221", + "source": "partner", "stage": "Proposal sent", + "opportunity_amount": 24000, "currency": "USD", + "custom_fields": { "service_line": "Brand Strategy", "engagement_type": "project", "estimated_start": "2026-11-01" } }, + { "title": "Ongoing SEO and content retainer", "first_name": "Jonah", "last_name": "Kimani", + "email": "jonah.kimani@example.com", "phone": "+1 404 555 0222", + "source": "compaign", "stage": "New enquiry", + "opportunity_amount": 3200, "currency": "USD", + "custom_fields": { "service_line": "Digital Marketing", "engagement_type": "retainer", "estimated_start": "2026-09-10" } }, + { "title": "ERP selection advisory", "first_name": "Sandra", "last_name": "Bergström", + "email": "sandra.bergstrom@example.com", "phone": "+1 617 555 0223", + "source": "existing customer", "stage": "Scoping", + "opportunity_amount": 16500, "currency": "USD", + "custom_fields": { "service_line": "Technology Advisory", "engagement_type": "timeandmaterials", "estimated_start": "2026-09-25" } }, + { "title": "Employee handbook rewrite", "first_name": "Marcus", "last_name": "O'Rourke", + "email": "marcus.orourke@example.com", "phone": "+1 646 555 0224", + "source": "website", "stage": "Qualified", + "opportunity_amount": 5600, "currency": "USD", + "custom_fields": { "service_line": "HR Consulting", "engagement_type": "project", "estimated_start": "2026-09-15" } }, + { "title": "Annual accounts and corporation tax", "first_name": "Fatima", "last_name": "Haddad", + "email": "fatima.haddad@example.com", "phone": "+1 305 555 0225", + "source": "partner", "stage": "Won", + "opportunity_amount": 4800, "currency": "USD", + "custom_fields": { "service_line": "Tax Advisory", "engagement_type": "project", "estimated_start": "2026-08-20" } }, + { "title": "Rebid — lost last year on price", "first_name": "Gregor", "last_name": "Novak", + "email": "gregor.novak@example.com", "phone": "+1 720 555 0226", + "source": "existing customer", "stage": "New enquiry", + "opportunity_amount": 11000, "currency": "USD", + "custom_fields": { "service_line": "Digital Marketing", "engagement_type": "retainer", "estimated_start": "2026-12-01" } }, + { "title": "Due diligence support for an acquisition", "first_name": "Aisling", "last_name": "Byrne", + "email": "aisling.byrne@example.com", "phone": "+1 415 555 0227", + "source": "partner", "stage": "Proposal sent", + "opportunity_amount": 32000, "currency": "USD", + "custom_fields": { "service_line": "Audit Support", "engagement_type": "timeandmaterials", "estimated_start": "2026-09-08" } }, + { "title": "Fractional marketing lead", "first_name": "Devon", "last_name": "Clarke", + "email": "devon.clarke@example.com", "phone": "+1 972 555 0228", + "source": "compaign", "stage": "Scoping", + "opportunity_amount": 7800, "currency": "USD", + "custom_fields": { "service_line": "Interim CFO", "engagement_type": "retainer", "estimated_start": "2026-10-15" } }, + { "title": "Process mapping for a claims team", "first_name": "Hiroshi", "last_name": "Tanaka", + "email": "hiroshi.tanaka@example.com", "phone": "+1 206 555 0229", + "source": "website", "stage": "Qualified", + "opportunity_amount": 9600, "currency": "USD", + "custom_fields": { "service_line": "Compliance Review", "engagement_type": "project", "estimated_start": "2026-09-22" } }, + { "title": "Pitch declined, went in-house", "first_name": "Bianca", "last_name": "Rossi", + "email": "bianca.rossi@example.com", "phone": "+1 646 555 0230", + "source": "other", "stage": "Lost pitch", + "opportunity_amount": 14000, "currency": "USD", + "custom_fields": { "service_line": "Web Design", "engagement_type": "project", "estimated_start": "2026-08-30" } }, + { "title": "Grant application support", "first_name": "Samuel", "last_name": "Adeyemi", + "email": "samuel.adeyemi@example.com", "phone": "+1 470 555 0231", + "source": "existing customer", "stage": "Won", + "opportunity_amount": 3400, "currency": "USD", + "custom_fields": { "service_line": "Compliance Review", "engagement_type": "project", "estimated_start": "2026-08-12" } }, + { "title": "Retainer uplift for an existing client", "first_name": "Charlotte", "last_name": "Meyer", + "email": "charlotte.meyer@example.com", "phone": "+1 617 555 0232", + "source": "existing customer", "stage": "Proposal sent", + "opportunity_amount": 2600, "currency": "USD", + "custom_fields": { "service_line": "Accounting & Bookkeeping", "engagement_type": "retainer", "estimated_start": "2026-09-01" } }, + { "title": "Board reporting pack redesign", "first_name": "Rafael", "last_name": "Ortega", + "email": "rafael.ortega@example.com", "phone": "+1 786 555 0233", + "source": "partner", "stage": "New enquiry", + "opportunity_amount": 6200, "currency": "USD", + "custom_fields": { "service_line": "Interim CFO", "engagement_type": "project", "estimated_start": "2026-10-28" } }, + { "title": "Cyber compliance readiness review", "first_name": "Anneke", "last_name": "Visser", + "email": "anneke.visser@example.com", "phone": "+1 503 555 0234", + "source": "website", "stage": "Scoping", + "opportunity_amount": 13500, "currency": "USD", + "custom_fields": { "service_line": "Compliance Review", "engagement_type": "timeandmaterials", "estimated_start": "2026-11-10" } } + ] + } +} diff --git a/backend/packs/real-estate.json b/backend/packs/real-estate.json new file mode 100644 index 00000000..b1bc631b --- /dev/null +++ b/backend/packs/real-estate.json @@ -0,0 +1,294 @@ +{ + "id": "real-estate", + "version": 2, + "name": "Real estate", + "description": "Brokers, agencies and developers selling residential and commercial property.", + "terminology": { + "lead.singular": "Enquiry", + "lead.plural": "Enquiries", + "opportunity.singular": "Deal", + "opportunity.plural": "Deals" + }, + "lead_pipeline": { + "name": "Sales", + "description": "Buyer enquiry through to booking.", + "stages": [ + { "name": "New enquiry", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "Contacted", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "Shortlisted", "order": 3, "stage_type": "open", "color": "#6366F1" }, + { "name": "Site visit", "order": 4, "stage_type": "open", "color": "#8B5CF6" }, + { "name": "Negotiation", "order": 5, "stage_type": "open", "color": "#F59E0B" }, + { "name": "Booked", "order": 6, "stage_type": "won", "color": "#10B981" }, + { "name": "Fell through", "order": 7, "stage_type": "lost", "color": "#EF4444" } + ] + }, + "case_pipeline": { + "name": "Service requests", + "description": "Buyer and owner issues after a booking.", + "stages": [ + { "name": "New request", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "With agent", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "Awaiting owner", "order": 3, "stage_type": "open", "color": "#F59E0B" }, + { "name": "Resolved", "order": 4, "stage_type": "closed", "color": "#10B981" }, + { "name": "Not our remit", "order": 5, "stage_type": "rejected", "color": "#EF4444" } + ] + }, + "task_pipeline": { + "name": "Agent workflow", + "description": "Day-to-day agent follow-ups.", + "stages": [ + { "name": "To do", "order": 1, "stage_type": "open", "color": "#6B7280" }, + { "name": "Scheduled", "order": 2, "stage_type": "open", "color": "#3B82F6" }, + { "name": "In progress", "order": 3, "stage_type": "in_progress", "color": "#8B5CF6" }, + { "name": "Done", "order": 4, "stage_type": "completed", "color": "#10B981" } + ] + }, + "custom_fields": [ + { "target": "Lead", "key": "budget_max", "label": "Budget (max)", "field_type": "number", "is_filterable": true, "display_order": 1 }, + { "target": "Lead", "key": "configuration", "label": "Configuration", "field_type": "dropdown", "is_filterable": true, "display_order": 2, + "options": [ + { "value": "studio", "label": "Studio" }, + { "value": "1bed", "label": "1 bed" }, + { "value": "2bed", "label": "2 bed" }, + { "value": "3bed", "label": "3 bed" }, + { "value": "villa", "label": "Villa" }, + { "value": "plot", "label": "Plot" } + ] }, + { "target": "Lead", "key": "preferred_location", "label": "Preferred location", "field_type": "text", "display_order": 3 }, + { "target": "Lead", "key": "possession_by", "label": "Possession by", "field_type": "date", "display_order": 4 }, + { "target": "Lead", "key": "finance_required", "label": "Finance required", "field_type": "checkbox", "display_order": 5 } + ], + "tags": [ + { "name": "Hot", "color": "red", "description": "Ready to transact" }, + { "name": "Investor", "color": "violet", "description": "Buying to let or resell" }, + { "name": "End user", "color": "teal", "description": "Buying to occupy" }, + { "name": "Channel partner", "color": "amber", "description": "Introduced by a partner" } + ], + "products": [ + { "name": "Buyer representation fee", "sku": "RE-BUY-REP", "price": 2500, "category": "Advisory", + "description": "Fixed fee for representing a buyer through search, negotiation and closing." }, + { "name": "Seller listing commission", "sku": "RE-SELL-COMM", "price": 0, "category": "Commission", + "description": "Percentage commission on the sale price; recorded per deal." }, + { "name": "Property valuation", "sku": "RE-VALUATION", "price": 450, "category": "Advisory", + "description": "Written market valuation with comparable evidence." }, + { "name": "Home staging", "sku": "RE-STAGING", "price": 1800, "category": "Marketing", + "description": "Furniture hire, styling and photography for a listing." }, + { "name": "Documentation & registration support", "sku": "RE-DOCS", "price": 900, "category": "Advisory", + "description": "Title checks, paperwork and registration assistance." }, + { "name": "Property management (monthly)", "sku": "RE-PM-MONTH", "price": 250, "category": "Management", + "description": "Tenant handling, rent collection and maintenance coordination." } + ], + "sample_data": { + "accounts": [ + { "name": "Harbourline Developments", "industry": "REAL ESTATE", "email": "sales@harbourline.example.com", + "phone": "+1 415 555 0300", "website": "https://harbourline.example.com", "city": "San Francisco", "country": "US", + "description": "Waterfront developer, three active towers in the Marina District." }, + { "name": "Cedar Grove Estates", "industry": "REAL ESTATE", "email": "hello@cedargrove.example.com", + "phone": "+1 617 555 0301", "website": "https://cedargrove.example.com", "city": "Boston", "country": "US", + "description": "Suburban family homes near the new school catchment." }, + { "name": "Ridgeview Hills Partners", "industry": "REAL ESTATE", "email": "enquiries@ridgeview.example.com", + "phone": "+1 480 555 0302", "city": "Scottsdale", "country": "US", + "description": "Hillside villa plots, gated community." }, + { "name": "Riverside Property Group", "industry": "REAL ESTATE", "email": "team@riversidepg.example.com", + "phone": "+1 305 555 0303", "city": "Miami", "country": "US", + "description": "Rental investment stock, mostly 1 and 2 bed." }, + { "name": "Willow Creek Land Co.", "industry": "REAL ESTATE", "email": "plots@willowcreek.example.com", + "phone": "+1 214 555 0304", "city": "Dallas", "country": "US", + "description": "Serviced plots for custom builds." }, + { "name": "Coral Bay Residences", "industry": "REAL ESTATE", "email": "sales@coralbay.example.com", + "phone": "+1 786 555 0305", "city": "Miami", "country": "US", + "description": "Sea-view villas and penthouses." } + ], + "contacts": [ + { "first_name": "Dana", "last_name": "Whitfield", "email": "dana.whitfield@example.com", "phone": "+1 415 555 0132", + "title": "Buyer", "account": "Harbourline Developments", "city": "San Francisco", "country": "US", + "description": "Looking for a 3 bed near the waterfront; second viewing booked." }, + { "first_name": "Tomasz", "last_name": "Kowalski", "email": "tomasz.kowalski@example.com", "phone": "+1 480 555 0177", + "title": "Buyer", "account": "Ridgeview Hills Partners", "city": "Scottsdale", "country": "US", + "description": "Cash buyer, hillside villa with a pool." }, + { "first_name": "Sofia", "last_name": "Alvarez", "email": "sofia.alvarez@example.com", "phone": "+1 305 555 0164", + "title": "Investor", "account": "Riverside Property Group", "city": "Miami", "country": "US", + "description": "Buying to let; wants yield figures before viewing." }, + { "first_name": "James", "last_name": "Okafor", "email": "james.okafor@example.com", "phone": "+1 214 555 0121", + "title": "Buyer", "account": "Willow Creek Land Co.", "city": "Dallas", "country": "US", + "description": "Corner plot for a custom build." }, + { "first_name": "Ahmed", "last_name": "Al-Sayed", "email": "ahmed.alsayed@example.com", "phone": "+1 786 555 0199", + "title": "Buyer", "account": "Coral Bay Residences", "city": "Miami", "country": "US", + "description": "Sea-view villa, no finance required." }, + { "first_name": "Priya", "last_name": "Raman", "email": "priya.raman@example.com", "phone": "+1 617 555 0143", + "title": "Buyer", "account": "Cedar Grove Estates", "city": "Boston", "country": "US", + "description": "2 bed with parking, school catchment matters." }, + { "first_name": "Marcus", "last_name": "Ilunga", "email": "marcus.ilunga@example.com", "phone": "+1 312 555 0198", + "title": "Buyer", "account": "Riverside Property Group", "city": "Chicago", "country": "US", + "description": "First-time buyer, studio budget." }, + { "first_name": "Helen", "last_name": "Okonkwo", "email": "helen.okonkwo@example.com", "phone": "+1 415 555 0306", + "title": "Sales Director", "account": "Harbourline Developments", "city": "San Francisco", "country": "US", + "description": "Developer-side contact for allocation and pricing." } + ], + "deals": [ + { "name": "Marina District 3 bed — Whitfield", "account": "Harbourline Developments", "stage": "NEGOTIATION", + "amount": 850000, "currency": "USD", "probability": 70, "closed_on": "2026-09-30", "lead_source": "CAMPAIGN", + "description": "Second viewing done; negotiating on the parking space." }, + { "name": "Ridgeview villa — Kowalski", "account": "Ridgeview Hills Partners", "stage": "PROPOSAL", + "amount": 1650000, "currency": "USD", "probability": 50, "closed_on": "2026-10-31", "lead_source": "PARTNER", + "description": "Cash buyer, wants the plot survey before signing." }, + { "name": "Riverside 1 bed rental — Alvarez", "account": "Riverside Property Group", "stage": "QUALIFICATION", + "amount": 300000, "currency": "USD", "probability": 30, "closed_on": "2026-11-15", "lead_source": "PARTNER", + "description": "Investor; needs the rental yield pack." }, + { "name": "Willow Creek corner plot — Okafor", "account": "Willow Creek Land Co.", "stage": "PROSPECTING", + "amount": 95000, "currency": "USD", "probability": 10, "closed_on": "2026-12-15", "lead_source": "OTHER", + "description": "Early enquiry, no viewing yet." }, + { "name": "Coral Bay sea-view villa — Al-Sayed", "account": "Coral Bay Residences", "stage": "CLOSED_WON", + "amount": 1980000, "currency": "USD", "probability": 100, "closed_on": "2026-07-20", "lead_source": "OTHER", + "description": "Booked. Handover scheduled for December." }, + { "name": "Cedar Grove 2 bed — Deshmukh", "account": "Cedar Grove Estates", "stage": "CLOSED_LOST", + "amount": 500000, "currency": "USD", "probability": 0, "closed_on": "2026-07-10", "lead_source": "CALL", + "description": "Financing fell through at the last stage." } + ], + "tickets": [ + { "name": "Snagging list from handover inspection", "status": "New", "priority": "High", "case_type": "Problem", + "account": "Coral Bay Residences", "contacts": ["Ahmed Al-Sayed"], "stage": "New request", + "description": "Eleven items raised at inspection, three affecting the kitchen fit-out." }, + { "name": "Parking allocation not confirmed", "status": "Assigned", "priority": "Normal", "case_type": "Question", + "account": "Harbourline Developments", "contacts": ["Dana Whitfield"], "stage": "With agent", + "description": "Buyer expected a second bay; allocation sheet says one." }, + { "name": "Delay on plot survey documents", "status": "Pending", "priority": "High", "case_type": "Incident", + "account": "Ridgeview Hills Partners", "contacts": ["Tomasz Kowalski"], "stage": "Awaiting owner", + "description": "Surveyor has not returned the report; buyer is chasing daily." }, + { "name": "Rental yield figures requested", "status": "Closed", "priority": "Low", "case_type": "Question", + "account": "Riverside Property Group", "contacts": ["Sofia Alvarez"], "stage": "Resolved", + "description": "Sent the three-year yield summary; investor is satisfied." }, + { "name": "Neighbour boundary dispute", "status": "Rejected", "priority": "Normal", "case_type": "Problem", + "account": "Willow Creek Land Co.", "contacts": ["James Okafor"], "stage": "Not our remit", + "description": "Pre-existing dispute between adjoining owners; referred to their solicitors." } + ], + "tasks": [ + { "title": "Confirm second viewing time", "status": "New", "priority": "High", "due_date": "2026-08-05", + "stage": "Scheduled", "deal": "Marina District 3 bed — Whitfield", + "description": "Buyer prefers a Saturday morning." }, + { "title": "Send the plot survey to the buyer", "status": "In Progress", "priority": "High", "due_date": "2026-08-04", + "stage": "In progress", "deal": "Ridgeview villa — Kowalski", + "description": "Chase the surveyor first — see the open service request." }, + { "title": "Prepare rental yield pack", "status": "Completed", "priority": "Medium", "due_date": "2026-07-28", + "stage": "Done", "deal": "Riverside 1 bed rental — Alvarez", + "description": "Three-year figures with comparable rents." }, + { "title": "Book handover inspection follow-up", "status": "New", "priority": "High", "due_date": "2026-08-08", + "stage": "To do", "ticket": "Snagging list from handover inspection", + "description": "Re-inspect the kitchen items once the contractor has been." }, + { "title": "Confirm parking allocation with the developer", "status": "In Progress", "priority": "Medium", + "due_date": "2026-08-06", "stage": "In progress", "ticket": "Parking allocation not confirmed", + "description": "Helen holds the allocation sheet." }, + { "title": "Quarterly check-in with Harbourline", "status": "New", "priority": "Low", "due_date": "2026-09-01", + "stage": "To do", "account": "Harbourline Developments", + "description": "Review the allocation for the next tower release." }, + { "title": "Call back the studio enquiry", "status": "New", "priority": "Medium", "due_date": "2026-08-03", + "stage": "To do", "lead": "Studio for a young professional", + "description": "First contact attempt went to voicemail." }, + { "title": "Send shortlist for the school catchment enquiry", "status": "In Progress", "priority": "Medium", + "due_date": "2026-08-07", "stage": "In progress", "lead": "2 bed with parking near the school", + "description": "Three properties inside the catchment, all with parking." } + ], + "leads": [ + { "title": "3 bed near the waterfront", "first_name": "Dana", "last_name": "Whitfield", + "email": "dana.whitfield@example.com", "phone": "+1 415 555 0132", + "source": "compaign", "stage": "Site visit", + "custom_fields": { "budget_max": 850000, "configuration": "3bed", "preferred_location": "Marina District", "possession_by": "2026-11-01", "finance_required": true } }, + { "title": "Studio for a young professional", "first_name": "Marcus", "last_name": "Ilunga", + "email": "marcus.ilunga@example.com", "phone": "+1 312 555 0198", + "source": "existing customer", "stage": "New enquiry", + "custom_fields": { "budget_max": 210000, "configuration": "studio", "preferred_location": "Downtown Loop", "finance_required": true } }, + { "title": "2 bed with parking near the school", "first_name": "Priya", "last_name": "Raman", + "email": "priya.raman@example.com", "phone": "+1 617 555 0143", + "source": "other", "stage": "Contacted", + "custom_fields": { "budget_max": 420000, "configuration": "2bed", "preferred_location": "Maple Heights", "possession_by": "2027-02-15", "finance_required": true } }, + { "title": "Hillside villa with a pool", "first_name": "Tomasz", "last_name": "Kowalski", + "email": "tomasz.kowalski@example.com", "phone": "+1 480 555 0177", + "source": "partner", "stage": "Negotiation", + "custom_fields": { "budget_max": 1650000, "configuration": "villa", "preferred_location": "Ridgeview Hills", "finance_required": false } }, + { "title": "1 bed rental investment", "first_name": "Sofia", "last_name": "Alvarez", + "email": "sofia.alvarez@example.com", "phone": "+1 305 555 0164", + "source": "partner", "stage": "Shortlisted", + "custom_fields": { "budget_max": 300000, "configuration": "1bed", "preferred_location": "Riverside", "finance_required": false } }, + { "title": "Corner plot for a custom build", "first_name": "James", "last_name": "Okafor", + "email": "james.okafor@example.com", "phone": "+1 214 555 0121", + "source": "other", "stage": "New enquiry", + "custom_fields": { "budget_max": 95000, "configuration": "plot", "preferred_location": "Willow Creek", "finance_required": false } }, + { "title": "3 bed booked for a spring move-in", "first_name": "Lucia", "last_name": "Fernandez", + "email": "lucia.fernandez@example.com", "phone": "+1 619 555 0155", + "source": "compaign", "stage": "Booked", + "custom_fields": { "budget_max": 780000, "configuration": "3bed", "preferred_location": "Oakwood Park", "possession_by": "2027-04-01", "finance_required": true } }, + { "title": "2 bed, financing fell through", "first_name": "Ravi", "last_name": "Deshmukh", + "email": "ravi.deshmukh@example.com", "phone": "+1 773 555 0110", + "source": "call", "stage": "Fell through", + "custom_fields": { "budget_max": 500000, "configuration": "2bed", "preferred_location": "Elm Terrace", "finance_required": true } }, + { "title": "2 bed near the new school", "first_name": "Emma", "last_name": "Larsen", + "email": "emma.larsen@example.com", "phone": "+1 720 555 0188", + "source": "existing customer", "stage": "Contacted", + "custom_fields": { "budget_max": 445000, "configuration": "2bed", "preferred_location": "Cedar Grove", "finance_required": true } }, + { "title": "Villa with a sea view", "first_name": "Ahmed", "last_name": "Al-Sayed", + "email": "ahmed.alsayed@example.com", "phone": "+1 786 555 0199", + "source": "other", "stage": "Site visit", + "custom_fields": { "budget_max": 1980000, "configuration": "villa", "preferred_location": "Coral Bay", "possession_by": "2026-12-20", "finance_required": false } }, + { "title": "Downsizing to a 2 bed", "first_name": "Margaret", "last_name": "Hollis", + "email": "margaret.hollis@example.com", "phone": "+1 503 555 0201", + "source": "existing customer", "stage": "Shortlisted", + "custom_fields": { "budget_max": 390000, "configuration": "2bed", "preferred_location": "Elm Terrace", "possession_by": "2027-01-15", "finance_required": false } }, + { "title": "Relocating for work, needs 3 bed", "first_name": "Daniel", "last_name": "Ferreira", + "email": "daniel.ferreira@example.com", "phone": "+1 971 555 0202", + "source": "website", "stage": "Contacted", + "custom_fields": { "budget_max": 720000, "configuration": "3bed", "preferred_location": "Oakwood Park", "possession_by": "2026-12-01", "finance_required": true } }, + { "title": "Second home by the water", "first_name": "Claire", "last_name": "Dubois", + "email": "claire.dubois@example.com", "phone": "+1 646 555 0203", + "source": "partner", "stage": "Site visit", + "custom_fields": { "budget_max": 1250000, "configuration": "villa", "preferred_location": "Coral Bay", "finance_required": false } }, + { "title": "Investor buying three units", "first_name": "Victor", "last_name": "Nwosu", + "email": "victor.nwosu@example.com", "phone": "+1 312 555 0204", + "source": "partner", "stage": "Negotiation", + "custom_fields": { "budget_max": 900000, "configuration": "1bed", "preferred_location": "Riverside", "finance_required": false } }, + { "title": "First-time buyer, studio budget", "first_name": "Amara", "last_name": "Diallo", + "email": "amara.diallo@example.com", "phone": "+1 404 555 0205", + "source": "website", "stage": "New enquiry", + "custom_fields": { "budget_max": 185000, "configuration": "studio", "preferred_location": "Downtown Loop", "finance_required": true } }, + { "title": "Plot enquiry from a builder", "first_name": "Nils", "last_name": "Andersen", + "email": "nils.andersen@example.com", "phone": "+1 214 555 0206", + "source": "call", "stage": "Contacted", + "custom_fields": { "budget_max": 140000, "configuration": "plot", "preferred_location": "Willow Creek", "finance_required": false } }, + { "title": "Family upsizing to a villa", "first_name": "Rosa", "last_name": "Iglesias", + "email": "rosa.iglesias@example.com", "phone": "+1 619 555 0207", + "source": "existing customer", "stage": "Shortlisted", + "custom_fields": { "budget_max": 1400000, "configuration": "villa", "preferred_location": "Ridgeview Hills", "possession_by": "2027-06-01", "finance_required": true } }, + { "title": "Booked a 1 bed off-plan", "first_name": "Peter", "last_name": "Nakamura", + "email": "peter.nakamura@example.com", "phone": "+1 415 555 0208", + "source": "compaign", "stage": "Booked", + "custom_fields": { "budget_max": 340000, "configuration": "1bed", "preferred_location": "Marina District", "possession_by": "2027-09-01", "finance_required": true } }, + { "title": "Withdrew after the survey", "first_name": "Sinead", "last_name": "Murphy", + "email": "sinead.murphy@example.com", "phone": "+1 617 555 0209", + "source": "website", "stage": "Fell through", + "custom_fields": { "budget_max": 465000, "configuration": "2bed", "preferred_location": "Maple Heights", "finance_required": true } }, + { "title": "Commercial plot, roadside frontage", "first_name": "Hassan", "last_name": "Qureshi", + "email": "hassan.qureshi@example.com", "phone": "+1 972 555 0210", + "source": "partner", "stage": "Negotiation", + "custom_fields": { "budget_max": 610000, "configuration": "plot", "preferred_location": "Willow Creek", "finance_required": false } }, + { "title": "Retiree seeking a ground-floor 2 bed", "first_name": "Alan", "last_name": "Whitcombe", + "email": "alan.whitcombe@example.com", "phone": "+1 503 555 0211", + "source": "call", "stage": "Contacted", + "custom_fields": { "budget_max": 355000, "configuration": "2bed", "preferred_location": "Cedar Grove", "possession_by": "2027-03-01", "finance_required": false } }, + { "title": "Referral from a past buyer", "first_name": "Grace", "last_name": "Mbeki", + "email": "grace.mbeki@example.com", "phone": "+1 470 555 0212", + "source": "existing customer", "stage": "Site visit", + "custom_fields": { "budget_max": 690000, "configuration": "3bed", "preferred_location": "Oakwood Park", "finance_required": true } }, + { "title": "Studio for a rental portfolio", "first_name": "Ivan", "last_name": "Petrov", + "email": "ivan.petrov@example.com", "phone": "+1 786 555 0213", + "source": "partner", "stage": "Shortlisted", + "custom_fields": { "budget_max": 225000, "configuration": "studio", "preferred_location": "Riverside", "finance_required": false } }, + { "title": "Waterfront penthouse enquiry", "first_name": "Beatrice", "last_name": "Lindqvist", + "email": "beatrice.lindqvist@example.com", "phone": "+1 646 555 0214", + "source": "compaign", "stage": "New enquiry", + "custom_fields": { "budget_max": 2400000, "configuration": "villa", "preferred_location": "Coral Bay", "finance_required": false } }, + { "title": "Chain broke on the sale", "first_name": "Owen", "last_name": "Griffiths", + "email": "owen.griffiths@example.com", "phone": "+1 720 555 0215", + "source": "other", "stage": "Fell through", + "custom_fields": { "budget_max": 520000, "configuration": "3bed", "preferred_location": "Elm Terrace", "finance_required": true } } + ] + } +} diff --git a/backend/tasks/migrations/0012_task_is_sample.py b/backend/tasks/migrations/0012_task_is_sample.py new file mode 100644 index 00000000..4007f401 --- /dev/null +++ b/backend/tasks/migrations/0012_task_is_sample.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-02 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0011_task_custom_fields'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='is_sample', + field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively — never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index c43194f9..6b617133 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -4,7 +4,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from accounts.models import Account -from common.base import AssignableMixin, BaseModel +from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from contacts.models import Contact @@ -346,6 +346,7 @@ class Task(AssignableMixin, BaseModel): teams = models.ManyToManyField(Teams, related_name="tasks_teams") tags = models.ManyToManyField(Tags, related_name="task_tags", blank=True) org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="tasks") + is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT) # Kanban fields kanban_order = models.DecimalField( diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js index 3d6e1138..824188d9 100644 --- a/frontend/src/hooks.server.js +++ b/frontend/src/hooks.server.js @@ -13,6 +13,7 @@ import * as Sentry from '@sentry/sveltekit'; import { redirect } from '@sveltejs/kit'; import axios from 'axios'; import { env } from '$env/dynamic/public'; +import { describeError } from '$lib/server/log-safe.js'; const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -130,7 +131,8 @@ async function performTokenRefresh(refreshToken) { return { access: response.data.access, refresh: response.data.refresh }; } catch (error) { - console.error('Token refresh failed:', error); + // Never log the raw error: its axios `config.data` is the refresh token. + console.error('Token refresh failed:', describeError(error)); return null; } } @@ -164,7 +166,8 @@ async function switchOrg(accessToken, orgId, refreshToken) { return response.data; } catch (error) { - console.error('Org switch failed:', error); + // Never log the raw error: its axios `config.headers` carries the JWT. + console.error('Org switch failed:', describeError(error)); return null; } } diff --git a/frontend/src/lib/server/log-safe.js b/frontend/src/lib/server/log-safe.js new file mode 100644 index 00000000..86bf9cc4 --- /dev/null +++ b/frontend/src/lib/server/log-safe.js @@ -0,0 +1,39 @@ +/** + * Safe error logging for server-side code. + * + * `console.error('...', err)` runs the error through Node's util.inspect, which + * walks the whole object graph. For an axios error that graph includes + * `config.headers.Authorization` (the caller's JWT) and `config.data` (the + * request body — which for the token-refresh call IS the refresh token, and for + * the OAuth exchange is the authorization code plus the PKCE verifier). Those + * end up in persistent server logs. + * + * Nothing here is derived from the request: no headers, no body, no URL. Only + * the message, HTTP status and error code, which together with the caller's own + * label are enough to identify what failed. + */ + +/** + * Reduce an error to a single line safe to write to server logs. + * + * @param {unknown} err - Any thrown value. + * @returns {string} A compact description containing no credentials. + */ +export function describeError(err) { + if (err === null || err === undefined) return 'unknown error'; + + const message = + typeof err === 'object' && err !== null && typeof (/** @type {any} */ (err).message) === 'string' + ? /** @type {any} */ (err).message + : String(err); + + const parts = [message]; + + const status = /** @type {any} */ (err)?.response?.status ?? /** @type {any} */ (err)?.status; + if (status !== undefined && status !== null) parts.push(`status=${status}`); + + const code = /** @type {any} */ (err)?.code; + if (typeof code === 'string' && code) parts.push(`code=${code}`); + + return parts.join(' '); +} diff --git a/frontend/src/lib/server/packs.js b/frontend/src/lib/server/packs.js new file mode 100644 index 00000000..bfb7db54 --- /dev/null +++ b/frontend/src/lib/server/packs.js @@ -0,0 +1,94 @@ +/** + * Vertical packs — the pack chooser shown at org creation. + * + * Server-only, matching every other `$lib/server/*` module: the JWT cookie + * never leaves the server. `apiRequest` lives at `$lib/api-helpers.js` (NOT + * `$lib/server/api` — that module does not exist) and its `API_BASE_URL` + * already includes `/api`, so endpoints here are given WITHOUT the `/api` + * prefix, same as every neighbouring module (see `$lib/server/v2/tags.js`, + * `$lib/server/v2/organization.js`). + * + * GET /api/packs/ needs IsAuthenticated only, no org context — it is exempt + * from HasOrgContext / RequireOrgContext (see `PackListView` and + * `EXEMPT_EXACT_PATHS` in `common/middleware/rls_context.py`) precisely so a + * brand-new user with zero orgs, who has no `org_id` claim on their JWT yet, + * can still list packs on `/org/new`, the "first ever org" page. Callers must + * still treat a failed listPacks() as "no packs available" rather than + * letting the error propagate and break the page — this call can still fail + * for other reasons (network, auth token expiry, etc). + */ +import { apiRequest } from '$lib/api-helpers.js'; + +/** + * The minimal shape `apiRequest` actually needs: something with a `.get()` + * that returns the access token. A real SvelteKit `Cookies` satisfies this + * structurally, but so does a throwaway bearer-only reader that is never + * connected to the response's Set-Cookie headers — see `applyPack`'s use in + * `org/new/+page.server.js`, where callers deliberately pass one instead of + * the real cookie jar so an org-scoped token minted mid-request never gets + * persisted to the caller's session. + * @typedef {{ get: (name: string) => string | undefined }} CookieReader + */ + +/** + * @param {import('@sveltejs/kit').Cookies | CookieReader} cookies + * @returns {Promise>} + */ +export async function listPacks(cookies) { + // apiRequest's own type only accepts a real Cookies here; at runtime it + // only ever calls `.get()`, which both a real Cookies and a CookieReader + // provide, so this cast is safe. + const res = await apiRequest( + '/packs/', + { method: 'GET' }, + { cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) } + ); + return res?.packs ?? []; +} + +/** + * POST /api/packs//apply/ — ADMIN only, org derived server-side from + * `request.profile.org`. Caller must supply cookies that already carry a + * token scoped to the org the pack should apply to. + * + * @param {import('@sveltejs/kit').Cookies | CookieReader} cookies + * @param {string} packId + * @returns {Promise<{ report: { created: any[], skipped: any[], failed: any[] } }>} + */ +export async function applyPack(cookies, packId) { + return apiRequest( + `/packs/${encodeURIComponent(packId)}/apply/`, + { method: 'POST' }, + { cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) } + ); +} + +/** + * DELETE /api/packs/sample-data/ — ADMIN only, org derived server-side. + * Deletes the demo records this org's applier created — accounts, contacts, + * deals, tickets, tasks and leads carrying `is_sample` — and nothing else. See + * `backend/common/packs/applier.py:clear_sample_data`. Real records, and + * another org's sample records, are untouched. + * + * `retained_by_type` is a normal outcome rather than a failure: a demo record + * the user has since attached their own work to (a real deal on a sample + * account, billable time on a sample ticket, an invoice raised against a sample + * account) is deliberately kept, because deleting it would take that work with + * it — or, for the invoice case, fail outright on a PROTECT constraint. Surface + * it to the user; do not treat it as an error. + * + * There is no separate endpoint to preview this count before deleting — + * the applier module exposes exactly list/apply/clear, no dry-run. A caller + * wanting to confirm "how many?" ahead of time has nothing authoritative to + * ask; the count only exists once this call has already acted. + * + * @param {import('@sveltejs/kit').Cookies | CookieReader} cookies + * @returns {Promise<{ deleted: number, deleted_by_type: Record, retained_by_type: Record }>} + */ +export async function clearSampleData(cookies) { + return apiRequest( + '/packs/sample-data/', + { method: 'DELETE' }, + { cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) } + ); +} diff --git a/frontend/src/lib/server/v2/lead-custom-fields.js b/frontend/src/lib/server/v2/lead-custom-fields.js new file mode 100644 index 00000000..c2a8ff81 --- /dev/null +++ b/frontend/src/lib/server/v2/lead-custom-fields.js @@ -0,0 +1,173 @@ +/** + * Per-org custom fields on a Lead — the definitions and a record's values. + * + * Server-only. Vertical packs create `CustomFieldDefinition` rows targeting + * `Lead` (every shipped pack does; none targets another entity), and the + * applier writes matching values into `Lead.custom_fields`. Until this module + * existed both ends were invisible: the definitions showed only under + * Settings → Custom fields, and the values sat in the JSON column with no page + * rendering them. For a real-estate org "Budget (max)" and "Preferred + * location" ARE the record, so a lead page that omits them omits the point. + * + * `GET /custom-fields/` is `(IsAuthenticated, HasOrgContext)` with no admin + * gate — definitions are shared config, read by any member and written only by + * an admin — so this is safe to call from a page every member can open. It + * returns every definition for the org including inactive ones and every + * target; the filtering to active Lead fields happens here. + * + * On the way back in, `common.custom_fields.validate_payload` is the authority: + * `LeadDetailView.patch` runs it over the submitted object, drops keys with no + * definition and coerces each value to its declared type. The allow-list built + * here is a convenience for the form, never the control — a forged key is + * discarded server-side whatever this file does. + */ +import { apiRequest } from '$lib/api-helpers.js'; + +/** + * Active custom-field definitions targeting Lead, in display order. + * + * A failure here returns [] rather than throwing: custom fields are an + * enhancement to the lead page, and an org with none is the common case, so a + * definitions call that fails should cost the reader the extra block — not the + * lead they were trying to open. + * + * @param {import('@sveltejs/kit').Cookies} cookies + * @returns {Promise} + */ +export async function leadFieldDefinitions(cookies) { + try { + // `include_counts=false` matters: without it the endpoint computes + // `records_missing_value` for every definition, which is one COUNT over + // the org's entire Lead table each — N+1 full scans on every lead page + // view, to render labels that need none of them. The filters are applied + // server-side too, so this asks for the smallest useful answer. + const response = await apiRequest( + '/custom-fields/?target_model=Lead&active_only=true&include_counts=false', + {}, + { cookies } + ); + return (response.definitions ?? []).sort( + (/** @type {any} */ a, /** @type {any} */ b) => + (a.display_order ?? 0) - (b.display_order ?? 0) + ); + } catch { + return []; + } +} + +/** + * Render one stored value for reading. + * + * Values arrive already coerced by `_coerce_value` — number as a JSON number, + * checkbox as a real boolean, date as a YYYY-MM-DD string — so this formats + * rather than parses. A dropdown stores its `value`; the human-facing `label` + * lives on the definition, so it is looked up here instead of showing the raw + * key. + * + * @param {any} definition + * @param {any} value + * @returns {string} + */ +export function displayValue(definition, value) { + if (value === null || value === undefined || value === '') return ''; + + switch (definition.field_type) { + case 'checkbox': + return value ? 'Yes' : 'No'; + case 'number': + return typeof value === 'number' ? value.toLocaleString() : String(value); + case 'dropdown': { + const match = (definition.options ?? []).find( + (/** @type {any} */ o) => o.value === value + ); + return match?.label ?? String(value); + } + default: + return String(value); + } +} + +/** + * Pair definitions with a lead's stored values for the detail page. + * + * Every active definition appears, including the ones this record has no value + * for — a blank "Possession by" is information, and a list that silently omits + * unfilled fields hides what the org decided it tracks. `filled` lets the page + * decide whether the block is worth showing at all. + * + * @param {any[]} definitions + * @param {Record | null | undefined} values + */ +export function pairForDisplay(definitions, values) { + const stored = values ?? {}; + return definitions.map((d) => { + const raw = stored[d.key]; + return { + key: d.key, + label: d.label, + field_type: d.field_type, + value: displayValue(d, raw), + filled: raw !== null && raw !== undefined && raw !== '' + }; + }); +} + +/** + * Shape the definitions and current values for the edit form. + * + * A checkbox is normalised to a real boolean and everything else to a string, + * because that is what the inputs bind to. `''` for an unset field means the + * input renders empty and submits empty, which `_coerce_value` maps back to + * `None` — so clearing a field works without a separate "remove" control. + * + * @param {any[]} definitions + * @param {Record | null | undefined} values + */ +export function pairForEdit(definitions, values) { + const stored = values ?? {}; + return definitions.map((d) => { + const raw = stored[d.key]; + return { + key: d.key, + label: d.label, + field_type: d.field_type, + options: d.options ?? [], + is_required: d.is_required ?? false, + value: + d.field_type === 'checkbox' + ? Boolean(raw) + : raw === null || raw === undefined + ? '' + : String(raw) + }; + }); +} + +/** + * Collect `cf_` inputs off a submitted form into a custom_fields object. + * + * Driven by the definition list, not by what the body happens to carry: a key + * with no active definition is never read, and an unchecked checkbox — which + * submits nothing at all in HTML — becomes an explicit `false` rather than + * silently keeping its previous value. Without that, a checkbox could be + * ticked but never unticked. + * + * @param {FormData} form + * @param {any[]} definitions + * @returns {Record} + */ +export function collectFromForm(form, definitions) { + /** @type {Record} */ + const out = {}; + for (const d of definitions) { + const field = `cf_${d.key}`; + if (d.field_type === 'checkbox') { + out[d.key] = form.get(field) !== null; + continue; + } + if (!form.has(field)) continue; + const raw = form.get(field)?.toString().trim() ?? ''; + out[d.key] = raw === '' ? null : raw; + } + return out; +} diff --git a/frontend/src/lib/server/v2/leads.js b/frontend/src/lib/server/v2/leads.js index 5ec5b318..beadfeff 100644 --- a/frontend/src/lib/server/v2/leads.js +++ b/frontend/src/lib/server/v2/leads.js @@ -25,6 +25,11 @@ import { error } from '@sveltejs/kit'; import { env } from '$env/dynamic/public'; import { apiRequest } from '$lib/api-helpers.js'; +import { + leadFieldDefinitions, + pairForDisplay, + pairForEdit +} from '$lib/server/v2/lead-custom-fields.js'; /** * Attachments serialise with a storage-relative `file_path` (`/media/…`) on @@ -137,8 +142,15 @@ export async function getLead({ cookies }, id) { // than widening every list row to carry a paragraph nobody scans. const lead = { ...toRow(response.lead_obj), description: response.lead_obj.description ?? '' }; + // Per-org custom fields. A vertical pack's whole promise is the fields it + // sets up for the industry, and they are stored on the lead — so the detail + // page reads them here rather than leaving them visible only under + // Settings → Custom fields. + const definitions = await leadFieldDefinitions(cookies); + return { lead, + customFields: pairForDisplay(definitions, response.lead_obj.custom_fields), activity: buildActivity(response), duplicates: await findDuplicates(cookies, lead) }; @@ -326,9 +338,15 @@ export async function getLeadForEdit({ cookies }, id) { // to a name, which reads the same on screen and cannot be saved. form.assigned_to = response.lead_obj.assigned_to?.[0]?.id ?? ''; + const definitions = await leadFieldDefinitions(cookies); + return { lead, form, + // The definitions drive both the inputs rendered here and the keys read + // back off the submitted form, so a field the org has not defined has + // nowhere to enter the request in the first place. + customFields: pairForEdit(definitions, response.lead_obj.custom_fields), owners: await listOwners(cookies), server: { // The DB constraint `unique_lead_email_per_org` is case-insensitive and @@ -400,6 +418,17 @@ export async function updateLead({ cookies }, id, values) { body.assigned_to = values.assigned_to ? [values.assigned_to] : []; } + // `validate_payload` merges rather than replaces: it carries forward every + // recognized key the body does not mention, and treats an explicit null as + // "clear this one" (`cleaned.pop`). So omitting a field preserves it and + // submitting it empty removes it — which is exactly what a form whose empty + // input means "no value" needs, and why `collectFromForm` sends null instead + // of skipping the key. Absent stays absent: a caller that does not edit + // custom fields never sends the key at all. + if ('custom_fields' in values && values.custom_fields) { + body.custom_fields = values.custom_fields; + } + return await apiRequest(`/leads/${id}/`, { method: 'PATCH', body }, { cookies }); } diff --git a/frontend/src/lib/server/v2/organization.js b/frontend/src/lib/server/v2/organization.js index 4b3a7d65..3ac0154d 100644 --- a/frontend/src/lib/server/v2/organization.js +++ b/frontend/src/lib/server/v2/organization.js @@ -106,3 +106,24 @@ export async function getOrgSettings({ cookies }) { export function updateOrgSettings({ cookies }, body) { return apiRequest('/org/settings/', { method: 'PATCH', body }, { cookies }); } + +/** + * Vertical-pack label overrides for this org — read via the same + * `/api/org/settings/` endpoint the settings page uses, not a second + * endpoint. `terminology` and `vertical` are `read_only_fields` on + * `OrgSettingsSerializer`: the pack applier writes them server-side, so + * there is nothing to validate or scope on the way out. + * + * Called from the `(app)` shell load on *every* page (see + * `routes/(app)/+layout.server.js`), not just the settings page, so it must + * never be the reason the shell fails to render — callers are expected to + * fold a rejection into an empty/undefined result the same way the shell's + * badge-count fetches already do, rather than let it propagate. + * + * @param {{ cookies: import('@sveltejs/kit').Cookies }} event + * @returns {Promise<{ terminology?: Record, vertical?: string }>} + */ +export async function getOrgTerminology({ cookies }) { + const org = await apiRequest('/org/settings/', {}, { cookies }); + return { terminology: org?.terminology, vertical: org?.vertical }; +} diff --git a/frontend/src/lib/terminology.js b/frontend/src/lib/terminology.js new file mode 100644 index 00000000..e2e788e9 --- /dev/null +++ b/frontend/src/lib/terminology.js @@ -0,0 +1,26 @@ +/** + * Label lookup for vertical packs. + * + * A pack can rename entities in the UI ("Leads" -> "Enquiries") by writing + * keys into `Org.terminology` (e.g. `{ "lead.plural": "Enquiries" }`), which + * the backend serializes back as `org.terminology`. This helper is the only + * way a component should read that map. + * + * Always called with an explicit fallback, so an org with no pack — or a + * terminology object missing this particular key — renders exactly as it + * does today. There is no branching on `org.vertical` anywhere; the entire + * effect of a pack on the UI is carried by this map plus the ordinary config + * rows the backend already created. + * + * `terminology` values are tenant-controlled text rendered as plain text. + * Never pass the result of this function to `{@html}`. + * + * @param {Record | null | undefined} terminology + * @param {string} key + * @param {string} fallback + * @returns {string} + */ +export function t(terminology, key, fallback) { + const value = terminology?.[key]; + return typeof value === 'string' && value.length > 0 ? value : fallback; +} diff --git a/frontend/src/lib/v2/components/Sidebar.svelte b/frontend/src/lib/v2/components/Sidebar.svelte index 887b8c45..ef445dd5 100644 --- a/frontend/src/lib/v2/components/Sidebar.svelte +++ b/frontend/src/lib/v2/components/Sidebar.svelte @@ -22,6 +22,7 @@ Smartphone, LogOut } from '@lucide/svelte'; + import { t } from '$lib/terminology.js'; /** * One flat tree, grouped by what the person is doing rather than by which @@ -38,24 +39,43 @@ * is one where a member gets nothing but a "for administrators" gate, so * showing it would only teach them to bounce off it. * + * `termKey` marks the handful of entity destinations a vertical pack may + * relabel (see `$lib/terminology.js`). The string in `label` below is only + * ever the fallback an org with no pack — or no override for that key — + * still renders; the derived `groups` below is what actually resolves it + * against `terminology`. No other label branches on the org at all. + * * @type {{ * counts?: Record, * org?: { name: string }, * role?: string, + * terminology?: Record | null, * onsearch?: () => void * }} */ - let { counts = {}, org = { name: 'BottleCRM' }, role = 'USER', onsearch = () => {} } = $props(); + let { + counts = {}, + org = { name: 'BottleCRM' }, + role = 'USER', + terminology = undefined, + onsearch = () => {} + } = $props(); const GROUPS = [ { label: 'Sell', items: [ { href: '/', label: 'Today', icon: Sun, exact: true }, - { href: '/pipeline', label: 'Pipeline', icon: Columns3, count: 'pipeline' }, - { href: '/leads', label: 'Leads', icon: Target, count: 'leads' }, - { href: '/accounts', label: 'Accounts', icon: Building2 }, - { href: '/contacts', label: 'Contacts', icon: Users }, + { + href: '/pipeline', + label: 'Pipeline', + icon: Columns3, + count: 'pipeline', + termKey: 'opportunity.plural' + }, + { href: '/leads', label: 'Leads', icon: Target, count: 'leads', termKey: 'lead.plural' }, + { href: '/accounts', label: 'Accounts', icon: Building2, termKey: 'account.plural' }, + { href: '/contacts', label: 'Contacts', icon: Users, termKey: 'contact.plural' }, { href: '/goals', label: 'Goals', icon: Trophy } ] }, @@ -74,7 +94,13 @@ { label: 'Bill', items: [ - { href: '/invoices', label: 'Invoices', icon: Receipt, count: 'invoices' }, + { + href: '/invoices', + label: 'Invoices', + icon: Receipt, + count: 'invoices', + termKey: 'invoice.plural' + }, { href: '/timesheet', label: 'Timesheet', icon: Clock } ] }, @@ -93,11 +119,16 @@ } ]; - // Drop admin-only items for members, then drop any group left with nothing. + // Drop admin-only items for members, resolve any relabelled entity through + // the terminology map, then drop any group left with nothing. let groups = $derived( GROUPS.map((group) => ({ ...group, - items: group.items.filter((item) => role === 'ADMIN' || !item.admin) + items: group.items + .filter((item) => role === 'ADMIN' || !item.admin) + .map((item) => + item.termKey ? { ...item, label: t(terminology, item.termKey, item.label) } : item + ) })).filter((group) => group.items.length > 0) ); diff --git a/frontend/src/routes/(app)/+layout.server.js b/frontend/src/routes/(app)/+layout.server.js index 465e4b22..6d54e93b 100644 --- a/frontend/src/routes/(app)/+layout.server.js +++ b/frontend/src/routes/(app)/+layout.server.js @@ -5,6 +5,7 @@ import { listTasks } from '$lib/server/v2/tasks.js'; import { listInvoices } from '$lib/server/v2/invoices.js'; import { countAwaitingApprovals } from '$lib/server/v2/approvals.js'; import { countUnread } from '$lib/server/v2/notifications.js'; +import { getOrgTerminology } from '$lib/server/v2/organization.js'; /** * Counts for migrated modules, fetched for real and merged over the fixtures. @@ -90,13 +91,17 @@ const LIVE_COUNTS = { }; /** - * The shell: nav counts and the org name. + * The shell: nav counts, the org name, and vertical-pack terminology. * * The org name is display-only and comes from the JWT via `locals.org` — never - * the client, never a fixture. Counts run concurrently (in sequence they would - * add a round trip per module to every page) and start empty: every badge the - * sidebar renders is in LIVE_COUNTS, so a count that fails just shows no badge - * rather than a stale number. + * the client, never a fixture. Everything below runs concurrently (in sequence + * it would add a round trip per module to every page) and starts empty: every + * badge the sidebar renders is in LIVE_COUNTS, so a count that fails just shows + * no badge rather than a stale number. `terminology` joins the same fan-out — + * a failed fetch there just leaves the sidebar's hard-coded labels in place + * (every consumer reads it through `$lib/terminology.js#t()`, which always + * takes an explicit fallback), the same "missing, not stale or broken" contract + * as a missing count badge. * * Still worth fixing: each count builds an entire page context — accounts, * tags, users, industries — to answer a question about one integer. One counts @@ -107,7 +112,10 @@ const LIVE_COUNTS = { export async function load(event) { const shell = { counts: /** @type {Record} */ ({}), - org: { name: event.locals.org?.name || 'BottleCRM' }, + org: { + name: event.locals.org?.name || 'BottleCRM', + terminology: /** @type {Record | undefined} */ (undefined) + }, // Server-derived from the JWT (never the client). Display-only: it lets the // shell hide destinations a member can only reach to be turned away — the // backend still enforces every one of those gates, so this is UX, not a @@ -115,15 +123,26 @@ export async function load(event) { role: event.locals.profile?.role ?? 'USER' }; - const keys = Object.keys(LIVE_COUNTS); - const counted = await Promise.allSettled( - keys.map((key) => LIVE_COUNTS[/** @type {keyof typeof LIVE_COUNTS} */ (key)](event)) - ); + // countKeys' fetches and the terminology fetch are pushed into ONE + // Promise.allSettled call so they all fire in the same network wave — the + // terminology lookup is not a second round trip, it rides the wave that was + // already here for the badges. `results` is indexed by position: the count + // keys first (in `countKeys` order), terminology last. + const countKeys = Object.keys(LIVE_COUNTS); + const results = await Promise.allSettled([ + ...countKeys.map((key) => LIVE_COUNTS[/** @type {keyof typeof LIVE_COUNTS} */ (key)](event)), + getOrgTerminology(event) + ]); - keys.forEach((key, index) => { - const result = counted[index]; + countKeys.forEach((key, index) => { + const result = results[index]; if (result.status === 'fulfilled') shell.counts[key] = result.value; }); + const terminologyResult = results[countKeys.length]; + if (terminologyResult.status === 'fulfilled') { + shell.org.terminology = terminologyResult.value.terminology; + } + return shell; } diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 9eaad9f8..fdb8b990 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -7,7 +7,7 @@ import CommandPalette from '$lib/v2/components/CommandPalette.svelte'; import { Search, Sun, Columns3, LifeBuoy, Receipt, Plus, Menu } from '@lucide/svelte'; - /** @type {{ data: { counts: Record, org: { name: string }, role: string }, children: import('svelte').Snippet }} */ + /** @type {{ data: { counts: Record, org: { name: string, terminology?: Record | null }, role: string }, children: import('svelte').Snippet }} */ let { data, children } = $props(); let paletteOpen = $state(false); @@ -60,6 +60,7 @@ counts={data.counts} org={data.org} role={data.role} + terminology={data.org.terminology} onsearch={() => (paletteOpen = true)} />
@@ -133,6 +134,7 @@ counts={data.counts} org={data.org} role={data.role} + terminology={data.org.terminology} onsearch={() => { menuOpen = false; paletteOpen = true; diff --git a/frontend/src/routes/(app)/leads/+page.svelte b/frontend/src/routes/(app)/leads/+page.svelte index 5cd892c4..3610d469 100644 --- a/frontend/src/routes/(app)/leads/+page.svelte +++ b/frontend/src/routes/(app)/leads/+page.svelte @@ -7,6 +7,7 @@ import { money, count, relativeDays, daysSince } from '$lib/v2/format.js'; import { LEAD_STATUS_TONE } from '$lib/v2/enums.js'; import { Plus, Upload, Target } from '@lucide/svelte'; + import { t } from '$lib/terminology.js'; /** @type {{ data: any }} */ let { data } = $props(); @@ -14,6 +15,15 @@ let leads = $derived(data.leads); let totals = $derived(data.totals); + /* A vertical pack renames this module in the sidebar. Reading the same map + here keeps the two agreeing — a nav item saying "Enquiries" that opens a + page headed "Leads" reads as a bug, not as configuration. `t()` falls back + to the literal, so an org with no terminology sees exactly what it saw + before. The values are tenant text and render as plain text. */ + let terms = $derived(data.org?.terminology); + let plural = $derived(t(terms, 'lead.plural', 'Leads')); + let singular = $derived(t(terms, 'lead.singular', 'lead')); + const FILTERS = []; /** @@ -26,14 +36,14 @@ const stale = (lead) => (daysSince(lead.last_contacted ?? lead.created_at) ?? 0) > 7; - + {#snippet sub()} {count(totals.count)} open · {totals.unworked_over_a_week} unworked for more than a week {/snippet} {#snippet actions()} - + {/snippet} @@ -42,12 +52,12 @@
{#if leads.length === 0} {#snippet icon()}{/snippet} {#snippet actions()} - + {/snippet} diff --git a/frontend/src/routes/(app)/leads/[id]/+page.svelte b/frontend/src/routes/(app)/leads/[id]/+page.svelte index 3ce21793..e37d6a2b 100644 --- a/frontend/src/routes/(app)/leads/[id]/+page.svelte +++ b/frontend/src/routes/(app)/leads/[id]/+page.svelte @@ -21,6 +21,7 @@ import Pill from '$lib/v2/components/Pill.svelte'; import { money, relativeDays, daysSince, shortDate } from '$lib/v2/format.js'; import { LEAD_STATUS_TONE, LEAD_STATUS_LABEL, industryLabel } from '$lib/v2/enums.js'; + import { t } from '$lib/terminology.js'; import { enhance } from '$app/forms'; import { ChevronRight, @@ -36,7 +37,7 @@ /** @type {{ data: any, form: any }} */ let { data, form } = $props(); - let { lead, activity, duplicates } = $derived(data); + let { lead, activity, duplicates, customFields } = $derived(data); let isConverted = $derived(lead.status === 'converted'); let firstName = $derived(lead.first_name || 'this lead'); let fullName = $derived(`${lead.first_name ?? ''} ${lead.last_name ?? ''}`.trim() || 'Lead'); @@ -173,7 +174,7 @@ {/snippet} {#snippet crumb()} - Leads + {t(data.org?.terminology, 'lead.plural', 'Leads')} {lead.company_name || 'No company'} {/snippet} @@ -259,6 +260,23 @@
{lead.description}
{/if} + + {#if customFields.length > 0} +
Details
+
+ {#each customFields as f (f.key)} +
+ {f.label} + + {f.filled ? f.value : '—'} + +
+ {/each} +
+ {/if} +
Activity
{#if hasFiles} @@ -432,6 +450,39 @@ line-height: 1.6; white-space: pre-wrap; } + .cf { + padding: 4px 16px; + font-size: 13px; + } + .cf-row { + display: flex; + gap: 16px; + align-items: baseline; + padding: 9px 0; + border-bottom: 1px solid var(--v2-line); + } + .cf-row:last-child { + border-bottom: 0; + } + .cf-label { + flex: 0 0 40%; + color: var(--v2-ink-soft); + } + .cf-value { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + } + /* Stack on narrow screens, matching the list's data-m card treatment. */ + @media (max-width: 767px) { + .cf-row { + display: block; + } + .cf-label { + display: block; + margin-bottom: 2px; + } + } .note-form { margin-bottom: 22px; } diff --git a/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js b/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js index 7b116a55..48be26f1 100644 --- a/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js +++ b/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js @@ -1,5 +1,6 @@ import { fail } from '@sveltejs/kit'; import { EDITABLE_FIELDS, getLeadForEdit, updateLead } from '$lib/server/v2/leads.js'; +import { collectFromForm, leadFieldDefinitions } from '$lib/server/v2/lead-custom-fields.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ cookies, params }) { @@ -45,6 +46,22 @@ export const actions = { const ownerWas = form.get('assigned_to_original')?.toString().trim() ?? ''; if (owner !== ownerWas) values.assigned_to = owner; + /* + * Custom fields are read against the org's own definitions, re-fetched + * here rather than trusted from the body: the keys come from the server's + * list, so a `cf_` input appended to the POST by hand names nothing and is + * never read. The API is still the authority — `validate_payload` drops + * unknown keys and coerces every value — but the form has no reason to + * forward something it did not offer. + * + * Only sent when the org actually defines Lead fields, so an org with none + * PATCHes exactly the body it did before. + */ + const definitions = await leadFieldDefinitions(cookies); + if (definitions.length > 0) { + values.custom_fields = collectFromForm(form, definitions); + } + if (!values.first_name && !values.last_name) { return fail(400, { values, diff --git a/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte b/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte index 605b80c1..80cc60ba 100644 --- a/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte +++ b/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte @@ -77,6 +77,11 @@ })); let form = $state(untrack(() => ({ ...data.form }))); + /* Copied per row, not shared: each entry's `value` is bound to an input, so + the objects have to be the page's own rather than the load result's. */ + let customFields = $state( + untrack(() => (data.customFields ?? []).map((/** @type {any} */ f) => ({ ...f }))) + ); let touched = $state(/** @type {Record} */ ({})); let submitted = $state(false); let saving = $state(false); @@ -130,6 +135,18 @@ else if (n < 0) e.opportunity_amount = 'Estimated value cannot be negative.'; } + /* Mirrors the required-field loop at the end of + `common.custom_fields.validate_payload`: a required definition errors + when neither the payload nor the stored record has a value. A plain + `required` attribute would do nothing here — the form is `novalidate`, + so the page owns its own checks. Without this the API answers 400 with + "is required" and the message has no field to sit beside. A checkbox is + exempt: false is a value, so a required one can never be unsatisfied. */ + for (const f of customFields) { + if (!f.is_required || f.field_type === 'checkbox') continue; + if (String(f.value ?? '').trim() === '') e[`cf_${f.key}`] = `${f.label} is required.`; + } + return e; }); @@ -448,6 +465,66 @@ bind:value={form.description}>
+ + {#if customFields.length > 0} + + {#each customFields as f (f.key)} +
+ {#if f.field_type === 'checkbox'} + + {:else} + + {#if f.field_type === 'dropdown'} + + {:else if f.field_type === 'textarea'} + + {:else} + (touched[`cf_${f.key}`] = true)} + aria-invalid={show(`cf_${f.key}`) ? 'true' : undefined} + /> + {/if} + {#if show(`cf_${f.key}`)} +

{errors[`cf_${f.key}`]}

+ {/if} + {/if} +
+ {/each} + {/if} +
+ +
+
Vertical pack
+
+

+ A pack adds starter pipelines, tags, custom fields, products and a set of sample + records — accounts, contacts, deals, tickets, tasks and leads — for one kind of + business. Applying one only fills in what this org is missing — anything already set up + is left exactly as it is, and applying the same pack twice is safe. +

+ +
+ First pack applied + {#if appliedPack} + {appliedPack.name} + {:else if org.vertical} + {org.vertical} + {:else} + None yet + {/if} +
+ + {#if !data.can_edit} +

+ Applying a pack or clearing sample data is limited to administrators. +

+ {:else} + {#if form?.error} +
+ +
+ {/if} + + {#if form?.report} + {@const created = form.report.created ?? []} + {@const skipped = form.report.skipped ?? []} + {@const appliedName = + packs.find((/** @type {any} */ p) => p.id === form.appliedPackId)?.name ?? + form.appliedPackId} +
+
Applied “{appliedName}”
+

+ {reportSummary(form.report)} +

+ {#if skipped.length} +
Skipped — already had these
+
    + {#each skipped as item (item.type + ':' + item.name)} +
  • + {item.name} + + ({item.type.replaceAll('_', ' ')} — {item.reason}) + +
  • + {/each} +
+ {/if} + {#if created.length} +
Created
+
    + {#each created as item (item.type + ':' + item.name)} +
  • + {item.name} + + ({item.type.replaceAll('_', ' ')}) + +
  • + {/each} +
+ {/if} +
+ {/if} + +
+ {#each packs as pack (pack.id)} +
+
+ {pack.name} + + {pack.description} + {#if pack.version}· v{pack.version}{/if} + +
+ {#if pack.id === org.vertical} + Applied + {/if} +
+ + +
+
+ {/each} + {#if !packs.length} +
+ No packs available right now. +
+ {/if} +
+ +
+ {#if confirmingClear} +
+ + Permanently delete every sample record a pack created for this org? This cannot + be undone — your real records are never touched, and any sample record you have + since attached real work to is kept. + + + +
+ {:else if form?.cleared !== undefined} + + {form.cleared + ? `Deleted ${form.cleared} sample ${form.cleared === 1 ? 'record' : 'records'}.` + : 'No sample data to clear.'} + {#if form.retained} + Kept {form.retained} + {form.retained === 1 ? 'record' : 'records'} you have since attached real work to. + {/if} + + {:else} + + + Removes only the records a pack created as samples. Your real records are never + touched. + + {/if} +
+ {/if} +
+
+ + diff --git a/frontend/src/routes/(no-layout)/login/+page.server.js b/frontend/src/routes/(no-layout)/login/+page.server.js index 8a395c8c..db19a854 100644 --- a/frontend/src/routes/(no-layout)/login/+page.server.js +++ b/frontend/src/routes/(no-layout)/login/+page.server.js @@ -15,6 +15,7 @@ import { redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { env as publicEnv } from '$env/dynamic/public'; import { generateCodeVerifier, generateCodeChallenge, generateState } from '$lib/utils/pkce.js'; +import { describeError } from '$lib/server/log-safe.js'; const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_SCOPES = ['openid', 'email', 'profile'].join(' '); @@ -125,9 +126,10 @@ async function handleOAuthCallback(code, returnedState, cookies) { cookies.set('jwt_access', access_token, getCookieOptions(60 * 60 * 24)); // 1 day cookies.set('jwt_refresh', refresh_token, getCookieOptions(60 * 60 * 24 * 365)); // 1 year } catch (error) { - console.error('OAuth token exchange failed - Full error:', error); - console.error('Response data:', error.response?.data); - console.error('Response status:', error.response?.status); + // Never log the raw error or the response body: the axios `config.data` for + // this call is the authorization code plus the PKCE code_verifier, and a + // token endpoint's response body can carry tokens. + console.error('OAuth token exchange failed:', describeError(error)); const errorMessage = error.response?.data?.error || error.message || 'Unknown error'; // Provide user-friendly error messages @@ -138,7 +140,9 @@ async function handleOAuthCallback(code, returnedState, cookies) { } else if (errStr.includes('expired')) { userError = 'code_expired'; } - console.error('Final error message:', errorMessage); + // Log the classification, not errorMessage: that string comes from the + // token endpoint's response body, which does not belong in logs. + console.error('OAuth failure classified as:', userError); throw redirect(307, `/login?error=${userError}`); } diff --git a/frontend/src/routes/(no-layout)/org/+page.server.js b/frontend/src/routes/(no-layout)/org/+page.server.js index aa12a2c1..f56bfd4e 100644 --- a/frontend/src/routes/(no-layout)/org/+page.server.js +++ b/frontend/src/routes/(no-layout)/org/+page.server.js @@ -12,6 +12,7 @@ import { env as publicEnv } from '$env/dynamic/public'; import { redirect, fail } from '@sveltejs/kit'; import axios from 'axios'; +import { describeError } from '$lib/server/log-safe.js'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -54,7 +55,8 @@ export async function load({ cookies, locals }) { return { orgs }; } catch (error) { - console.error('Error fetching organizations:', error); + // Never log the raw error: its axios `config.headers` carries the JWT. + console.error('Error fetching organizations:', describeError(error)); // Return empty array so user can create a new organization return { orgs: [] }; } @@ -126,7 +128,7 @@ export const actions = { if (error.status === 303) { throw error; // Re-throw redirect } - console.error('Org switch failed:', error); + console.error('Org switch failed:', describeError(error)); return fail(500, { error: 'Failed to switch organization' }); } } diff --git a/frontend/src/routes/(no-layout)/org/new/+page.server.js b/frontend/src/routes/(no-layout)/org/new/+page.server.js index 0327c0f2..18230204 100644 --- a/frontend/src/routes/(no-layout)/org/new/+page.server.js +++ b/frontend/src/routes/(no-layout)/org/new/+page.server.js @@ -12,10 +12,22 @@ import { env } from '$env/dynamic/private'; import { env as publicEnv } from '$env/dynamic/public'; import axios from 'axios'; +import { describeError } from '$lib/server/log-safe.js'; +import { listPacks, applyPack } from '$lib/server/packs.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ cookies }) { - // No data needed for load + // GET /api/packs/ needs IsAuthenticated only, no org context — but a + // brand-new user creating their very first org must never have this call + // break the page. Fall back to an empty list, which renders as just the + // "Skip for now" option. + try { + const packs = await listPacks(cookies); + return { packs }; + } catch (/** @type {any} */ err) { + console.error('Could not load vertical packs:', err?.message, err?.status); + return { packs: [] }; + } } /** @type {import('./$types').Actions} */ @@ -82,6 +94,84 @@ export const actions = { secure: env.NODE_ENV === 'production' }); + // Optional vertical pack, chosen in the "What kind of business is this?" + // group below. Empty string — the "Skip for now" option, and the + // default when nothing is submitted — means do nothing. + const vertical = formData.get('vertical')?.toString(); + if (vertical) { + // POST /api/packs//apply/ is ADMIN-only and derives its org from + // request.profile.org, i.e. from the org_id claim on the JWT — never + // from a body field. /api/org/ does not mint new tokens, so the + // access token we are holding here still carries the PREVIOUS org's + // claim (or no org claim at all, for a brand-new user's first org). + // Mint one scoped to the org we just created, the same way `/org`'s + // `selectOrg` action and the shell's own hooks.server.js do it, so + // the apply call lands on the right org as its ADMIN. + // + // This token is used ONLY as the bearer credential for the one + // applyPack() call below — it is never written to the jwt_access / + // jwt_refresh cookies. Doing so would silently rotate the caller's + // session to the new org mid-request (e.g. a user already in org A + // creating org B would suddenly be sitting in org B everywhere else + // too), which is a change to the session/org-claim contract the + // brief says must not move. The Skip path never touches these + // cookies at all, and this path must behave identically apart from + // the pack application itself. The existing `org` cookie + the + // shell's own hooks.server.js switch-org-on-mismatch logic already + // handle rotating the *browser's* session correctly if and when the + // user actually navigates into the new org. + try { + // Deliberately do NOT send `refresh` here. OrgSwitchView's + // `_retire_presented_refresh_token` blacklists whatever refresh + // token is presented -- that's correct for every other switch-org + // caller (hooks.server.js, /org, /settings/profile), which all + // write the replacement token back to the jwt_access/jwt_refresh + // cookies in the same request. This call mints a token used ONLY + // as the bearer credential for the one applyPack() call below (see + // the comment above) and never persists it, so blacklisting the + // caller's real refresh token here would leave the browser holding + // a dead one with nothing to replace it -- the user gets signed + // out the next time the access token expires and a refresh is + // attempted. `refresh` is optional on this endpoint precisely for + // callers like this one that only need the org-scoped access + // token and must not touch the caller's session. + const switchResponse = await axios.post( + `${apiUrl}/api/auth/switch-org/`, + { org_id: newOrg.id }, + { + headers: { + Authorization: `Bearer ${jwtAccess}`, + 'Content-Type': 'application/json' + } + } + ); + + const { access_token } = switchResponse.data; + + // A minimal cookies-shaped shim: applyPack()/apiRequest only ever + // call `.get('jwt_access')` on what they're given. Handing them + // this instead of the real `cookies` object means the org-scoped + // token is used for exactly this one outgoing request and never + // reaches `cookies.set()`, so nothing is queued onto the response. + const bearerOnly = { get: (name) => (name === 'jwt_access' ? access_token : undefined) }; + await applyPack(bearerOnly, vertical); + } catch (packErr) { + // A pack failing to apply must never fail org creation — the org + // and its admin profile already exist, already committed by the + // /api/org/ call above. Log it and let signup succeed anyway. + // + // Do NOT log packErr itself: it may be an AxiosError carrying + // `.config.headers.Authorization` with the bearer token in it, and + // Node's default error formatting prints that in full. Log only + // the message and status. + console.error( + `Vertical pack "${vertical}" did not apply to new org ${newOrg.id} (org creation still succeeded):`, + packErr?.message, + packErr?.response?.status ?? packErr?.status + ); + } + } + // Return success return { data: { @@ -89,7 +179,8 @@ export const actions = { } }; } catch (err) { - console.error('Error creating organization:', err); + // Never log the raw error: its axios `config.headers` carries the JWT. + console.error('Error creating organization:', describeError(err)); // Check if it's a duplicate name error if (err.response?.status === 400) { diff --git a/frontend/src/routes/(no-layout)/org/new/+page.svelte b/frontend/src/routes/(no-layout)/org/new/+page.svelte index 6979ece5..f461af10 100644 --- a/frontend/src/routes/(no-layout)/org/new/+page.svelte +++ b/frontend/src/routes/(no-layout)/org/new/+page.svelte @@ -6,7 +6,9 @@ import imgLogo from '$lib/assets/images/logo.png'; import { ArrowLeft, Check, AlertCircle } from '@lucide/svelte'; - let { form } = $props(); + let { data, form } = $props(); + + let packs = $derived(data?.packs ?? []); let isSubmitting = $state(false); @@ -63,6 +65,47 @@

This becomes your workspace name in BottleCRM.

+ {#if packs.length > 0} +
+ What kind of business is this? +

+ Sets up a starter pipeline, tags and fields for your industry. You can change + everything later. +

+ + + + {#each packs as pack (pack.id)} + + {/each} +
+ {/if} + {#if form?.error}
@@ -110,3 +153,46 @@
+ +