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

This commit is contained in:
Ashwin 2026-08-02 08:49:06 +05:30
parent a287aced3a
commit 9dc080ddec
64 changed files with 4958 additions and 53 deletions

View file

@ -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.'),
),
]

View file

@ -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,

View file

@ -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.'),
),
]

View file

@ -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(

View file

@ -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(

View file

@ -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):
"""

View file

@ -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/<pack_id>/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):

View file

@ -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),
]

View file

@ -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,
),
),
]

View file

@ -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),
]

View file

@ -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}"

View file

@ -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"]

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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):

View file

@ -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(

View file

@ -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."""

View file

@ -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/<id>/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

View file

@ -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"]

View file

@ -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)}"
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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
# <str:pack_id>/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/<str:pack_id>/apply/", PackApplyView.as_view(), name="pack_apply"),
]

View file

@ -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)

View file

@ -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"],
}
)

View file

@ -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.'),
),
]

View file

@ -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 "

View file

@ -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",)

View file

@ -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.'),
),
]

View file

@ -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.'),
),
]

View file

@ -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")

View file

@ -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):

View file

@ -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)

View file

@ -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.'),
),
]

View file

@ -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,

View file

@ -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" } }
]
}
}

View file

@ -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" } }
]
}
}

View file

@ -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 } }
]
}
}

View file

@ -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.'),
),
]

View file

@ -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(

View file

@ -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;
}
}

View file

@ -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(' ');
}

View file

@ -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<Array<{ id: string, name: string, description: string, version: string }>>}
*/
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/<id>/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<string, number>, retained_by_type: Record<string, number> }>}
*/
export async function clearSampleData(cookies) {
return apiRequest(
'/packs/sample-data/',
{ method: 'DELETE' },
{ cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) }
);
}

View file

@ -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<any[]>}
*/
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<string, any> | 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<string, any> | 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_<key>` 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<string, any>}
*/
export function collectFromForm(form, definitions) {
/** @type {Record<string, any>} */
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;
}

View file

@ -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 });
}

View file

@ -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<string, string>, vertical?: string }>}
*/
export async function getOrgTerminology({ cookies }) {
const org = await apiRequest('/org/settings/', {}, { cookies });
return { terminology: org?.terminology, vertical: org?.vertical };
}

View file

@ -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<string, string> | 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;
}

View file

@ -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<string, number>,
* org?: { name: string },
* role?: string,
* terminology?: Record<string, string> | 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)
);

View file

@ -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<string, number>} */ ({}),
org: { name: event.locals.org?.name || 'BottleCRM' },
org: {
name: event.locals.org?.name || 'BottleCRM',
terminology: /** @type {Record<string, string> | 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;
}

View file

@ -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<string, number>, org: { name: string }, role: string }, children: import('svelte').Snippet }} */
/** @type {{ data: { counts: Record<string, number>, org: { name: string, terminology?: Record<string, string> | 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)}
/>
<div class="v2-main">
@ -133,6 +134,7 @@
counts={data.counts}
org={data.org}
role={data.role}
terminology={data.org.terminology}
onsearch={() => {
menuOpen = false;
paletteOpen = true;

View file

@ -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;
</script>
<PageHeader title="Leads">
<PageHeader title={plural}>
{#snippet sub()}
<span class="v2-num">{count(totals.count)}</span> open ·
<span class="v2-num">{totals.unworked_over_a_week}</span> unworked for more than a week
{/snippet}
{#snippet actions()}
<button class="v2-btn"><Upload />Import</button>
<button class="v2-btn v2-btn-primary"><Plus />New lead</button>
<button class="v2-btn v2-btn-primary"><Plus />New {singular}</button>
{/snippet}
</PageHeader>
@ -42,12 +52,12 @@
<div class="v2-scroll">
{#if leads.length === 0}
<EmptyState
title="No leads yet"
title="No {plural.toLowerCase()} yet"
body="A lead is somebody who might buy, before you know enough to call it a deal. Import a list, or add the last person who emailed you."
>
{#snippet icon()}<Target size={21} />{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary">New lead</button>
<button class="v2-btn v2-btn-primary">New {singular}</button>
<button class="v2-btn">Import</button>
{/snippet}
</EmptyState>

View file

@ -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 @@
<Avatar name={fullName} size={42} />
{/snippet}
{#snippet crumb()}
<a href="/leads">Leads</a>
<a href="/leads">{t(data.org?.terminology, 'lead.plural', 'Leads')}</a>
<ChevronRight size={12} />
<span>{lead.company_name || 'No company'}</span>
{/snippet}
@ -259,6 +260,23 @@
<div class="v2-card about">{lead.description}</div>
{/if}
<!-- Per-org custom fields. Every active definition shows, filled or
not: on a real-estate org these ARE the record, and an unfilled
"Possession by" is worth seeing rather than silently omitted. -->
{#if customFields.length > 0}
<div class="v2-label" style="margin:22px 0 10px">Details</div>
<div class="v2-card cf">
{#each customFields as f (f.key)}
<div class="cf-row">
<span class="cf-label">{f.label}</span>
<span class="cf-value" class:v2-muted={!f.filled}>
{f.filled ? f.value : '—'}
</span>
</div>
{/each}
</div>
{/if}
<div class="act-head">
<div class="v2-label">Activity</div>
{#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;
}

View file

@ -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,

View file

@ -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<string, boolean>} */ ({}));
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}></textarea>
</div>
<!-- Per-org custom fields, in the order the org defined them. The input
type follows field_type; every one submits a string (or nothing, for
an unchecked box) and `_coerce_value` converts on the way in. -->
{#if customFields.length > 0}
<div class="v2-label v2-section-label">Details</div>
{#each customFields as f (f.key)}
<div class="v2-field">
{#if f.field_type === 'checkbox'}
<label class="cf-check">
<input type="checkbox" name="cf_{f.key}" bind:checked={f.value} />
<span>{f.label}</span>
</label>
{:else}
<label for="f-cf-{f.key}">
{f.label}
{#if f.is_required}<span class="req">required</span>{/if}
</label>
{#if f.field_type === 'dropdown'}
<select
id="f-cf-{f.key}"
name="cf_{f.key}"
class="v2-input"
bind:value={f.value}
onblur={() => (touched[`cf_${f.key}`] = true)}
aria-invalid={show(`cf_${f.key}`) ? 'true' : undefined}
>
<option value="">—</option>
{#each f.options as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
{:else if f.field_type === 'textarea'}
<textarea
id="f-cf-{f.key}"
name="cf_{f.key}"
class="v2-input"
rows="3"
bind:value={f.value}
onblur={() => (touched[`cf_${f.key}`] = true)}
aria-invalid={show(`cf_${f.key}`) ? 'true' : undefined}></textarea>
{:else}
<input
id="f-cf-{f.key}"
name="cf_{f.key}"
class="v2-input"
type={f.field_type === 'number' ? 'number' : f.field_type === 'date' ? 'date' : 'text'}
step={f.field_type === 'number' ? 'any' : undefined}
bind:value={f.value}
onblur={() => (touched[`cf_${f.key}`] = true)}
aria-invalid={show(`cf_${f.key}`) ? 'true' : undefined}
/>
{/if}
{#if show(`cf_${f.key}`)}
<p class="v2-error">{errors[`cf_${f.key}`]}</p>
{/if}
{/if}
</div>
{/each}
{/if}
<div class="actions">
<button class="v2-btn v2-btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save changes'}
@ -469,6 +546,14 @@
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* A checkbox labels itself inline — the stacked label/input of the other
fields leaves the box floating under its own caption. */
.cf-check {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.req,
.locked {
margin-left: 6px;

View file

@ -1,15 +1,72 @@
import { fail } from '@sveltejs/kit';
import { getOrgSettings } from '$lib/server/v2/organization.js';
import { listPacks, applyPack, clearSampleData } from '$lib/server/packs.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/**
* Organization settings (read).
* Organization settings (read) + the "Vertical pack" section.
*
* Server load, so the JWT cookie stays server-side. GET is open to any member;
* `can_edit` (from the JWT role claim) decides whether the page shows the edit
* affordance, and the edit route + the backend PATCH are what actually enforce
* admin-only.
* admin-only. The same `can_edit` flag is reused below to decide whether the
* pack list and its actions render at all — vertical packs are admin-only for
* the identical reason (`PackApplyView`/`PackSampleDataView` both 403 a
* non-admin server-side), so there is no second admin check to invent.
*
* @type {import('./$types').PageServerLoad}
*/
export async function load({ cookies }) {
return await getOrgSettings({ cookies });
const settings = await getOrgSettings({ cookies });
// GET /api/packs/ needs IsAuthenticated only, no org context — but a
// reload of this page must never break over a transient failure here, so
// this follows the same "empty list on error" fallback the org-creation
// page already established for the identical call.
let packs = [];
try {
packs = await listPacks(cookies);
} catch (/** @type {any} */ err) {
console.error('Could not load vertical packs:', err?.message, err?.status);
}
return { ...settings, packs };
}
/** @type {import('./$types').Actions} */
export const actions = {
// Applying is additive-only and safe to repeat — a pack already applied
// just reports everything as skipped. There is deliberately no guard here
// against re-submitting the currently-applied pack.
apply: async ({ cookies, request }) => {
const packId = (await request.formData()).get('pack_id')?.toString();
if (!packId) return fail(400, { error: 'Choose a pack to apply.' });
try {
const { report } = await applyPack(cookies, packId);
return { appliedPackId: packId, report };
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { error: 'Only an administrator can apply a vertical pack.' });
}
return fail(400, { error: readableError(err, 'Could not apply this pack.') });
}
},
clearSampleData: async ({ cookies }) => {
try {
const { deleted, retained_by_type } = await clearSampleData(cookies);
// Retained records are not a failure — they are demo rows the user has
// since attached real work to, which the backend deliberately keeps.
// Passing the count through lets the page say so instead of reporting a
// smaller number than the user expected with no explanation.
const retained = Object.values(retained_by_type ?? {}).reduce((a, b) => a + b, 0);
return { cleared: deleted ?? 0, retained };
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { error: 'Only an administrator can clear sample data.' });
}
return fail(400, { error: readableError(err, 'Could not clear sample data.') });
}
}
};

View file

@ -22,21 +22,85 @@
* Editing is admin-only. `can_edit` (from the JWT role claim) decides whether
* the "Edit details" link shows; the edit route and the backend PATCH are what
* enforce it. A member sees this page read-only.
*
* VERTICAL PACK SECTION
* `can_edit` — the same admin-derived flag the rest of this page already
* uses to gate the "Edit details" link — also gates the pack list and its
* actions here, because `PackApplyView`/`PackSampleDataView` are ADMIN-only
* for the identical reason: this is org-wide configuration, not a personal
* setting. A member sees only which pack (if any) is currently applied,
* with no buttons, matching the 403 the backend would return if the UI
* offered one anyway.
*
* Applying is additive-only and idempotent, so the currently-applied pack
* stays clickable — re-applying it is expected to report everything
* skipped, not to be disallowed.
*
* The report after an apply shows created AND skipped, by name, because a
* pack applied to an org that already has some of this configured is the
* normal case, not a partial failure — a plain success toast would say
* less than what actually happened. "Clear sample data" needs an in-page
* confirm step (never the native `confirm()`, which blocks the browser
* automation this app is smoke-tested with) before it deletes anything.
*/
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import NextAction from '$lib/v2/components/NextAction.svelte';
import { count, shortDate } from '$lib/v2/format.js';
import { FileText, ShieldAlert, Pencil } from '@lucide/svelte';
import { FileText, ShieldAlert, Pencil, Trash2 } from '@lucide/svelte';
import { enhance } from '$app/forms';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
let org = $derived(data.org);
let packs = $derived(data.packs ?? []);
/** The registry entry matching `org.vertical`, or null if unapplied / unknown. */
let appliedPack = $derived(packs.find((/** @type {any} */ p) => p.id === org.vertical) ?? null);
let address = $derived(
[org.address_line, org.city, org.state, org.postcode, org.country].filter(Boolean).join(', ')
);
/** Disables every Apply / Clear button while any one of them is in flight. */
let busy = $state(false);
let confirmingClear = $state(false);
/** Shared submit handler for the per-pack Apply forms. */
const applySubmit = () => {
busy = true;
return async (/** @type {any} */ { update }) => {
await update();
busy = false;
};
};
/** Clearing also drops back out of the confirm step on a successful run. */
const clearSubmit = () => {
busy = true;
return async (/** @type {any} */ { update, result }) => {
await update();
busy = false;
if (result?.type === 'success') confirmingClear = false;
};
};
/**
* "Created 18 items, skipped 3 you already had" — the exact wording this
* section exists to show instead of a success toast.
* @param {{ created?: any[], skipped?: any[] }} report
*/
function reportSummary(report) {
const created = report?.created?.length ?? 0;
const skipped = report?.skipped?.length ?? 0;
if (!created && !skipped) return 'Nothing to add — this org already has all of it.';
if (!skipped) return `Created ${created} item${created === 1 ? '' : 's'}.`;
if (!created) {
return `Already had everything from this pack — skipped ${skipped} item${skipped === 1 ? '' : 's'} you already had.`;
}
return `Created ${created} item${created === 1 ? '' : 's'}, skipped ${skipped} you already had.`;
}
</script>
<PageHeader title="Organization">
@ -162,5 +226,181 @@
</div>
</div>
</div>
<div style="margin-top:24px">
<div class="v2-label" style="margin-bottom:10px">Vertical pack</div>
<div class="v2-card" style="padding:16px 18px">
<p class="v2-sub" style="font-size:12.5px;margin:0 0 14px;line-height:1.5">
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.
</p>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
<span class="v2-sub" style="font-size:11.5px">First pack applied</span>
{#if appliedPack}
<Pill tone="moss">{appliedPack.name}</Pill>
{:else if org.vertical}
<Pill tone="slate">{org.vertical}</Pill>
{:else}
<span class="v2-sub" style="font-size:12.5px">None yet</span>
{/if}
</div>
{#if !data.can_edit}
<p class="v2-sub" style="font-size:12px;margin:0">
Applying a pack or clearing sample data is limited to administrators.
</p>
{:else}
{#if form?.error}
<div style="margin-bottom:14px">
<NextAction label="That did not work" text={form.error} tone="rust" />
</div>
{/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}
<div
class="v2-card"
style="padding:14px 16px;margin-bottom:16px;border-color:color-mix(in srgb, var(--v2-moss) 40%, var(--v2-line))"
>
<div style="font-weight:650;font-size:13px">Applied “{appliedName}”</div>
<p class="v2-sub" style="font-size:12.5px;margin:4px 0 10px">
{reportSummary(form.report)}
</p>
{#if skipped.length}
<div class="v2-label" style="margin-bottom:4px">Skipped — already had these</div>
<ul style="margin:0 0 10px;padding-left:18px;font-size:12.5px;line-height:1.7">
{#each skipped as item (item.type + ':' + item.name)}
<li>
{item.name}
<span class="v2-sub" style="font-size:11px">
({item.type.replaceAll('_', ' ')} — {item.reason})
</span>
</li>
{/each}
</ul>
{/if}
{#if created.length}
<div class="v2-label" style="margin-bottom:4px">Created</div>
<ul style="margin:0;padding-left:18px;font-size:12.5px;line-height:1.7">
{#each created as item (item.type + ':' + item.name)}
<li>
{item.name}
<span class="v2-sub" style="font-size:11px">
({item.type.replaceAll('_', ' ')})
</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<div class="v2-card" style="overflow:hidden;margin-bottom:20px">
{#each packs as pack (pack.id)}
<div class="v2-setting">
<div class="v2-setting-body">
<b>{pack.name}</b>
<span class="v2-sub" style="font-size:11.5px">
{pack.description}
{#if pack.version}· v{pack.version}{/if}
</span>
</div>
{#if pack.id === org.vertical}
<Pill tone="moss">Applied</Pill>
{/if}
<form method="POST" action="?/apply" use:enhance={applySubmit}>
<input type="hidden" name="pack_id" value={pack.id} />
<button class="v2-btn v2-btn-sm" disabled={busy}>Apply</button>
</form>
</div>
{/each}
{#if !packs.length}
<div class="v2-setting">
<span class="v2-sub" style="font-size:12.5px">No packs available right now.</span>
</div>
{/if}
</div>
<div class="danger">
{#if confirmingClear}
<form
method="POST"
action="?/clearSampleData"
use:enhance={clearSubmit}
style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"
>
<span class="v2-sub" style="font-size:12px">
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.
</span>
<button class="v2-btn danger-btn" type="submit" disabled={busy}>
<Trash2 size={14} /> Clear sample data
</button>
<button
class="v2-btn"
type="button"
disabled={busy}
onclick={() => (confirmingClear = false)}
>
Cancel
</button>
</form>
{:else if form?.cleared !== undefined}
<span class="v2-sub" style="font-size:12.5px">
{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}
</span>
{:else}
<button
class="v2-btn danger-btn"
type="button"
onclick={() => (confirmingClear = true)}
>
<Trash2 size={14} /> Clear sample data
</button>
<span class="v2-sub" style="font-size:11.5px">
Removes only the records a pack created as samples. Your real records are never
touched.
</span>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
/* Mirrors the destructive-action pattern used on the product-edit page
(frontend/src/routes/(app)/invoices/products/[id]/edit/+page.svelte) —
same class names, same look, so "delete something" reads the same way
everywhere in the app. */
.danger {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
border-top: 1px solid var(--v2-line-soft);
padding-top: 16px;
}
.danger-btn {
color: var(--v2-rust);
border-color: color-mix(in srgb, var(--v2-rust) 32%, transparent);
}
.danger-btn:hover {
background: color-mix(in srgb, var(--v2-rust) 9%, transparent);
}
</style>

View file

@ -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}`);
}

View file

@ -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' });
}
}

View file

@ -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/<id>/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) {

View file

@ -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 @@
<p class="v2-hint">This becomes your workspace name in BottleCRM.</p>
</div>
{#if packs.length > 0}
<fieldset class="v2-field pack-choice">
<legend>What kind of business is this?</legend>
<p class="v2-hint" style="margin-top:0">
Sets up a starter pipeline, tags and fields for your industry. You can change
everything later.
</p>
<label class="pack-opt">
<input
type="radio"
name="vertical"
value=""
checked
disabled={isSubmitting || !!form?.data}
/>
<span class="pack-opt-body">
<b>Skip for now</b>
<span class="v2-hint" style="margin:0">Start with a blank workspace.</span>
</span>
</label>
{#each packs as pack (pack.id)}
<label class="pack-opt">
<input
type="radio"
name="vertical"
value={pack.id}
disabled={isSubmitting || !!form?.data}
/>
<span class="pack-opt-body">
<b>{pack.name}</b>
{#if pack.description}
<span class="v2-hint" style="margin:0">{pack.description}</span>
{/if}
</span>
</label>
{/each}
</fieldset>
{/if}
{#if form?.error}
<div class="v2-auth-note v2-auth-note-bad" style="margin-bottom:14px">
<AlertCircle />
@ -110,3 +153,46 @@
</div>
</div>
</div>
<style>
.pack-choice {
border: 1px solid var(--v2-line-soft);
border-radius: 8px;
padding: 14px 16px;
}
.pack-choice legend {
font-weight: 600;
padding: 0 6px;
}
.pack-opt {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 9px 10px;
border: 1px solid var(--v2-line);
border-radius: 8px;
cursor: pointer;
}
.pack-opt + .pack-opt {
margin-top: 7px;
}
.pack-opt:has(input:checked) {
border-color: var(--v2-ember);
background: var(--v2-ember-soft);
}
.pack-opt:has(input:disabled) {
cursor: not-allowed;
opacity: 0.65;
}
.pack-opt input[type='radio'] {
margin-top: 2px;
accent-color: var(--v2-ember);
flex: none;
}
.pack-opt-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
</style>