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:
parent
a287aced3a
commit
9dc080ddec
64 changed files with 4958 additions and 53 deletions
18
backend/accounts/migrations/0008_account_is_sample.py
Normal file
18
backend/accounts/migrations/0008_account_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
18
backend/cases/migrations/0026_case_is_sample.py
Normal file
18
backend/cases/migrations/0026_case_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
68
backend/common/migrations/0032_vertical_packs.py
Normal file
68
backend/common/migrations/0032_vertical_packs.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
86
backend/common/migrations/0034_rls_pipeline_tables.py
Normal file
86
backend/common/migrations/0034_rls_pipeline_tables.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
4
backend/common/packs/__init__.py
Normal file
4
backend/common/packs/__init__.py
Normal 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"]
|
||||
639
backend/common/packs/applier.py
Normal file
639
backend/common/packs/applier.py
Normal 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()
|
||||
39
backend/common/packs/loader.py
Normal file
39
backend/common/packs/loader.py
Normal 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)
|
||||
482
backend/common/packs/schema.py
Normal file
482
backend/common/packs/schema.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
149
backend/common/tests/test_packs_api.py
Normal file
149
backend/common/tests/test_packs_api.py
Normal 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
|
||||
160
backend/common/tests/test_packs_applier.py
Normal file
160
backend/common/tests/test_packs_applier.py
Normal 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"]
|
||||
88
backend/common/tests/test_packs_content.py
Normal file
88
backend/common/tests/test_packs_content.py
Normal 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)}"
|
||||
)
|
||||
112
backend/common/tests/test_packs_loader.py
Normal file
112
backend/common/tests/test_packs_loader.py
Normal 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
|
||||
28
backend/common/tests/test_packs_models.py
Normal file
28
backend/common/tests/test_packs_models.py
Normal 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
|
||||
362
backend/common/tests/test_packs_sample_data.py
Normal file
362
backend/common/tests/test_packs_sample_data.py
Normal 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
|
||||
88
backend/common/tests/test_rls_pipeline_tables.py
Normal file
88
backend/common/tests/test_rls_pipeline_tables.py
Normal 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"
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
89
backend/common/views/pack_views.py
Normal file
89
backend/common/views/pack_views.py
Normal 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"],
|
||||
}
|
||||
)
|
||||
18
backend/contacts/migrations/0013_contact_is_sample.py
Normal file
18
backend/contacts/migrations/0013_contact_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
|
|||
16
backend/leads/migrations/0014_lead_is_sample.py
Normal file
16
backend/leads/migrations/0014_lead_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
18
backend/leads/migrations/0015_alter_lead_is_sample.py
Normal file
18
backend/leads/migrations/0015_alter_lead_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
18
backend/opportunity/migrations/0014_opportunity_is_sample.py
Normal file
18
backend/opportunity/migrations/0014_opportunity_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
324
backend/packs/education-admissions.json
Normal file
324
backend/packs/education-admissions.json
Normal 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" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
323
backend/packs/professional-services.json
Normal file
323
backend/packs/professional-services.json
Normal 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" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
294
backend/packs/real-estate.json
Normal file
294
backend/packs/real-estate.json
Normal 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 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
18
backend/tasks/migrations/0012_task_is_sample.py
Normal file
18
backend/tasks/migrations/0012_task_is_sample.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
frontend/src/lib/server/log-safe.js
Normal file
39
frontend/src/lib/server/log-safe.js
Normal 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(' ');
|
||||
}
|
||||
94
frontend/src/lib/server/packs.js
Normal file
94
frontend/src/lib/server/packs.js
Normal 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) }
|
||||
);
|
||||
}
|
||||
173
frontend/src/lib/server/v2/lead-custom-fields.js
Normal file
173
frontend/src/lib/server/v2/lead-custom-fields.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
26
frontend/src/lib/terminology.js
Normal file
26
frontend/src/lib/terminology.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.') });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue