From 565a71ba1df920419320b080d893a1be90cc88b6 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Fri, 17 Jul 2026 09:07:06 +1200 Subject: [PATCH 01/66] fix: keep Xero provisioning fixture in sync --- .../tests/test_xero_instance_templates.py | 23 +++++++++++-------- mypy-baseline.txt | 2 -- .../server/templates/xero-apps.json.template | 1 - 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/workflow/tests/test_xero_instance_templates.py b/apps/workflow/tests/test_xero_instance_templates.py index 5d1aaecd8..b32963d01 100644 --- a/apps/workflow/tests/test_xero_instance_templates.py +++ b/apps/workflow/tests/test_xero_instance_templates.py @@ -3,8 +3,11 @@ import tempfile from pathlib import Path +from django.core import serializers from django.test import SimpleTestCase +from apps.workflow.models import XeroApp + REPO_ROOT = Path(__file__).resolve().parents[3] CREDENTIALS_TEMPLATE = ( REPO_ROOT / "scripts" / "server" / "templates" / "credentials-instance.template" @@ -91,7 +94,8 @@ def test_credentials_template_includes_phone_provider_env_vars(self) -> None: self.assertIn("PHONE_PROVIDER_PASSWORD=", content) self.assertIn("PHONE_PROVIDER_ACCOUNT_CODE=", content) - def test_xero_apps_template_renders_to_valid_json(self): + def test_xero_apps_template_renders_to_valid_json(self) -> None: + """Model field removals must not leave provisioning fixtures unreadable.""" rendered = ( XERO_APPS_TEMPLATE.read_text() .replace("__INSTANCE__", "msm-uat") @@ -104,15 +108,16 @@ def test_xero_apps_template_renders_to_valid_json(self): ) ) - payload = json.loads(rendered) - self.assertEqual(len(payload), 1) - fields = payload[0]["fields"] - self.assertEqual(fields["label"], "msm-uat xero") - self.assertEqual(fields["client_id"], "client-id") - self.assertEqual(fields["client_secret"], "client-secret") - self.assertEqual(fields["webhook_key"], "webhook-key") + deserialized = list(serializers.deserialize("json", rendered)) + self.assertEqual(len(deserialized), 1) + xero_app = deserialized[0].object + assert isinstance(xero_app, XeroApp) + self.assertEqual(xero_app.label, "msm-uat xero") + self.assertEqual(xero_app.client_id, "client-id") + self.assertEqual(xero_app.client_secret, "client-secret") + self.assertEqual(xero_app.webhook_key, "webhook-key") self.assertEqual( - fields["redirect_uri"], + xero_app.redirect_uri, "https://msm-uat.docketworks.site/api/xero/oauth/callback/", ) diff --git a/mypy-baseline.txt b/mypy-baseline.txt index 543316eb4..c1e62f52e 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -271,8 +271,6 @@ apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missin apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a type annotation [no-untyped-def] apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value diff --git a/scripts/server/templates/xero-apps.json.template b/scripts/server/templates/xero-apps.json.template index a5502a575..ca7c5042f 100644 --- a/scripts/server/templates/xero-apps.json.template +++ b/scripts/server/templates/xero-apps.json.template @@ -9,7 +9,6 @@ "redirect_uri": "__XERO_REDIRECT_URI__", "webhook_key": "__XERO_WEBHOOK_KEY__", "is_active": true, - "tenant_id": null, "token_type": null, "access_token": null, "refresh_token": null, From 9177eace6e88808cec67081c466cbd831223a4d7 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Fri, 17 Jul 2026 11:13:24 +1200 Subject: [PATCH 02/66] fix: repair demo instance seeding --- apps/workflow/fixtures/initial_data.json | 84 ++++++++----------- .../tests/test_xero_instance_templates.py | 52 +++++++++++- docs/instance-setup-demo.md | 16 ++-- docs/server_setup.md | 6 +- scripts/server/README.md | 3 +- scripts/server/dw-run.sh | 2 +- scripts/server/instance.sh | 4 +- 7 files changed, 98 insertions(+), 69 deletions(-) diff --git a/apps/workflow/fixtures/initial_data.json b/apps/workflow/fixtures/initial_data.json index 69fae2b95..c90fd0202 100644 --- a/apps/workflow/fixtures/initial_data.json +++ b/apps/workflow/fixtures/initial_data.json @@ -1,28 +1,15 @@ [ - { - "model": "company.company", - "pk": "00000000-0000-0000-0000-000000000001", - "fields": { - "xero_contact_id": null, - "name": "Demo Company Shop", - "email": "demo@example.com", - "address": null, - "is_account_customer": false, - "raw_json": {}, - "xero_last_modified": "2024-01-01T00:00:00Z", - "django_created_at": "2024-01-01T00:00:00Z", - "django_updated_at": "2024-01-01T00:00:00Z" - } - }, { "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000001", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "charles.baker@example.com", "first_name": "Charles", "last_name": "Baker", "preferred_name": "Charlie", - "wage_rate": "35.80", + "base_wage_rate": "35.80", + "wage_rate": "42.96", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -39,11 +26,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000002", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "alex.cooper@example.com", "first_name": "Alex", "last_name": "Cooper", "preferred_name": null, - "wage_rate": "33.50", + "base_wage_rate": "33.50", + "wage_rate": "40.20", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -60,11 +49,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000003", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "nathan.chen@example.com", "first_name": "Nathan", "last_name": "Chen", "preferred_name": "Nate", - "wage_rate": "36.25", + "base_wage_rate": "36.25", + "wage_rate": "43.50", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -81,11 +72,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000004", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "robert.irwin@example.com", "first_name": "Robert", "last_name": "Irwin", "preferred_name": "Rob", - "wage_rate": "37.50", + "base_wage_rate": "37.50", + "wage_rate": "45.00", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -102,11 +95,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000005", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "peter.johnson@example.com", "first_name": "Peter", "last_name": "Johnson", "preferred_name": "Pete", - "wage_rate": "34.75", + "base_wage_rate": "34.75", + "wage_rate": "41.70", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -123,11 +118,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000006", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "james.kennedy@example.com", "first_name": "James", "last_name": "Kennedy", "preferred_name": "Jim", - "wage_rate": "36.00", + "base_wage_rate": "36.00", + "wage_rate": "43.20", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -144,11 +141,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000007", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "andrew.mitchell@example.com", "first_name": "Andrew", "last_name": "Mitchell", "preferred_name": "Andy", - "wage_rate": "35.25", + "base_wage_rate": "35.25", + "wage_rate": "42.30", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -165,11 +164,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000008", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "anthony.parker@example.com", "first_name": "Anthony", "last_name": "Parker", "preferred_name": "Tony", - "wage_rate": "33.75", + "base_wage_rate": "33.75", + "wage_rate": "40.50", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -186,11 +187,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000009", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "thomas.richards@example.com", "first_name": "Thomas", "last_name": "Richards", "preferred_name": "Tom", - "wage_rate": "34.50", + "base_wage_rate": "34.50", + "wage_rate": "41.40", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -207,11 +210,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000010", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "matthew.robinson@example.com", "first_name": "Matthew", "last_name": "Robinson", "preferred_name": "Matt", - "wage_rate": "36.50", + "base_wage_rate": "36.50", + "wage_rate": "43.80", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -228,11 +233,13 @@ "model": "accounts.staff", "pk": "10000000-0000-0000-0000-000000000011", "fields": { + "password": "pbkdf2_sha256$1200000$r38oa8JEHbS2mwPAOUuXvP$3I5jBqODtIZEnfrWoV0ONxu7R2JBNCu2tidiV9ChlYY=", "email": "patrick.xu@example.com", "first_name": "Patrick", "last_name": "Xu", "preferred_name": null, - "wage_rate": "35.00", + "base_wage_rate": "35.00", + "wage_rate": "42.00", "hours_mon": "8.0", "hours_tue": "8.0", "hours_wed": "8.0", @@ -244,30 +251,5 @@ "updated_at": "2024-01-01T00:00:00Z", "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe48" } - }, - { - "model": "accounts.staff", - "pk": "10000000-0000-0000-0000-000000000012", - "fields": { - "password": "pbkdf2_sha256$870000$5Nw3RUuFaZZPCkeyVOm4kx$Attep1SqGF6ymdwm44LOte4wwszqte0W5ey3xcENFAI=", - "email": "defaultadmin@example.com", - "first_name": "Default", - "last_name": "Admin", - "preferred_name": null, - "wage_rate": "40.00", - "hours_mon": "8.0", - "hours_tue": "8.0", - "hours_wed": "8.0", - "hours_thu": "8.0", - "hours_fri": "8.0", - "hours_sat": "0.00", - "hours_sun": "0.00", - "is_active": true, - "is_office_staff": true, - "is_superuser": true, - "date_joined": "2024-01-01T00:00:00Z", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } } ] diff --git a/apps/workflow/tests/test_xero_instance_templates.py b/apps/workflow/tests/test_xero_instance_templates.py index b32963d01..34aebec22 100644 --- a/apps/workflow/tests/test_xero_instance_templates.py +++ b/apps/workflow/tests/test_xero_instance_templates.py @@ -1,12 +1,15 @@ import json import subprocess import tempfile +from decimal import Decimal from pathlib import Path from django.core import serializers -from django.test import SimpleTestCase +from django.core.management import call_command +from django.test import SimpleTestCase, TestCase -from apps.workflow.models import XeroApp +from apps.accounts.models import Staff +from apps.workflow.models import CompanyDefaults, XeroApp REPO_ROOT = Path(__file__).resolve().parents[3] CREDENTIALS_TEMPLATE = ( @@ -72,6 +75,43 @@ / "backup-files-instance.service.template" ) SETTINGS_FILE = REPO_ROOT / "docketworks" / "settings.py" +COMPANY_DEFAULTS_FIXTURE = ( + REPO_ROOT / "apps" / "workflow" / "fixtures" / "company_defaults.json" +) +INITIAL_DATA_FIXTURE = ( + REPO_ROOT / "apps" / "workflow" / "fixtures" / "initial_data.json" +) + + +class DemoSeedFixtureTests(TestCase): + def test_demo_seed_fixtures_load_current_demo_contract(self) -> None: + """Schema changes must not break demo creation or its advertised logins.""" + call_command( + "loaddata", + str(COMPANY_DEFAULTS_FIXTURE), + str(INITIAL_DATA_FIXTURE), + verbosity=0, + ) + + demo_staff = Staff.objects.filter(email__endswith="@example.com") + self.assertEqual(demo_staff.count(), 11) + self.assertFalse( + Staff.objects.filter(email="defaultadmin@example.com").exists() + ) + self.assertTrue( + all(staff.check_password("Default-staff-password") for staff in demo_staff) + ) + + charles = demo_staff.get(email="charles.baker@example.com") + self.assertEqual(charles.base_wage_rate, Decimal("35.80")) + self.assertEqual(charles.wage_rate, Decimal("42.96")) + + defaults = CompanyDefaults.objects.get(pk=1) + self.assertEqual(defaults.company_name, "Demo Company") + self.assertEqual( + str(defaults.shop_company_id), + "00000000-0000-0000-0000-000000000001", + ) class XeroInstanceTemplateTests(SimpleTestCase): @@ -240,6 +280,14 @@ def test_instance_script_rejects_seed_for_existing_checkout(self) -> None: self.assertIn('[[ "$IS_EXISTING" == "true" && "$SEED" == "true" ]]', content) self.assertIn("--seed is only valid when creating a new instance", content) + def test_instance_script_loads_canonical_demo_seed_fixtures(self) -> None: + """Renaming fixtures must not leave --seed pointing at a missing label.""" + content = INSTANCE_SCRIPT.read_text() + + self.assertIn("apps/workflow/fixtures/company_defaults.json", content) + self.assertIn("apps/workflow/fixtures/initial_data.json", content) + self.assertNotIn("demo_fixtures", content) + def test_instance_script_rejects_config_without_release_link(self) -> None: content = INSTANCE_SCRIPT.read_text() diff --git a/docs/instance-setup-demo.md b/docs/instance-setup-demo.md index 4affd4dc6..aa440ae82 100644 --- a/docs/instance-setup-demo.md +++ b/docs/instance-setup-demo.md @@ -38,20 +38,16 @@ render and load the initial XeroApp fixture. ## Step 2: Create Instance ```bash -sudo scripts/server/instance.sh create uat +sudo scripts/server/instance.sh create uat --seed ``` -**Check:** `https://-uat.docketworks.site` shows login page. +This loads the demo CompanyDefaults and 11 dummy staff. The initial admin is +created separately by the provisioning script, so it is not part of the demo +fixture. -## Step 3: Load Demo Data +**Check:** `https://-uat.docketworks.site` shows the login page. -```bash -# Company settings (starting point — will be customised in Step 5) -scripts/server/dw-run.sh -uat python manage.py loaddata apps/workflow/fixtures/company_defaults.json - -# Demo staff (11 dummy employees + admin) -scripts/server/dw-run.sh -uat python manage.py loaddata apps/workflow/fixtures/initial_data.json -``` +## Step 3: Verify Demo Data **Check:** ```bash diff --git a/docs/server_setup.md b/docs/server_setup.md index fe4b13fef..c17bb6df7 100644 --- a/docs/server_setup.md +++ b/docs/server_setup.md @@ -126,7 +126,7 @@ sudo scripts/server/instance.sh create # Re-run after root-owned credential/config edits sudo scripts/server/instance.sh reconfigure -# Or with demo fixtures: +# Or create a demo with CompanyDefaults and 11 dummy staff: sudo scripts/server/instance.sh create --seed ``` @@ -171,8 +171,8 @@ SQL ## Part C.1: Post-Create Setup -After `instance.sh create` completes, the instance has infrastructure but no data. -Choose the path that matches your scenario: +After `instance.sh create` completes without `--seed`, the instance has +infrastructure but no tenant data. Choose the path that matches your scenario: ### Path A: Backup Restore (e.g. MSM demo) diff --git a/scripts/server/README.md b/scripts/server/README.md index efc57065d..2476cd925 100644 --- a/scripts/server/README.md +++ b/scripts/server/README.md @@ -69,7 +69,8 @@ sudo ./scripts/server/instance.sh create mycompany uat sudo ./scripts/server/instance.sh reconfigure mycompany uat ``` -Add `--seed` to load demo fixture data: +Add `--seed` to load the demo CompanyDefaults and 11 dummy staff. The dummy +staff use the documented `Default-staff-password` login: ```bash sudo ./scripts/server/instance.sh create mycompany uat --seed diff --git a/scripts/server/dw-run.sh b/scripts/server/dw-run.sh index 140d5a7f7..c8d266b1d 100755 --- a/scripts/server/dw-run.sh +++ b/scripts/server/dw-run.sh @@ -7,7 +7,7 @@ set -euo pipefail # Usage: dw-run [args...] # Examples: # dw-run msm-uat python manage.py migrate --no-input -# dw-run msm-uat python manage.py loaddata demo_fixtures +# dw-run msm-uat python manage.py loaddata company_defaults # dw-run msm-uat python scripts/setup_dev_logins.py SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/scripts/server/instance.sh b/scripts/server/instance.sh index 6c290802b..4c72f621c 100755 --- a/scripts/server/instance.sh +++ b/scripts/server/instance.sh @@ -571,7 +571,9 @@ EOSQL if [[ "$SEED" == "true" ]]; then log "Loading demo fixtures..." - "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python manage.py loaddata demo_fixtures + "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python manage.py loaddata \ + apps/workflow/fixtures/company_defaults.json \ + apps/workflow/fixtures/initial_data.json fi fi From cfbd5c194805a496c4dc820a6e6c5b25b274053b Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Sun, 19 Jul 2026 08:10:03 +1200 Subject: [PATCH 03/66] fix: make xero tenant provisioning explicit --- .../management/commands/create_shop_jobs.py | 33 ++-- apps/workflow/fixtures/initial_data.json | 22 +-- .../commands/finalize_instance_onboarding.py | 36 ++++ apps/workflow/management/commands/xero.py | 177 ++++++++++------- apps/workflow/services/__init__.py | 2 + apps/workflow/services/instance_onboarding.py | 112 +++++++++++ .../tests/test_instance_onboarding.py | 76 +++++++ .../tests/test_xero_instance_templates.py | 28 +-- .../workflow/tests/test_xero_setup_command.py | 66 ++++++- docs/client_onboarding.md | 27 ++- docs/instance-setup-demo.md | 141 ++++--------- docs/instance-setup-production.md | 187 +++++------------- docs/restore-prod-to-nonprod.md | 18 +- docs/server_setup.md | 103 +++------- scripts/server/README.md | 19 +- scripts/server/instance.sh | 140 ++++++++++--- .../templates/credentials-instance.template | 3 +- 17 files changed, 716 insertions(+), 474 deletions(-) create mode 100644 apps/workflow/management/commands/finalize_instance_onboarding.py create mode 100644 apps/workflow/services/instance_onboarding.py create mode 100644 apps/workflow/tests/test_instance_onboarding.py diff --git a/apps/job/management/commands/create_shop_jobs.py b/apps/job/management/commands/create_shop_jobs.py index b325751ca..819756016 100644 --- a/apps/job/management/commands/create_shop_jobs.py +++ b/apps/job/management/commands/create_shop_jobs.py @@ -1,4 +1,4 @@ -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from apps.accounts.models import Staff from apps.job.models import Job @@ -8,7 +8,7 @@ class Command(BaseCommand): help = "Create shop jobs for internal purposes" - def handle(self, *args, **kwargs): + def handle(self, *args: object, **kwargs: object) -> None: # Define shop job details shop_jobs = [ { @@ -49,21 +49,32 @@ def handle(self, *args, **kwargs): company_defaults = CompanyDefaults.get_solo() shop_company = company_defaults.shop_company - # Iterate through the shop jobs and create them + created = 0 + updated = 0 automation_user = Staff.get_automation_user() for job_details in shop_jobs: - # Create the job instance - job = Job( + matches = Job.objects.filter( name=job_details["name"], company=shop_company, - description="", - status="special", - shop_job=True, # Changed from shop_job to is_shop_job - job_is_valid=True, - paid=False, ) + if matches.count() > 1: + raise CommandError( + f"Multiple shop jobs named '{job_details['name']}' already exist." + ) + job = matches.first() + if job is None: + job = Job(name=job_details["name"], company=shop_company) + created += 1 + else: + updated += 1 + job.description = job_details["description"] + job.status = "special" + job.job_is_valid = True + job.paid = False job.save(staff=automation_user) self.stdout.write( - self.style.SUCCESS("Shop jobs have been successfully created.") + self.style.SUCCESS( + f"Shop jobs ready: {created} created, {updated} updated." + ) ) diff --git a/apps/workflow/fixtures/initial_data.json b/apps/workflow/fixtures/initial_data.json index c90fd0202..272947895 100644 --- a/apps/workflow/fixtures/initial_data.json +++ b/apps/workflow/fixtures/initial_data.json @@ -19,7 +19,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe38" + "xero_user_id": null } }, { @@ -42,7 +42,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe39" + "xero_user_id": null } }, { @@ -65,7 +65,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe40" + "xero_user_id": null } }, { @@ -88,7 +88,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe41" + "xero_user_id": null } }, { @@ -111,7 +111,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe42" + "xero_user_id": null } }, { @@ -134,7 +134,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe43" + "xero_user_id": null } }, { @@ -157,7 +157,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe44" + "xero_user_id": null } }, { @@ -180,7 +180,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe45" + "xero_user_id": null } }, { @@ -203,7 +203,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe46" + "xero_user_id": null } }, { @@ -226,7 +226,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe47" + "xero_user_id": null } }, { @@ -249,7 +249,7 @@ "hours_sun": "0.00", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", - "xero_user_id": "4ac80a91-1aee-434b-88a9-c509b48bfe48" + "xero_user_id": null } } ] diff --git a/apps/workflow/management/commands/finalize_instance_onboarding.py b/apps/workflow/management/commands/finalize_instance_onboarding.py new file mode 100644 index 000000000..66d10aba2 --- /dev/null +++ b/apps/workflow/management/commands/finalize_instance_onboarding.py @@ -0,0 +1,36 @@ +"""Management command for the post-OAuth instance onboarding workflow.""" + +from django.core.management.base import BaseCommand + +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.models import CompanyDefaults +from apps.workflow.services.error_persistence import persist_app_error +from apps.workflow.services.instance_onboarding import finalize_instance_onboarding + + +class Command(BaseCommand): + help = "Finalize Xero onboarding and enable synchronization for a new instance" + + def add_arguments(self, parser) -> None: + parser.add_argument( + "--seed-xero", + action="store_true", + help="Create missing demo-only Xero configuration and employees", + ) + + def handle(self, *args: object, **options: object) -> None: + try: + finalize_instance_onboarding(seed_xero=bool(options["seed_xero"])) + except AlreadyLoggedException: + CompanyDefaults.objects.filter(pk=1).update(enable_xero_sync=False) + raise + except Exception as exc: + CompanyDefaults.objects.filter(pk=1).update(enable_xero_sync=False) + err = persist_app_error(exc) + raise AlreadyLoggedException(exc, err.id) from exc + + self.stdout.write( + self.style.SUCCESS( + "Instance onboarding complete; automated Xero sync is enabled." + ) + ) diff --git a/apps/workflow/management/commands/xero.py b/apps/workflow/management/commands/xero.py index 06db25c6d..e3f91655c 100644 --- a/apps/workflow/management/commands/xero.py +++ b/apps/workflow/management/commands/xero.py @@ -17,7 +17,11 @@ resolve_sales_branding_theme, ) from apps.workflow.accounting.registry import get_provider -from apps.workflow.api.xero.auth import api_client, get_tenant_id, get_valid_token +from apps.workflow.api.xero.auth import ( + api_client, + get_tenant_id, + get_valid_token, +) from apps.workflow.api.xero.payroll import ( get_earnings_rates, get_employees, @@ -25,8 +29,10 @@ get_pay_runs, get_payroll_calendars, ) +from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.models import XeroApp from apps.workflow.models.company_defaults import CompanyDefaults +from apps.workflow.services.error_persistence import persist_app_error def get_employees_simple_dev(): @@ -83,6 +89,11 @@ def add_arguments(self, parser): "and payroll calendar" ), ) + parser.add_argument( + "--seed-xero", + action="store_true", + help="Create missing demo-only Xero configuration during --setup", + ) parser.add_argument( "--no-set", action="store_true", @@ -184,20 +195,29 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): + try: + self._handle(*args, **options) + except AlreadyLoggedException: + raise + except Exception as exc: + err = persist_app_error(exc) + raise AlreadyLoggedException(exc, err.id) from exc + + def _handle(self, *args, **options): # First check we have a valid token token = get_valid_token() if not token: - self.stdout.write( - self.style.ERROR( - "No valid Xero token found.\n" - "Connect to Xero via Admin > Xero Settings in the web app first." - ) + raise CommandError( + "No valid Xero token found. Connect to Xero via Admin > " + "Xero Settings in the web app first." ) - return # Handle specific flags + if options["seed_xero"] and not options["setup"]: + raise CommandError("--seed-xero is only valid with --setup.") + if options["setup"]: - self.run_setup() + self.run_setup(seed_xero=options["seed_xero"]) return if options["users"]: @@ -263,7 +283,7 @@ def handle(self, *args, **options): self.get_tenants(options) def get_tenants(self, options): - """Get available Xero tenant IDs and names""" + """Get available Xero tenant IDs and names.""" identity_api = IdentityApi(api_client) connections = identity_api.get_connections() @@ -274,27 +294,19 @@ def get_tenants(self, options): self.stdout.write(f"Name: {conn.tenant_name}") self.stdout.write("-----------------------------") - # If only one tenant and --no-set not specified, automatically set it if len(connections) == 1 and not options["no_set"]: tenant_id = connections[0].tenant_id tenant_name = connections[0].tenant_name - - try: - company_defaults = CompanyDefaults.get_solo() - company_defaults.xero_tenant_id = tenant_id - company_defaults.save() - - self.stdout.write( - self.style.SUCCESS( - f"Automatically set tenant ID to {tenant_id} " - f"({tenant_name}) in CompanyDefaults" - ) - ) - except Exception as e: - self.stdout.write( - self.style.ERROR(f"Failed to set tenant ID in CompanyDefaults: {e}") + company_defaults = CompanyDefaults.get_solo() + company_defaults.xero_tenant_id = tenant_id + company_defaults.save(update_fields=["xero_tenant_id"]) + self.stdout.write( + self.style.SUCCESS( + f"Automatically set tenant ID to {tenant_id} " + f"({tenant_name}) in CompanyDefaults" ) - elif len(connections) == 1 and options["no_set"]: + ) + elif len(connections) == 1: self.stdout.write( self.style.WARNING( "Single tenant found but --no-set specified, " @@ -309,6 +321,36 @@ def get_tenants(self, options): ) ) + def _validate_production_xero_items(self, calendar_name: str) -> None: + """Require production payroll configuration without creating Xero data.""" + from apps.workflow.models import XeroPayItem + + if not calendar_name: + raise CommandError("Production requires xero_payroll_calendar_name.") + calendars = get_payroll_calendars() + if not any(calendar["name"] == calendar_name for calendar in calendars): + raise CommandError( + f"Payroll calendar '{calendar_name}' does not exist in the production Xero tenant." + ) + + pay_items = list(XeroPayItem.objects.all()) + if not pay_items: + raise CommandError( + "No required XeroPayItem records are configured locally." + ) + earnings_names = {rate["name"] for rate in get_earnings_rates()} + leave_names = {leave["name"] for leave in get_leave_types()} + missing = [ + item.name + for item in pay_items + if item.name not in (leave_names if item.uses_leave_api else earnings_names) + ] + if missing: + raise CommandError( + "Production Xero is missing required pay items: " + + ", ".join(sorted(missing)) + ) + def _ensure_demo_xero_items_exist(self, calendar_name: str, tenant_id: str) -> None: """Create any Xero payroll items missing from the demo org (e.g. after a demo org reset).""" from xero_python.payrollnz.models import EarningsRate, LeaveType @@ -384,12 +426,9 @@ def _ensure_demo_xero_items_exist(self, calendar_name: str, tenant_id: str) -> N None, ) if not expense_account_id: - self.stdout.write( - self.style.ERROR( - "Cannot create earnings rates: no existing rate has an expense_account_id." - ) + raise CommandError( + "Cannot create demo earnings rates: no existing rate has an expense_account_id." ) - return for item in pay_items: if item.uses_leave_api: @@ -434,7 +473,7 @@ def _ensure_demo_xero_items_exist(self, calendar_name: str, tenant_id: str) -> N ) ) - def run_setup(self): + def run_setup(self, *, seed_xero: bool = False) -> None: """Configure Xero tenant, theme, shortcode, and payroll calendar.""" self.stdout.write("Setting up Xero connection...") @@ -447,15 +486,13 @@ def run_setup(self): raise if not connections: - self.stdout.write( - self.style.ERROR( - "No Xero organisations connected.\n" - "Please connect an organisation in Xero first." - ) + raise CommandError( + "No Xero organisations connected. Please connect an organisation " + "in Xero first." ) - return - # Step 2: Use first connected organisation + # Step 2: Use first connected organisation. This intentionally rebinds + # CompanyDefaults after Xero's recurring demo-tenant resets. connection = connections[0] tenant_id = connection.tenant_id tenant_name = connection.tenant_name @@ -487,9 +524,12 @@ def run_setup(self): # Always update cache to prevent stale tenant ID from being used cache.set("xero_tenant_id", tenant_id) - self._ensure_demo_xero_items_exist( - company.xero_payroll_calendar_name, tenant_id - ) + if seed_xero: + self._ensure_demo_xero_items_exist( + company.xero_payroll_calendar_name, tenant_id + ) + else: + self._validate_production_xero_items(company.xero_payroll_calendar_name) # Step 4: Fetch organisation shortcode for deep linking accounting_api = AccountingApi(api_client) @@ -504,9 +544,24 @@ def run_setup(self): shortcode = org_response.organisations[0].short_code # Step 5: Select a live sales branding theme for this tenant - sales_branding_theme = resolve_sales_branding_theme( - get_provider(), company.xero_sales_branding_theme_id - ) + if not seed_xero: + configured_theme_id = company.xero_sales_branding_theme_id + if configured_theme_id is None: + raise CommandError( + "Production requires an explicitly selected Xero sales branding theme." + ) + sales_branding_theme = next( + ( + theme + for theme in get_provider().list_document_themes() + if theme.external_id == str(configured_theme_id) + ), + None, + ) + else: + sales_branding_theme = resolve_sales_branding_theme( + get_provider(), company.xero_sales_branding_theme_id + ) if sales_branding_theme is None: raise CommandError( "Xero returned no branding themes. Create a branding theme in " @@ -516,28 +571,18 @@ def run_setup(self): # Step 6: Fetch payroll calendar ID calendar_name = company.xero_payroll_calendar_name if not calendar_name: - self.stdout.write( - self.style.WARNING( - "xero_payroll_calendar_name not configured in CompanyDefaults. " - "Skipping payroll calendar setup." - ) - ) - payroll_calendar_id = None - else: - calendars = get_payroll_calendars() - matching_calendar = next( - (c for c in calendars if c["name"] == calendar_name), None + raise CommandError("xero_payroll_calendar_name is required.") + calendars = get_payroll_calendars() + matching_calendar = next( + (c for c in calendars if c["name"] == calendar_name), None + ) + if not matching_calendar: + available = [c["name"] for c in calendars] + raise CommandError( + f"Payroll calendar '{calendar_name}' not found in Xero. " + f"Available calendars: {available}" ) - if not matching_calendar: - available = [c["name"] for c in calendars] - self.stdout.write( - self.style.ERROR( - f"Payroll calendar '{calendar_name}' not found in Xero.\n" - f"Available calendars: {available}" - ) - ) - return - payroll_calendar_id = matching_calendar["id"] + payroll_calendar_id = matching_calendar["id"] # Step 7: Save to CompanyDefaults company.xero_shortcode = shortcode diff --git a/apps/workflow/services/__init__.py b/apps/workflow/services/__init__.py index ca12a449c..b84030435 100644 --- a/apps/workflow/services/__init__.py +++ b/apps/workflow/services/__init__.py @@ -27,6 +27,7 @@ persist_app_error, persist_xero_error, ) + from .instance_onboarding import finalize_instance_onboarding from .llm_service import LLMService, quick_completion, quick_json_completion from .request import get_client_ip from .search import apply_text_search @@ -60,6 +61,7 @@ "create_recording", "extract_job_context", "extract_request_context", + "finalize_instance_onboarding", "get_client_ip", "list_app_errors", "list_grouped_app_errors", diff --git a/apps/workflow/services/instance_onboarding.py b/apps/workflow/services/instance_onboarding.py new file mode 100644 index 000000000..0bf6b5ddd --- /dev/null +++ b/apps/workflow/services/instance_onboarding.py @@ -0,0 +1,112 @@ +"""Finalise a freshly created instance after its Xero OAuth connection exists.""" + +from django.core.management import call_command + +from apps.accounts.models import Staff +from apps.job.models import Job +from apps.timesheet.services import PayrollEmployeeSyncService +from apps.workflow.api.xero.auth import get_valid_token +from apps.workflow.api.xero.payroll import sync_xero_pay_items +from apps.workflow.api.xero.sync import one_way_sync_all_xero_data +from apps.workflow.models import CompanyDefaults, XeroAccount + +CANONICAL_SHOP_JOB_NAMES = ( + "Annual Leave", + "Bench - busy work", + "Bereavement Leave", + "Business Development", + "Office Admin", + "Sick Leave", + "Training", + "Travel", + "Worker Admin", +) + + +def _sync_accounts() -> None: + errors = [ + event.get("message", "Unknown account sync error") + for event in one_way_sync_all_xero_data(entities=["accounts"], force=True) + if event.get("severity") == "error" + ] + if errors: + raise RuntimeError("Xero account sync failed: " + "; ".join(errors)) + if not XeroAccount.objects.exists(): + raise RuntimeError( + "Xero account sync completed without importing any accounts." + ) + + +def _sync_staff(*, seed_xero: bool) -> None: + if seed_xero: + staff = Staff.objects.filter(base_wage_rate__gt=0) + summary = PayrollEmployeeSyncService.sync_staff( + staff, + dry_run=False, + allow_create=True, + ) + if summary["missing"]: + raise RuntimeError("Demo staff could not all be linked to Xero Payroll.") + else: + summary = PayrollEmployeeSyncService.import_staff_from_xero( + dry_run=False, + initial_password="Default-staff-password", + ) + if summary["errors"]: + messages = [item["reason"] for item in summary["errors"]] + raise RuntimeError("Xero staff import failed: " + "; ".join(messages)) + + wage_staff = Staff.objects.filter(base_wage_rate__gt=0) + if not wage_staff.exists(): + raise RuntimeError("No wage-earning staff were configured during onboarding.") + staff_without_xero = wage_staff.filter( + xero_user_id__isnull=True, + ) + if staff_without_xero.exists(): + raise RuntimeError("One or more wage-earning staff are not linked to Xero.") + + +def _validate_completion() -> CompanyDefaults: + company = CompanyDefaults.get_solo() + required_xero_values = { + "xero_tenant_id": company.xero_tenant_id, + "xero_shortcode": company.xero_shortcode, + "xero_sales_branding_theme_id": company.xero_sales_branding_theme_id, + "xero_payroll_calendar_id": company.xero_payroll_calendar_id, + } + missing = [name for name, value in required_xero_values.items() if not value] + if missing: + raise RuntimeError( + "Xero onboarding left required CompanyDefaults unset: " + ", ".join(missing) + ) + shop_job_count = Job.objects.filter( + company=company.shop_company, + status="special", + name__in=CANONICAL_SHOP_JOB_NAMES, + ).count() + if shop_job_count != 9: + raise RuntimeError(f"Expected 9 canonical shop jobs, found {shop_job_count}.") + return company + + +def finalize_instance_onboarding(*, seed_xero: bool = False) -> None: + """Complete Xero-dependent setup and enable automated sync last.""" + company = CompanyDefaults.get_solo() + if company.enable_xero_sync: + company.enable_xero_sync = False + company.save(update_fields=["enable_xero_sync"]) + + if not get_valid_token(): + raise RuntimeError("Complete Xero OAuth before finalising instance onboarding.") + xero_setup_args = ["--setup"] + if seed_xero: + xero_setup_args.append("--seed-xero") + call_command("xero", *xero_setup_args) + sync_xero_pay_items() + _sync_accounts() + _sync_staff(seed_xero=seed_xero) + call_command("create_shop_jobs") + + company = _validate_completion() + company.enable_xero_sync = True + company.save(update_fields=["enable_xero_sync"]) diff --git a/apps/workflow/tests/test_instance_onboarding.py b/apps/workflow/tests/test_instance_onboarding.py new file mode 100644 index 000000000..a70dfa2e5 --- /dev/null +++ b/apps/workflow/tests/test_instance_onboarding.py @@ -0,0 +1,76 @@ +from unittest.mock import Mock, patch + +from django.core.management import call_command + +from apps.job.models import Job +from apps.testing import BaseTestCase +from apps.workflow.models import CompanyDefaults +from apps.workflow.services.instance_onboarding import finalize_instance_onboarding + + +class FinalizeInstanceOnboardingTests(BaseTestCase): + @patch("apps.workflow.services.instance_onboarding._validate_completion") + @patch("apps.workflow.services.instance_onboarding._sync_staff") + @patch("apps.workflow.services.instance_onboarding._sync_accounts") + @patch("apps.workflow.services.instance_onboarding.sync_xero_pay_items") + @patch("apps.workflow.services.instance_onboarding.call_command") + @patch( + "apps.workflow.services.instance_onboarding.get_valid_token", + return_value={"access_token": "token"}, + ) + def test_enables_sync_only_after_every_onboarding_step_succeeds( + self, + _mock_token: Mock, + _mock_call_command: Mock, + _mock_pay_items: Mock, + _mock_accounts: Mock, + _mock_staff: Mock, + mock_validate: Mock, + ) -> None: + company = CompanyDefaults.get_solo() + company.enable_xero_sync = False + company.save(update_fields=["enable_xero_sync"]) + mock_validate.return_value = company + + finalize_instance_onboarding(seed_xero=True) + + company.refresh_from_db() + self.assertTrue(company.enable_xero_sync) + _mock_call_command.assert_any_call("xero", "--setup", "--seed-xero") + _mock_staff.assert_called_once_with(seed_xero=True) + + @patch("apps.workflow.services.instance_onboarding._sync_accounts") + @patch("apps.workflow.services.instance_onboarding.sync_xero_pay_items") + @patch("apps.workflow.services.instance_onboarding.call_command") + @patch( + "apps.workflow.services.instance_onboarding.get_valid_token", + return_value={"access_token": "token"}, + ) + def test_failure_leaves_sync_disabled( + self, + _mock_token: Mock, + _mock_call_command: Mock, + _mock_pay_items: Mock, + mock_accounts: Mock, + ) -> None: + company = CompanyDefaults.get_solo() + company.enable_xero_sync = True + company.save(update_fields=["enable_xero_sync"]) + mock_accounts.side_effect = RuntimeError("account sync failed") + + with self.assertRaisesRegex(RuntimeError, "account sync failed"): + finalize_instance_onboarding() + + company.refresh_from_db() + self.assertFalse(company.enable_xero_sync) + + +class CreateShopJobsTests(BaseTestCase): + def test_command_is_idempotent(self) -> None: + call_command("create_shop_jobs", verbosity=0) + call_command("create_shop_jobs", verbosity=0) + + company = CompanyDefaults.get_solo() + jobs = Job.objects.filter(company=company.shop_company, status="special") + self.assertEqual(jobs.count(), 9) + self.assertEqual(jobs.filter(name="Training").count(), 1) diff --git a/apps/workflow/tests/test_xero_instance_templates.py b/apps/workflow/tests/test_xero_instance_templates.py index 34aebec22..85ee71b75 100644 --- a/apps/workflow/tests/test_xero_instance_templates.py +++ b/apps/workflow/tests/test_xero_instance_templates.py @@ -101,6 +101,7 @@ def test_demo_seed_fixtures_load_current_demo_contract(self) -> None: self.assertTrue( all(staff.check_password("Default-staff-password") for staff in demo_staff) ) + self.assertFalse(demo_staff.exclude(xero_user_id__isnull=True).exists()) charles = demo_staff.get(email="charles.baker@example.com") self.assertEqual(charles.base_wage_rate, Decimal("35.80")) @@ -118,6 +119,8 @@ class XeroInstanceTemplateTests(SimpleTestCase): def test_credentials_template_includes_xero_oauth_env_vars(self): content = CREDENTIALS_TEMPLATE.read_text() + self.assertNotIn("INSTANCE_PROFILE", content) + self.assertNotIn("XERO_EXPECTED_TENANT_ID", content) self.assertIn("XERO_DEFAULT_USER_ID=", content) self.assertIn("XERO_CLIENT_ID=", content) self.assertIn("XERO_CLIENT_SECRET=", content) @@ -220,7 +223,7 @@ def test_instance_script_requires_xero_default_user_id(self) -> None: self.assertIn('MISSING+=("XERO_DEFAULT_USER_ID")', content) self.assertNotIn("UNCONFIGURED_XERO_DEFAULT_USER_ID", content) - def test_instance_script_exposes_reconfigure_as_convergent_command(self) -> None: + def test_instance_script_exposes_reconfigure_for_existing_instances(self) -> None: content = INSTANCE_SCRIPT.read_text() self.assertIn("instance.sh reconfigure ", content) @@ -274,31 +277,32 @@ def test_instance_script_only_seeds_missing_db_config(self) -> None: content, ) - def test_instance_script_rejects_seed_for_existing_checkout(self) -> None: + def test_instance_script_uses_explicit_seed_flag(self) -> None: content = INSTANCE_SCRIPT.read_text() - self.assertIn('[[ "$IS_EXISTING" == "true" && "$SEED" == "true" ]]', content) - self.assertIn("--seed is only valid when creating a new instance", content) + self.assertIn("prepare-config [--seed]", content) + self.assertIn('[[ "$SEED" == "true" ]]', content) + self.assertNotIn("INSTANCE_PROFILE", content) - def test_instance_script_loads_canonical_demo_seed_fixtures(self) -> None: - """Renaming fixtures must not leave --seed pointing at a missing label.""" + def test_instance_script_loads_instance_company_config_then_demo_staff( + self, + ) -> None: content = INSTANCE_SCRIPT.read_text() - self.assertIn("apps/workflow/fixtures/company_defaults.json", content) + self.assertIn("$INSTANCE.company-defaults.json", content) self.assertIn("apps/workflow/fixtures/initial_data.json", content) self.assertNotIn("demo_fixtures", content) - def test_instance_script_rejects_config_without_release_link(self) -> None: + def test_instance_script_rejects_incomplete_reconfigure_state(self) -> None: content = INSTANCE_SCRIPT.read_text() self.assertIn( - '[[ -f "$INSTANCE_DIR/.env" && ! -L "$INSTANCE_DIR/app" && ! -L "$INSTANCE_DIR/current" ]]', + '[[ ! -f "$INSTANCE_DIR/.env" || ( ! -L "$INSTANCE_DIR/app" && ! -L "$INSTANCE_DIR/current" ) ]]', content, ) - self.assertIn("has config but no app/current release link", content) - self.assertIn("Restore or recreate the instance", content) + self.assertIn("Cannot reconfigure incomplete instance", content) self.assertLess( - content.index("has config but no app/current release link"), + content.index("Cannot reconfigure incomplete instance"), content.index('TARGET_SHA="$(resolve_release_ref origin/production)"'), ) diff --git a/apps/workflow/tests/test_xero_setup_command.py b/apps/workflow/tests/test_xero_setup_command.py index 8e33923b6..a09a2fc0d 100644 --- a/apps/workflow/tests/test_xero_setup_command.py +++ b/apps/workflow/tests/test_xero_setup_command.py @@ -188,12 +188,12 @@ def test_run_setup_always_calls_demo_item_provisioning( mock_identity_api_cls, mock_accounting_api_cls, mock_get_payroll_calendars, - _mock_cache_set, + mock_cache_set, mock_get_provider, mock_resolve_theme, ): company = SimpleNamespace( - xero_tenant_id=None, + xero_tenant_id="stale-tenant", xero_payroll_calendar_name="Weekly Testing", xero_shortcode=None, xero_sales_branding_theme_id=None, @@ -224,7 +224,7 @@ def test_run_setup_always_calls_demo_item_provisioning( cmd = Command() cmd._ensure_demo_xero_items_exist = Mock() - cmd.run_setup() + cmd.run_setup(seed_xero=True) cmd._ensure_demo_xero_items_exist.assert_called_once_with( "Weekly Testing", "tenant-123" @@ -234,6 +234,58 @@ def test_run_setup_always_calls_demo_item_provisioning( company.xero_sales_branding_theme_id, UUID(selected_theme.external_id), ) + self.assertEqual(company.xero_tenant_id, "tenant-123") + mock_cache_set.assert_called_once_with("xero_tenant_id", "tenant-123") + + @patch("apps.workflow.management.commands.xero.get_provider") + @patch("apps.workflow.management.commands.xero.cache.set") + @patch("apps.workflow.management.commands.xero.AccountingApi") + @patch("apps.workflow.management.commands.xero.IdentityApi") + @patch("apps.workflow.management.commands.xero.CompanyDefaults.get_solo") + def test_production_setup_validates_without_creating_xero_items( + self, + mock_get_solo, + mock_identity_api_cls, + mock_accounting_api_cls, + _mock_cache_set, + mock_get_provider, + ): + theme_id = UUID("11111111-2222-3333-4444-555555555555") + company = SimpleNamespace( + xero_tenant_id=None, + xero_payroll_calendar_name="Weekly", + xero_shortcode=None, + xero_sales_branding_theme_id=theme_id, + xero_payroll_calendar_id=None, + save=Mock(), + ) + mock_get_solo.return_value = company + mock_identity_api_cls.return_value.get_connections.return_value = [ + SimpleNamespace(tenant_id="tenant-123", tenant_name="Live Company") + ] + mock_accounting_api_cls.return_value.get_organisations.return_value = ( + SimpleNamespace(organisations=[SimpleNamespace(short_code="LIVE")]) + ) + mock_get_provider.return_value.list_document_themes.return_value = [ + DocumentTheme( + external_id=str(theme_id), + name="Invoices", + is_default=False, + ) + ] + + cmd = Command() + cmd._ensure_demo_xero_items_exist = Mock() + cmd._validate_production_xero_items = Mock() + with patch( + "apps.workflow.management.commands.xero.get_payroll_calendars", + return_value=[{"name": "Weekly", "id": "calendar-live"}], + ): + cmd.run_setup() + + cmd._ensure_demo_xero_items_exist.assert_not_called() + cmd._validate_production_xero_items.assert_called_once_with("Weekly") + self.assertEqual(company.xero_tenant_id, "tenant-123") self.assertEqual( company.save.call_args_list[-1].kwargs["update_fields"], [ @@ -248,6 +300,14 @@ def test_removed_create_missing_xero_items_flag_is_rejected(self): with self.assertRaises(CommandError): parser.parse_args(["--setup", "--create-missing-xero-items"]) + def test_seed_xero_requires_setup(self) -> None: + with patch( + "apps.workflow.management.commands.xero.get_valid_token", + return_value={"access_token": "token"}, + ): + with self.assertRaisesRegex(CommandError, "only valid with --setup"): + Command()._handle(seed_xero=True, setup=False) + class CompanyDefaultsBrandingThemeFixtureTests(TestCase): def test_shared_fixtures_do_not_ship_tenant_specific_theme_ids(self) -> None: diff --git a/docs/client_onboarding.md b/docs/client_onboarding.md index 5ed2eee3c..f1797c252 100644 --- a/docs/client_onboarding.md +++ b/docs/client_onboarding.md @@ -92,10 +92,8 @@ The client needs a Xero subscription. DocketWorks handles jobs and delegates inv **Sales Branding Theme** (Settings > Invoice settings): - Ensure one branding theme contains the terms and conditions required on both quotes and invoices -- Prefer making that theme first in Xero's branding-theme order; DocketWorks - imports the first theme during `xero --setup` -- If another theme must remain the Xero default, select the terms-bearing theme - later in DocketWorks Company Settings +- Select the terms-bearing theme in DocketWorks Company Settings before + production finalisation. Demo seeding may select the first available theme. ### 2b. You create the Xero Developer App @@ -223,10 +221,11 @@ For production, set in the instance `.env`: Follow `uat_setup.md` (Part C) or the production deployment process. ```bash -# UAT -sudo scripts/server/instance.sh prepare-config -sudoedit /opt/docketworks/config/-.credentials.env -sudo scripts/server/instance.sh create +# Production (add --seed to both commands for a demo instance) +sudo scripts/server/instance.sh prepare-config prod +sudoedit /opt/docketworks/config/-prod.credentials.env +sudoedit /opt/docketworks/config/-prod.company-defaults.json +sudo scripts/server/instance.sh create prod --no-start ``` --- @@ -240,15 +239,15 @@ Once the instance is running: 1. Log into the app as admin 2. Admin > Xero > "Login with Xero" 3. Authorize the client's Xero organisation -4. Run: +4. In production, select the required live sales branding theme in Admin > Settings. +5. Run: ```bash - python manage.py xero --setup - python manage.py start_xero_sync + python manage.py finalize_instance_onboarding ``` -`xero --setup` imports the first sales branding theme returned in the connected -organisation's Xero order. It preserves a previously selected theme when that -theme still exists in the connected organisation. +Finalisation discovers the connected tenant, validates production Xero +configuration without creating remote objects, and enables automated sync only +after every onboarding check succeeds. Demo onboarding uses `--seed-xero`. ### 7b. Company Settings diff --git a/docs/instance-setup-demo.md b/docs/instance-setup-demo.md index aa440ae82..959424625 100644 --- a/docs/instance-setup-demo.md +++ b/docs/instance-setup-demo.md @@ -1,138 +1,69 @@ # Instance Setup: Demo -Onboard a prospect for a paid trial of DocketWorks. Uses dummy staff but the prospect's real rates, markups, and configuration. Connects to Xero Demo Company. +Create a non-production demonstration installation with 11 dummy staff and a +dedicated Xero Demo Company connection. -**Prerequisites:** Collect from the prospect before starting: -- Company name and acronym -- Charge-out rate, wage rate, time/materials markups, leave loading -- Working hours pattern -- Financial year start month, starting job/PO numbers, PO prefix - -**Assumes:** Base server setup is complete (`scripts/server/server-setup.sh`). - ---- - -## Step 1: Prepare Credentials - -```bash -sudo scripts/server/instance.sh prepare-config uat -``` - -Edit the root-owned credentials file: +## 1. Prepare persistent instance configuration ```bash +sudo scripts/server/instance.sh prepare-config uat --seed sudoedit /opt/docketworks/config/-uat.credentials.env +sudoedit /opt/docketworks/config/-uat.company-defaults.json ``` -Fill in: -- XERO_DEFAULT_USER_ID — the existing Xero Demo Company login/user ID that will own time entries -- GCP_CREDENTIALS — shared dev service account key -- EMAIL credentials - -XERO_DEFAULT_USER_ID must be present before `instance.sh create` runs. - -Also fill in the Xero Client ID, Client Secret, Webhook Key, and Redirect URI -for the **Xero Demo Company** app. `instance.sh create` uses these values to -render and load the initial XeroApp fixture. +Set `xero_tenant_id` in the company-defaults JSON to the UUID obtained outside +DocketWorks, complete the credentials, and keep `enable_xero_sync` false. This +is offline configuration; no DocketWorks services or OAuth flow are involved. -## Step 2: Create Instance +## 2. Create the instance ```bash -sudo scripts/server/instance.sh create uat --seed +sudo scripts/server/instance.sh create uat --seed --no-start ``` -This loads the demo CompanyDefaults and 11 dummy staff. The initial admin is -created separately by the provisioning script, so it is not part of the demo -fixture. +The command loads the configured demo Company/CompanyDefaults and 11 dummy +staff without starting gunicorn or Celery. Dummy staff initially have no Xero +employee IDs because those IDs belong to a particular Xero tenant. -**Check:** `https://-uat.docketworks.site` shows the login page. +Verify the bootstrap data: -## Step 3: Verify Demo Data - -**Check:** ```bash scripts/server/dw-run.sh -uat python scripts/restore_checks/check_company_defaults.py -``` - -## Step 3.5: Check Xero App Credentials - -```bash scripts/server/dw-run.sh -uat python scripts/restore_checks/check_xero_app.py ``` -## Step 4: Configure Company Settings +## 3. Authorise Xero Demo Company -In Admin > Settings, set the prospect's real values: -- Company name, acronym, address, email, website -- Charge-out rate, wage rate, markups, leave loading -- Working hours (Mon-Fri pattern) -- Financial year start month -- Starting job/PO numbers and PO prefix -- Shop client name +Log in as `defaultadmin@example.com` / `Default-admin-password`, open Admin > +Xero, and complete the existing OAuth flow. -Upload logos: Admin > Settings > Company > Logo and Logo Wide. - -## Step 5: Connect to Xero Demo Company - -1. Log in as admin (`defaultadmin@example.com` / `Default-admin-password`) -2. Admin > Xero > "Login with Xero" -3. Authorize "Demo Company" +## 4. Finalise onboarding ```bash -scripts/server/dw-run.sh -uat python manage.py xero --setup +scripts/server/dw-run.sh -uat python manage.py finalize_instance_onboarding --seed-xero ``` -`xero --setup` stores the first sales branding theme in the Demo Company's Xero -order. In Admin > Settings, change it to a terms-bearing theme if the selected -theme is not the one the prospect should see. +The explicit flag may create missing demo-only payroll objects, including the +configured weekly calendar and required pay items. It then selects a live +branding theme if none is configured, syncs accounts and pay items, links or +creates Xero Payroll employees for all dummy staff, creates the nine canonical +shop jobs, validates the result, and enables automated sync last. -**Note:** `xero --setup` creates the "Weekly Testing" payroll calendar in the Demo Company if it's missing (a weekly calendar anchored to a **Monday** — Docketworks payroll posting requires Mon→Sun periods). If you ever create one by hand instead (Payroll > Settings > Payroll Calendars), its period **must start on a Monday**; `xero --setup` fails loudly if the calendar it just created came back on any other day. +Failures exit non-zero and leave sync disabled. The command is safe to rerun +after correcting the cause. -## Step 6: Sync Xero Data - -```bash -# Chart of accounts -scripts/server/dw-run.sh -uat python manage.py start_xero_sync --entity accounts --force - -# Pay items -scripts/server/dw-run.sh -uat python manage.py xero --configure-payroll -``` - -## Step 7: Create Shop Jobs - -```bash -scripts/server/dw-run.sh -uat python manage.py create_shop_jobs -``` - -Creates: Annual Leave, Sick Leave, Bereavement Leave, Travel, Training, Business Development, Office Admin, Worker Admin, Bench. - -Edit the leave jobs in Admin to set their Xero Pay Item. - -## Step 8: Verify AI Providers - -AI providers are configured during instance creation (`instance.sh create`). Verify: - -```bash -scripts/server/dw-run.sh -uat python scripts/restore_checks/check_ai_providers.py -``` - -## Step 9: Final Sync - -```bash -scripts/server/dw-run.sh -uat python manage.py start_xero_sync -``` +After a monthly Xero Demo Company reset, run `xero --setup --seed-xero`; setup +discovers the replacement tenant and updates CompanyDefaults and the cache. -## Step 10: Verify +## 5. Verify -- [ ] Log in as admin — dashboard loads -- [ ] Staff list shows 11 demo employees -- [ ] Shop jobs visible on Kanban board -- [ ] Admin > Xero shows "Connected" -- [ ] Can create a new job and add time/materials -- [ ] A DocketWorks quote and invoice use the selected Xero branding theme -- [ ] Create a test timesheet entry +- Staff list shows 11 demo employees, all linked to Xero Payroll. +- Exactly nine shop jobs are visible. +- Admin > Xero reports connected. +- A normal Xero sync completes without errors. +- A test job, timesheet, quote, and invoice work as expected. -## Login Credentials +Logins: - Admin: `defaultadmin@example.com` / `Default-admin-password` -- All staff: their email / `Default-staff-password` +- Staff: their fixture email / `Default-staff-password` diff --git a/docs/instance-setup-production.md b/docs/instance-setup-production.md index 12f1f7e64..0e6ea3c24 100644 --- a/docs/instance-setup-production.md +++ b/docs/instance-setup-production.md @@ -1,164 +1,79 @@ # Instance Setup: Production -Set up a production instance for a client connecting to their real Xero organisation. +Set up one client installation against that client's real Xero organisation. +Complete the client-onboarding prerequisites first and ensure the required +payroll calendar, pay items, and invoice branding theme already exist in Xero. +Production onboarding validates those objects; it never creates them. -**Prerequisites:** Complete Phase 1-5 of [client_onboarding.md](client_onboarding.md) first (collect company details, configure Xero, create GCP service account, set up AI providers, configure email). - -**Assumes:** Base server setup is complete (`scripts/server/server-setup.sh`). - ---- - -## Step 1: Prepare Credentials +## 1. Prepare persistent instance configuration ```bash sudo scripts/server/instance.sh prepare-config prod -``` - -Edit the root-owned credentials file: - -```bash sudoedit /opt/docketworks/config/-prod.credentials.env +sudoedit /opt/docketworks/config/-prod.company-defaults.json ``` -Fill in: -- XERO_DEFAULT_USER_ID — the existing Xero login/user ID that will own time entries -- GCP_CREDENTIALS path (from Phase 3a of client_onboarding.md) -- EMAIL_HOST_USER + EMAIL_HOST_PASSWORD +Complete every required secret. In the company-defaults file, replace every +placeholder and set `xero_tenant_id` to the UUID obtained outside DocketWorks, +including the exact name of the existing Xero payroll calendar. Keep +`enable_xero_sync` false. -XERO_DEFAULT_USER_ID must be present before `instance.sh create` runs. +These root-owned files are the durable source for rebuilding and reconfiguring +the instance. `prepare-config` refuses to overwrite either file. -Also fill in the Xero Client ID, Client Secret, Webhook Key, and Redirect URI -from the client's Xero app. `instance.sh create` uses these values to render -and load the initial XeroApp fixture. - -## Step 2: Create Instance +## 2. Create the instance ```bash -sudo scripts/server/instance.sh create prod +sudo scripts/server/instance.sh create prod --no-start ``` -Creates: OS user, database, .env, code clone, frontend build, migrations, admin user, systemd services (gunicorn + celery), nightly backup timer, and nginx config. - -**Check:** `https://-prod.docketworks.site` shows login page. +Creation refuses existing or partial state. It creates the infrastructure, +runs migrations, and loads the configured Company and CompanyDefaults before +creating the admin account. It does not create dummy staff or start application +services. -## Step 2.5: Check Xero App Credentials +Check the app and private Xero app configuration: ```bash +scripts/server/dw-run.sh -prod python scripts/restore_checks/check_company_defaults.py scripts/server/dw-run.sh -prod python scripts/restore_checks/check_xero_app.py ``` -Expected: `XeroApp configured: -prod xero`. - -## Step 3: Connect to Xero - -Log into the app as admin (`defaultadmin@example.com` / `Default-admin-password`). - -Admin > Xero > "Login with Xero" > Authorize the client's Xero organisation. - -**Check:** in **Admin > Xero Apps** the row shows `Authorised: ✓`. -(There's no CLI check for this — `check_xero_app.py` is a pre-OAuth -existence check and doesn't read tokens.) - -## Step 4: Configure Xero - -```bash -scripts/server/dw-run.sh -prod python manage.py xero --setup -``` - -Sets `xero_tenant_id`, `xero_shortcode`, `xero_payroll_calendar_id`, and the -first sales branding theme in the connected organisation's Xero order. A valid -existing custom theme selection is preserved. - -**Requires:** The payroll calendar must already exist in Xero (created during client onboarding Phase 2a). - -## Step 5: Configure Company Settings - -In Admin > Settings, set all values collected in Phase 1 of client_onboarding.md: -- Company name, acronym, address, email, website, phone -- Charge-out rate, wage rate, markups, leave loading -- Working hours (Mon-Fri pattern) -- Financial year start month -- Starting job/PO numbers and PO prefix -- Shop client name (must match the Xero contact from Phase 2a) -- Google Drive folder IDs (Shared Drive, How We Work, SOPs, Reference Library) -- Quote template ID and quotes folder ID (if applicable) -- Xero sales branding theme — select the terms-bearing theme if it is not the - first theme imported by `xero --setup` - -Upload logos: Admin > Settings > Company > Logo and Logo Wide. - -## Step 6: Sync Xero Data - -```bash -# Chart of accounts -scripts/server/dw-run.sh -prod python manage.py start_xero_sync --entity accounts --force - -# Pay items -scripts/server/dw-run.sh -prod python manage.py xero --configure-payroll -``` -**Check:** -```bash -scripts/server/dw-run.sh -prod python scripts/restore_checks/check_xero_accounts.py -``` -Expected: `Total accounts synced: ~60+` +## 3. Start services and authorise Xero -## Step 7: Import Staff from Xero +Log in as `defaultadmin@example.com` / `Default-admin-password`, open Admin > +Xero, and complete the existing OAuth flow. -```bash -# Preview first -scripts/server/dw-run.sh -prod python manage.py xero --import-staff-dry-run - -# Import -scripts/server/dw-run.sh -prod python manage.py xero --import-staff -``` +In Admin > Settings, explicitly select the live Xero sales branding theme that +contains the client's required quote and invoice terms. Production finalisation +does not select the first theme automatically. -This creates Staff records from Xero Payroll employees with wage rates and working hours. All imported staff get `password_needs_reset=True`. - -## Step 8: Create Shop Jobs +## 4. Finalise onboarding ```bash -scripts/server/dw-run.sh -prod python manage.py create_shop_jobs +scripts/server/dw-run.sh -prod python manage.py finalize_instance_onboarding ``` -Creates: Annual Leave, Sick Leave, Bereavement Leave, Travel, Training, Business Development, Office Admin, Worker Admin, Bench. - -Edit the leave jobs in Admin to set their Xero Pay Item (Annual Leave → Annual Leave type, etc.). - -## Step 9: Configure AI Providers - -In Admin > AI Providers, add each provider: -- Provider type, model name, API key -- Mark one as default - -## Step 10: Import Documents (if applicable) - -If SOPs were uploaded to Google Drive (Phase 3d of client_onboarding.md): - -```bash -scripts/server/dw-run.sh -prod python manage.py import_dropbox_hs_documents -``` - -## Step 11: Start Xero Sync - -```bash -scripts/server/dw-run.sh -prod python manage.py start_xero_sync -``` - -**Check:** No errors in output. Xero data appears in the app. - -## Step 12: Verify - -- [ ] Log in as admin — dashboard loads -- [ ] Staff list shows imported employees -- [ ] Shop jobs visible on Kanban board -- [ ] Create a test timesheet entry -- [ ] Admin > Xero shows "Connected" status -- [ ] A quote created from DocketWorks shows the required terms in its Xero PDF -- [ ] An invoice created from DocketWorks shows the required terms in its Xero PDF -- [ ] Password reset email works (test with a staff member) - -## Post-Setup - -- Change admin password from the default -- Have each staff member log in and set their password -- Monitor Xero sync for the first few days +The command is rerunnable. It discovers and stores the connected tenant, then validates the payroll calendar, +pay items, and selected branding theme; stores the tenant, shortcode, theme and +calendar IDs; syncs pay items and accounts; imports active staff from Xero; +creates or updates the nine canonical shop jobs; runs completion checks; and +sets `enable_xero_sync=true` only after every step succeeds. + +Any failure exits non-zero, persists the error, and leaves automated Xero sync +disabled. Fix the source configuration and rerun the same command. + +## 5. Verify and hand over + +- Staff list contains the expected Xero Payroll employees. +- Exactly nine shop jobs are present. +- Admin > Xero reports connected. +- A normal Xero sync completes without errors. +- Test quote and invoice PDFs use the selected terms-bearing theme. +- Password reset email works. +- Change the default admin password and have imported staff reset theirs. + +Use `instance.sh reconfigure prod` only after editing persistent +credentials for an already complete instance. The CompanyDefaults JSON is the +rebuild source; live business settings are subsequently managed in the app. +Reconfigure is not a repair command for partial creation. diff --git a/docs/restore-prod-to-nonprod.md b/docs/restore-prod-to-nonprod.md index bb287b033..481eed40f 100644 --- a/docs/restore-prod-to-nonprod.md +++ b/docs/restore-prod-to-nonprod.md @@ -159,9 +159,10 @@ by the required `xero --setup` step after destination OAuth is connected. #### Load Company Defaults Fixture For demo restores only, this replaces your real company name and logos with the -shipped DocketWorks demo values. Tenant installs should load their -instance-owned `/opt/docketworks/instances//company_defaults.json` copy -instead of the shared repo fixture. +shipped DocketWorks demo values. The durable source for a tenant-specific +server rebuild remains the root-owned +`/opt/docketworks/config/.company-defaults.json`; do not maintain a +second long-lived instance-directory copy. ```bash python manage.py loaddata apps/workflow/fixtures/company_defaults.json @@ -238,12 +239,12 @@ This script automates the Xero OAuth login flow using Playwright. It navigates t #### Configure Xero Connection ```bash -python manage.py xero --setup +python manage.py xero --setup --seed-xero ``` **What this does:** Configures all required Xero settings in CompanyDefaults: -1. Sets `xero_tenant_id` from connected organisation +1. Discovers the first connected organisation and stores its tenant ID 2. Sets `xero_shortcode` for deep linking 3. Preserves a live sales branding theme selection or replaces a restored, cross-tenant ID with the first theme in the destination organisation's Xero @@ -263,7 +264,12 @@ Xero setup complete. **Note:** Requires `xero_payroll_calendar_name` to be set in CompanyDefaults (loaded from fixture in Load Company Defaults). -`--setup` provisions any payroll calendar, earnings rates, or leave types that are present in the restored DB but missing from this Xero org (e.g. a fresh demo org), so the seed step below can match every backup pay item by name. The payroll calendar it creates is a weekly calendar anchored to a **Monday** (payroll posting requires Mon→Sun periods); `--setup` aborts if Xero hands back a calendar starting on any other day. +`--setup --seed-xero` provisions any payroll calendar, earnings rates, or leave types that are present in the restored DB but missing from this Xero org (e.g. a fresh demo org), so the seed step below can match every backup pay item by name. The payroll calendar it creates is a weekly calendar anchored to a **Monday** (payroll posting requires Mon→Sun periods); setup aborts if Xero hands back a calendar starting on any other day. + +Do not run `finalize_instance_onboarding` for a restored production dataset. +Fresh-instance finalisation imports or creates staff before enabling sync; +restore instead uses the lower-level setup, pay-item sync, and +`seed_xero_from_database` sequence below to remap the restored dataset. #### Sync Pay Items from Xero diff --git a/docs/server_setup.md b/docs/server_setup.md index c17bb6df7..5fe79409d 100644 --- a/docs/server_setup.md +++ b/docs/server_setup.md @@ -114,20 +114,21 @@ Certs auto-renew via `certbot renew` using the same Dreamhost DNS hooks. ### Automated (recommended) ```bash -# Step 1: scaffold credentials file -sudo scripts/server/instance.sh prepare-config +# Step 1: scaffold credentials and CompanyDefaults config +sudo scripts/server/instance.sh prepare-config [--seed] -# Step 2: fill in the root-owned credentials +# Step 2: fill in both root-owned configuration files sudoedit /opt/docketworks/config/-.credentials.env +sudoedit /opt/docketworks/config/-.company-defaults.json # Step 3: create the instance -sudo scripts/server/instance.sh create +sudo scripts/server/instance.sh create [--seed] --no-start -# Re-run after root-owned credential/config edits +# Re-run after root-owned credential edits sudo scripts/server/instance.sh reconfigure -# Or create a demo with CompanyDefaults and 11 dummy staff: -sudo scripts/server/instance.sh create --seed +# After deliberately starting services and completing OAuth: +scripts/server/dw-run.sh - python manage.py finalize_instance_onboarding [--seed-xero] ``` ### What instance.sh creates @@ -167,77 +168,35 @@ DROP ROLE dw_test; SQL ``` +### Migrating an instance created before durable CompanyDefaults config + +Before its next `instance.sh reconfigure`, create +`/opt/docketworks/config/.company-defaults.json` from the matching +production or demo template. Replace every placeholder, copy the current +`CompanyDefaults.xero_tenant_id` into it, and keep `enable_xero_sync` false in +that rebuild source. + +This is configuration rollout only. `reconfigure` does not reload the rebuild +source into a running database or run fresh-instance finalisation. + --- ## Part C.1: Post-Create Setup -After `instance.sh create` completes without `--seed`, the instance has -infrastructure but no tenant data. Choose the path that matches your scenario: +After `instance.sh create`, the instance has infrastructure plus its +configured Company and CompanyDefaults. Choose the next data workflow: ### Path A: Backup Restore (e.g. MSM demo) For instances that need production data, follow [restore-prod-to-nonprod.md](restore-prod-to-nonprod.md). -### Path B: Fresh Prospect (new Xero org) - -For a prospect trying DocketWorks with their own Xero: - -1. **Instance created** — admin user auto-created (`defaultadmin@example.com` / `Default-admin-password`) - -2. **Load tenant CompanyDefaults fixture** - - ```bash - # Copy the shared template to tenant-owned config and edit it there. - cp apps/workflow/fixtures/company_defaults_prospect.json \ - /opt/docketworks/instances//company_defaults.json - - # Edit: replace all __PLACEHOLDER__ values with prospect's info - # Key fields: company_name, acronym, address, email, po_prefix, - # xero_payroll_calendar_name (must match their Xero calendar) - - # Load it - scripts/server/dw-run.sh python manage.py loaddata /opt/docketworks/instances//company_defaults.json - ``` - - Do not treat `apps/workflow/fixtures/company_defaults*.json` as tenant - state. They are shared starting templates; the instance-owned copy is the - value that survives reset/rebuild work. +### Path B: Fresh instance -3. **Xero OAuth** — log into `https://.docketworks.site` as admin, go to Admin > Xero Settings, click "Login with Xero" and authorize - -4. **Xero configuration** - - ```bash - scripts/server/dw-run.sh python manage.py xero --setup - scripts/server/dw-run.sh python manage.py xero --configure-payroll - scripts/server/dw-run.sh python manage.py start_xero_sync --entity accounts - ``` - - `xero --setup` stores the first sales branding theme in the connected - organisation's Xero order. In Admin > Settings, select the terms-bearing - theme if the organisation uses a different theme for customer documents. - - Existing connected installations do not need to rerun setup when they - receive the branding-theme migration: the migration selects and stores the - first live Xero theme before services restart. If Xero is unavailable, the - migration fails and the deployment must be retried. - -5. **Import staff from Xero** - - ```bash - # Preview first - scripts/server/dw-run.sh python manage.py xero --import-staff-dry-run - - # Then import - scripts/server/dw-run.sh python manage.py xero --import-staff - ``` - - This pulls employees from Xero Payroll and creates Staff records with their - wage rates and working hours. All imported staff get `password_needs_reset=True`. - -6. **Verify** — log in as admin, check Staff list, mark office staff via admin - UI, then create a quote and invoice and confirm their Xero PDFs contain the - required terms +Complete OAuth and run `finalize_instance_onboarding`. See +[instance-setup-production.md](instance-setup-production.md) or +[instance-setup-demo.md](instance-setup-demo.md). The root-owned +`/opt/docketworks/config/.company-defaults.json` is the durable tenant +configuration; repo fixtures are only templates. --- @@ -334,17 +293,17 @@ curl -s https://.docketworks.site/api/health ```bash # Create test instance -sudo scripts/server/instance.sh prepare-config test uat +sudo scripts/server/instance.sh prepare-config test uat --seed # Fill in credentials... -sudo scripts/server/instance.sh create test uat +sudo scripts/server/instance.sh create test uat --seed # Verify systemctl status gunicorn-test-uat curl https://test-uat.docketworks.site/api/health # Create second instance with seed data -sudo scripts/server/instance.sh prepare-config test2 uat -# Fill in credentials... +sudo scripts/server/instance.sh prepare-config test2 uat --seed +# Fill in both config files... sudo scripts/server/instance.sh create test2 uat --seed # Verify both work independently diff --git a/scripts/server/README.md b/scripts/server/README.md index 2476cd925..c0ac608c3 100644 --- a/scripts/server/README.md +++ b/scripts/server/README.md @@ -56,24 +56,26 @@ This script is host-level only. It does NOT touch existing instances; per-instan Two-step process: ```bash -# Step 1: creates the credentials file from template -sudo ./scripts/server/instance.sh prepare-config mycompany uat +# Step 1: creates durable credentials and CompanyDefaults config +sudo ./scripts/server/instance.sh prepare-config mycompany uat --seed -# Fill out the root-owned credentials file (see "Xero Setup" below) +# Fill out both root-owned files (see "Xero Setup" below) sudoedit /opt/docketworks/config/mycompany-uat.credentials.env +sudoedit /opt/docketworks/config/mycompany-uat.company-defaults.json # Step 2: reads credentials, creates everything -sudo ./scripts/server/instance.sh create mycompany uat +sudo ./scripts/server/instance.sh create mycompany uat --seed --no-start -# Re-run after root-owned credential/config edits +# Re-run after root-owned credential edits sudo ./scripts/server/instance.sh reconfigure mycompany uat ``` -Add `--seed` to load the demo CompanyDefaults and 11 dummy staff. The dummy -staff use the documented `Default-staff-password` login: +The `--seed` flag selects the demo CompanyDefaults template and loads 11 dummy +staff. After deliberately starting the services and completing OAuth, seed the +demo Xero organisation and finish onboarding with: ```bash -sudo ./scripts/server/instance.sh create mycompany uat --seed +scripts/server/dw-run.sh mycompany-uat python manage.py finalize_instance_onboarding --seed-xero ``` After creation, the instance is live at its configured URL. Each instance also gets `backup-db-.timer` enabled for nightly database backups. @@ -163,6 +165,7 @@ Shows each instance's name, status (running/stopped/no service), current release ├── certbot-hooks/ # Dreamhost DNS challenge scripts ├── config/ │ ├── .credentials.env # root-owned operator input (survives destroy) +│ ├── .company-defaults.json # root-owned tenant bootstrap data │ └── rclone/.conf # Per-instance backup upload config └── instances/ └── / # Mutable instance state diff --git a/scripts/server/instance.sh b/scripts/server/instance.sh index 4c72f621c..f1fac70e1 100755 --- a/scripts/server/instance.sh +++ b/scripts/server/instance.sh @@ -2,7 +2,7 @@ set -euo pipefail # Manage docketworks instances. -# Usage: instance.sh prepare-config +# Usage: instance.sh prepare-config [--seed] # instance.sh create [--seed] [--fqdn ] [--no-start] # instance.sh reconfigure [--fqdn ] [--no-start] # instance.sh destroy @@ -74,28 +74,58 @@ parse_client_env() { # ============================================================ do_prepare_config() { parse_client_env "$@" + shift 2 + + local SEED=false + local parsed + if ! parsed=$(getopt -o '' --long seed -n "$(basename "$0") prepare-config" -- "$@"); then + echo "Usage: $(basename "$0") prepare-config [--seed]" >&2 + exit 1 + fi + eval set -- "$parsed" + while true; do + case "$1" in + --seed) SEED=true; shift ;; + --) shift; break ;; + esac + done + if [[ $# -gt 0 ]]; then + echo "ERROR: Unexpected arguments to 'prepare-config': $*" >&2 + exit 1 + fi local CREDS_FILE="$CONFIG_DIR/$INSTANCE.credentials.env" - if [[ -f "$CREDS_FILE" ]]; then - echo "Credentials file already exists at:" + local COMPANY_DEFAULTS_FILE="$CONFIG_DIR/$INSTANCE.company-defaults.json" + if [[ -e "$CREDS_FILE" || -e "$COMPANY_DEFAULTS_FILE" ]]; then + echo "Instance configuration already exists:" echo " $CREDS_FILE" + echo " $COMPANY_DEFAULTS_FILE" echo "" - echo "Edit it directly, or delete it and re-run to start fresh." - exit 0 + echo "Edit the existing files directly. prepare-config never overwrites them." + exit 1 fi ensure_config_dir sed "s|__INSTANCE__|$INSTANCE|g" "$TEMPLATE_DIR/credentials-instance.template" \ > "$CREDS_FILE" + if [[ "$SEED" == "true" ]]; then + cp "$SCRIPT_DIR/../../apps/workflow/fixtures/company_defaults.json" \ + "$COMPANY_DEFAULTS_FILE" + else + cp "$SCRIPT_DIR/../../apps/workflow/fixtures/company_defaults_prospect.json" \ + "$COMPANY_DEFAULTS_FILE" + fi chown root:root "$CREDS_FILE" - chmod 600 "$CREDS_FILE" + chown root:root "$COMPANY_DEFAULTS_FILE" + chmod 600 "$CREDS_FILE" "$COMPANY_DEFAULTS_FILE" echo "" echo "============================================================" - echo " Credentials file created at:" + echo " Instance configuration created at:" echo " $CREDS_FILE" + echo " $COMPANY_DEFAULTS_FILE" echo "" - echo " Fill it out, then run:" + echo " Fill out both files, then run:" echo " sudo $0 create $CLIENT $ENV" echo "" echo " See instructions in the file for Xero app setup." @@ -314,6 +344,43 @@ render_phone_provider_settings_fixture() { # ============================================================ # create / reconfigure # ============================================================ +validate_company_defaults_config() { + local config_file="$1" + + if [[ ! -f "$config_file" ]]; then + echo "ERROR: No company defaults file found at $config_file" >&2 + echo " Run prepare-config first, then complete the generated JSON." >&2 + exit 1 + fi + require_root_owned_credentials_file "$config_file" + python3 -c ' +import json +import pathlib +import sys +from uuid import UUID + +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +records = json.loads(text) +models = [record.get("model") for record in records] +required = {"company.company", "workflow.companydefaults"} +if set(models) != required or len(records) != 2: + raise SystemExit(f"ERROR: {path} must contain exactly one Company and one CompanyDefaults record") +if "__" in text: + raise SystemExit(f"ERROR: {path} still contains unresolved __PLACEHOLDER__ values") +defaults = next(record["fields"] for record in records if record["model"] == "workflow.companydefaults") +tenant_id = defaults.get("xero_tenant_id") +if not isinstance(tenant_id, str) or not tenant_id: + raise SystemExit(f"ERROR: {path} must set workflow.companydefaults.xero_tenant_id") +try: + UUID(tenant_id) +except ValueError as exc: + raise SystemExit(f"ERROR: {path} has an invalid workflow.companydefaults.xero_tenant_id") from exc +if defaults.get("enable_xero_sync") is not False: + raise SystemExit(f"ERROR: {path} must keep enable_xero_sync false until onboarding is finalized") +' "$config_file" +} + do_configure() { local allow_seed="$1" local command_name="$2" @@ -353,7 +420,9 @@ do_configure() { fi local CREDS_FILE="$CONFIG_DIR/$INSTANCE.credentials.env" + local COMPANY_DEFAULTS_FILE="$CONFIG_DIR/$INSTANCE.company-defaults.json" require_instance_credentials "$CREDS_FILE" + validate_company_defaults_config "$COMPANY_DEFAULTS_FILE" local INSTANCE_DIR="$INSTANCES_DIR/$INSTANCE" local INSTANCE_USER @@ -365,21 +434,21 @@ do_configure() { local TEST_DB_NAME="$TEST_DB_USER" local IS_EXISTING=false local NEEDS_APP_BOOTSTRAP=false - if [[ -L "$INSTANCE_DIR/app" || -L "$INSTANCE_DIR/current" || -f "$INSTANCE_DIR/.env" ]]; then - IS_EXISTING=true - fi - if [[ ! -f "$INSTANCE_DIR/.env" ]]; then + if [[ "$command_name" == "create" ]]; then + if [[ -e "$INSTANCE_DIR" ]] || id "$INSTANCE_USER" &>/dev/null || \ + sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1; then + echo "ERROR: Refusing to create over existing or partial instance state for $INSTANCE." >&2 + echo " Use reconfigure for a complete instance, or destroy partial state first." >&2 + exit 1 + fi NEEDS_APP_BOOTSTRAP=true - fi - if [[ -f "$INSTANCE_DIR/.env" && ! -L "$INSTANCE_DIR/app" && ! -L "$INSTANCE_DIR/current" ]]; then - echo "ERROR: $INSTANCE_DIR has config but no app/current release link." >&2 - echo " Restore or recreate the instance instead of reconfiguring partial state." >&2 - exit 1 - fi - if [[ "$IS_EXISTING" == "true" && "$SEED" == "true" ]]; then - echo "ERROR: --seed is only valid when creating a new instance." >&2 - echo " Existing instance: $INSTANCE_DIR" >&2 - exit 1 + else + IS_EXISTING=true + if [[ ! -f "$INSTANCE_DIR/.env" || ( ! -L "$INSTANCE_DIR/app" && ! -L "$INSTANCE_DIR/current" ) ]]; then + echo "ERROR: Cannot reconfigure incomplete instance $INSTANCE_DIR." >&2 + echo " Use create for a new instance, or destroy partial state first." >&2 + exit 1 + fi fi log "==========================================" @@ -541,6 +610,25 @@ EOSQL "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python manage.py migrate --no-input fi + if [[ "$NEEDS_APP_BOOTSTRAP" == "true" ]]; then + log "Loading instance Company and CompanyDefaults..." + local COMPANY_DEFAULTS_FIXTURE="$INSTANCE_DIR/.fixtures/company_defaults.json" + mkdir -p "$INSTANCE_DIR/.fixtures" + cp "$COMPANY_DEFAULTS_FILE" "$COMPANY_DEFAULTS_FIXTURE" + chown -R "$INSTANCE_USER:$INSTANCE_USER" "$INSTANCE_DIR/.fixtures" + chmod 700 "$INSTANCE_DIR/.fixtures" + chmod 600 "$COMPANY_DEFAULTS_FIXTURE" + "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python manage.py loaddata \ + "$COMPANY_DEFAULTS_FIXTURE" + rm -f "$COMPANY_DEFAULTS_FIXTURE" + + if [[ "$SEED" == "true" ]]; then + log "Loading demo staff fixture..." + "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python manage.py loaddata \ + apps/workflow/fixtures/initial_data.json + fi + fi + render_ai_providers_fixture "$INSTANCE_DIR" "$INSTANCE_USER" log "Loading AI providers..." local AI_PROVIDERS_FIXTURE="$INSTANCE_DIR/.fixtures/ai_providers.json" @@ -569,12 +657,6 @@ EOSQL # (see docs/restore-prod-to-nonprod.md), never instance creation. "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python scripts/setup_dev_logins.py --admin-only - if [[ "$SEED" == "true" ]]; then - log "Loading demo fixtures..." - "$SCRIPT_DIR/dw-run.sh" "$INSTANCE" python manage.py loaddata \ - apps/workflow/fixtures/company_defaults.json \ - apps/workflow/fixtures/initial_data.json - fi fi if [[ "$NO_START" == "true" ]]; then @@ -878,7 +960,7 @@ do_list() { # ============================================================ if [[ $# -lt 1 ]]; then echo "Usage: $0 {prepare-config|create|reconfigure|destroy|list} [args...]" - echo " prepare-config — scaffold credentials file" + echo " prepare-config [--seed]" echo " create [--seed] [--fqdn ] [--no-start]" echo " reconfigure [--fqdn ] [--no-start]" echo " destroy " diff --git a/scripts/server/templates/credentials-instance.template b/scripts/server/templates/credentials-instance.template index eb397ea50..8db1bf90b 100644 --- a/scripts/server/templates/credentials-instance.template +++ b/scripts/server/templates/credentials-instance.template @@ -1,5 +1,5 @@ # Instance configuration — fill this out before running 'instance.sh create' -# + # ─── XERO INTEGRATION ─────────────────────────────────────────────────────────── # The business already has a Xero organisation. We create a Xero *app* # (an API connection to the existing org), not a new Xero account. @@ -15,6 +15,7 @@ # - Copy the "Webhook signing key" into XERO_WEBHOOK_KEY below. # 4. XERO_DEFAULT_USER_ID — the Xero login/user ID that will own time # entries. This must exist before instance.sh create runs. +# XERO_DEFAULT_USER_ID must be present for instance creation. # Use the userId from the Xero Projects users API: # GET /projects.xro/2.0/projectsusers # (Temporary workaround — will be replaced by per-staff mapping.) From 8af74f298a2675cdc568f5ebbf9717e0a3ac5106 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Sun, 19 Jul 2026 09:40:02 +1200 Subject: [PATCH 04/66] chore: remove unnecessary lint suppressions Suppression comments had accumulated as a way to make checks pass rather than as deliberate, justified overrides. Audit found 61; this removes 52, leaving 9 that are genuinely unavoidable. Config, not inline comments: - Delete .flake8, which competed with pyproject.toml's [tool.flake8] and disagreed with it. Every `# noqa: E402` in scripts/ (15) was dead under both configs. Drop the stale test_gzip.py per-file-ignore. - Add an eslint override for the vendored shadcn-vue components instead of repeating `eslint-disable vue/multi-word-component-names` in 23 files. - Configure no-unused-vars with ignoreRestSiblings/^_ patterns rather than suppressing deliberate destructuring-omits case by case. - Enable reportUnusedDisableDirectives so dead directives can't reaccumulate. Delete the xero/sync.py re-export shim: the autogenerated api/xero/__init__.py already re-exports every name from its defining module, so the `# noqa: F401` block was redundant. Consumers now import from push/seed/transforms directly (ADR 0017 - no aliases kept for safety). Replace `no-explicit-any` suppressions with real types. Two were hiding live unsoundness: updateField/updateTaskField took `keyof T` but assigned a string, so `updateField('tasks', 'oops')` type-checked. archived-jobs.vue discarded a fully-typed generated response for hand-copied annotations that had already drifted from the schema; restoring the real type proved several guards dead. quote_spreadsheet.py's blanket `# flake8: noqa` + `# pylint: skip-file` suppressed nothing - the file is clean under both configs. Also fixes pre-existing gate failures on this branch: check_mypy.sh was already red at HEAD with 12 unbaselined errors from the demo-seeding commits. Types them properly rather than baselining, shrinking mypy-baseline.txt by 37. Removes shadow defaults in the xero command, where `options.get(key, default)` duplicated defaults argparse already guarantees. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RrwgHpia3Cbs4YBWPd7ggJ --- .flake8 | 14 ---- apps/accounts/apps.py | 4 +- apps/job/importers/quote_spreadsheet.py | 4 +- apps/workflow/api/xero/sync.py | 26 +----- .../commands/finalize_instance_onboarding.py | 4 +- apps/workflow/management/commands/xero.py | 37 ++++----- apps/workflow/models/company_defaults.py | 27 ++++-- apps/workflow/tasks.py | 2 +- .../tests/test_localdate_regression.py | 5 +- apps/workflow/tests/test_sync_clients.py | 6 +- apps/workflow/tests/test_xero_app_active.py | 2 +- .../workflow/tests/test_xero_setup_command.py | 28 ++++--- docketworks/settings_test.py | 3 + frontend/eslint.config.ts | 29 +++++++ frontend/src/components/DataTable.vue | 6 +- .../safety-wizard/SafetyWizardModal.vue | 49 ++++++----- .../components/shared/SmartCostLinesTable.vue | 21 ++--- frontend/src/components/ui/alert/Alert.vue | 1 - frontend/src/components/ui/avatar/Avatar.vue | 1 - frontend/src/components/ui/badge/Badge.vue | 1 - frontend/src/components/ui/button/Button.vue | 1 - .../src/components/ui/calendar/Calendar.vue | 1 - frontend/src/components/ui/card/Card.vue | 1 - .../src/components/ui/checkbox/Checkbox.vue | 1 - .../components/ui/collapsible/Collapsible.vue | 1 - frontend/src/components/ui/dialog/Dialog.vue | 1 - frontend/src/components/ui/drawer/Drawer.vue | 1 - frontend/src/components/ui/input/Input.vue | 1 - frontend/src/components/ui/label/Label.vue | 1 - .../components/ui/pagination/Pagination.vue | 1 - .../src/components/ui/popover/Popover.vue | 1 - .../src/components/ui/progress/Progress.vue | 1 - frontend/src/components/ui/select/Select.vue | 1 - .../src/components/ui/skeleton/Skeleton.vue | 1 - frontend/src/components/ui/sonner/Sonner.vue | 1 - frontend/src/components/ui/switch/Switch.vue | 1 - frontend/src/components/ui/table/Table.vue | 1 - frontend/src/components/ui/table/utils.ts | 8 +- frontend/src/components/ui/tabs/Tabs.vue | 1 - .../src/components/ui/textarea/Textarea.vue | 1 - .../src/components/ui/tooltip/Tooltip.vue | 1 - frontend/src/pages/kanban.vue | 1 - .../reports/data-quality/archived-jobs.vue | 83 +++++++------------ .../staff-performance-report.service.ts | 3 +- mypy-baseline.txt | 37 --------- pyproject.toml | 3 +- scripts/migrate_to_snapshot.py | 6 +- scripts/payroll_reconciliation.py | 8 +- scripts/push_companies_to_xero.py | 2 +- scripts/regen_golden_pdfs.py | 16 ++-- 50 files changed, 195 insertions(+), 262 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 209834e20..000000000 --- a/.flake8 +++ /dev/null @@ -1,14 +0,0 @@ -[flake8] -max-line-length = 88 -exclude = - */migrations/*, - scripts/*, - adhoc/*, - node_modules/*, - .venv/*, - venv/*, - .tox/*, - __pycache__/*, - mediafiles/*, - .git/*, - *.json, diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py index a988be53e..e8b3a07d3 100644 --- a/apps/accounts/apps.py +++ b/apps/accounts/apps.py @@ -7,5 +7,7 @@ class AccountsConfig(AppConfig): verbose_name = "User Accounts" def ready(self) -> None: - # Import here to avoid AppRegistryNotReady during Django startup + # Imported for its import-time side effects only, so the name is + # deliberately unused (F401). Deferred to ready() because importing it + # at module level raises AppRegistryNotReady during Django startup. import apps.workflow.extensions # noqa: F401 diff --git a/apps/job/importers/quote_spreadsheet.py b/apps/job/importers/quote_spreadsheet.py index 25f5c8c4b..f180bdc34 100644 --- a/apps/job/importers/quote_spreadsheet.py +++ b/apps/job/importers/quote_spreadsheet.py @@ -1,6 +1,4 @@ -# flake8: noqa -# pylint: skip-file -# This entire file is AI slop and will be rewritten - no point fixing linting issues +# This entire file is AI slop and is slated for a rewrite. import logging from dataclasses import dataclass diff --git a/apps/workflow/api/xero/sync.py b/apps/workflow/api/xero/sync.py index d614c8ad2..c34f4d1e5 100644 --- a/apps/workflow/api/xero/sync.py +++ b/apps/workflow/api/xero/sync.py @@ -19,29 +19,9 @@ get_all_pay_slips_for_sync, get_pay_runs_for_sync, ) -from apps.workflow.api.xero.push import ( # noqa: F401 - bulk_create_contacts_in_xero, - create_company_contact_in_xero, - get_all_xero_contacts, - map_costline_to_expense_entry, - map_costline_to_time_entry, - sync_company_to_xero, - sync_costlines_to_xero, - sync_expense_entries_bulk, - sync_job_to_xero, - sync_time_entries_bulk, -) -from apps.workflow.api.xero.seed import ( # noqa: F401 - seed_companies_to_xero, - seed_jobs_to_xero, - sync_single_contact, - sync_single_invoice, - sync_single_pay_run, -) -from apps.workflow.api.xero.transforms import process_xero_data # noqa: F401 -from apps.workflow.api.xero.transforms import sync_companies # noqa: F401 from apps.workflow.api.xero.transforms import ( sync_accounts, + sync_companies, sync_entities, transform_bill, transform_credit_note, @@ -580,7 +560,9 @@ def sync_local_stock_to_xero(): } -def one_way_sync_all_xero_data(entities=None, force=False): +def one_way_sync_all_xero_data( + entities: Sequence[str] | None = None, force: bool = False +) -> Iterator[XeroSyncEvent]: """Normal sync using latest timestamps""" yield from sync_all_xero_data( use_latest_timestamps=True, entities=entities, force=force diff --git a/apps/workflow/management/commands/finalize_instance_onboarding.py b/apps/workflow/management/commands/finalize_instance_onboarding.py index 66d10aba2..bb2a9d393 100644 --- a/apps/workflow/management/commands/finalize_instance_onboarding.py +++ b/apps/workflow/management/commands/finalize_instance_onboarding.py @@ -1,6 +1,6 @@ """Management command for the post-OAuth instance onboarding workflow.""" -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.models import CompanyDefaults @@ -11,7 +11,7 @@ class Command(BaseCommand): help = "Finalize Xero onboarding and enable synchronization for a new instance" - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--seed-xero", action="store_true", diff --git a/apps/workflow/management/commands/xero.py b/apps/workflow/management/commands/xero.py index e3f91655c..5445c2fc1 100644 --- a/apps/workflow/management/commands/xero.py +++ b/apps/workflow/management/commands/xero.py @@ -203,7 +203,7 @@ def handle(self, *args, **options): err = persist_app_error(exc) raise AlreadyLoggedException(exc, err.id) from exc - def _handle(self, *args, **options): + def _handle(self, *args: object, **options: object) -> None: # First check we have a valid token token = get_valid_token() if not token: @@ -217,7 +217,7 @@ def _handle(self, *args, **options): raise CommandError("--seed-xero is only valid with --setup.") if options["setup"]: - self.run_setup(seed_xero=options["seed_xero"]) + self.run_setup(seed_xero=bool(options["seed_xero"])) return if options["users"]: @@ -225,7 +225,7 @@ def _handle(self, *args, **options): return if options["payroll_employees"]: - self.get_payroll_employees(use_raw_api=options.get("raw_api", False)) + self.get_payroll_employees(use_raw_api=bool(options["raw_api"])) return if options["payroll_rates"]: @@ -248,8 +248,8 @@ def _handle(self, *args, **options): self.configure_payroll() return - import_staff_requested = options["import_staff"] or options.get( - "import_staff_dry_run" + import_staff_requested = ( + options["import_staff"] or options["import_staff_dry_run"] ) if import_staff_requested: self.import_staff(options) @@ -257,8 +257,8 @@ def _handle(self, *args, **options): link_staff_requested = ( options["link_staff"] - or bool(options.get("link_staff_dry_run")) - or bool(options.get("link_staff_emails")) + or bool(options["link_staff_dry_run"]) + or bool(options["link_staff_emails"]) ) if link_staff_requested: @@ -267,8 +267,8 @@ def _handle(self, *args, **options): create_staff_requested = ( options["create_staff"] - or bool(options.get("create_staff_dry_run")) - or bool(options.get("create_staff_emails")) + or bool(options["create_staff_dry_run"]) + or bool(options["create_staff_emails"]) ) if create_staff_requested: @@ -854,8 +854,8 @@ def link_staff(self, options): Includes all staff (active and inactive) so that historical timesheets can be posted for departed employees. """ - emails_option = options.get("link_staff_emails") - dry_run = options.get("link_staff_dry_run", False) + emails_option = options["link_staff_emails"] + dry_run = options["link_staff_dry_run"] # Only include staff with wage rates (excludes admin-only users) queryset = Staff.objects.filter(wage_rate__gt=0) @@ -903,8 +903,8 @@ def link_staff(self, options): def create_staff(self, options): """Create Xero Payroll employees for specified staff members.""" - emails_option = options.get("create_staff_emails") - dry_run = options.get("create_staff_dry_run", False) + emails_option = options["create_staff_emails"] + dry_run = options["create_staff_dry_run"] if not emails_option: self.stdout.write( @@ -1083,13 +1083,12 @@ def _prompt_for_leave_type_name(self, label, leave_types, current_value): return type_name - def import_staff(self, options): + def import_staff(self, options: dict[str, object]) -> None: """Import employees from Xero Payroll as local Staff records.""" - dry_run = options.get("import_staff_dry_run", False) - force = options.get("force", False) - initial_password = options.get( - "import_staff_password", "Default-staff-password" - ) + # Defaults live in add_arguments(); argparse always populates these keys. + dry_run = bool(options["import_staff_dry_run"]) + force = bool(options["force"]) + initial_password = str(options["import_staff_password"]) # Guard against double-import existing_staff = Staff.objects.filter(base_wage_rate__gt=0).count() diff --git a/apps/workflow/models/company_defaults.py b/apps/workflow/models/company_defaults.py index 6b03ba5d5..8dc75f8a1 100644 --- a/apps/workflow/models/company_defaults.py +++ b/apps/workflow/models/company_defaults.py @@ -1,6 +1,8 @@ +from collections.abc import Iterable from decimal import Decimal from django.db import models +from django.db.models.base import ModelBase from solo.models import SingletonModel @@ -344,7 +346,17 @@ class Meta: ), ] - def save(self, *args, **kwargs): + # Variadic to stay substitutable for SingletonModel.save, which is variadic; + # a fixed signature here trips pylint's arguments-differ. + def save( + self, + *args: object, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + **kwargs: object, + ) -> None: # Check if annual_leave_loading changed - if so, recompute all staff wage_rates loading_changed = False if self.pk: @@ -354,14 +366,19 @@ def save(self, *args, **kwargs): except CompanyDefaults.DoesNotExist: pass - result = super().save(*args, **kwargs) + super().save( + *args, + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + **kwargs, + ) if loading_changed: self._recompute_all_staff_wage_rates() - return result - - def _recompute_all_staff_wage_rates(self): + def _recompute_all_staff_wage_rates(self) -> None: """Bulk-recompute wage_rate for all staff based on current annual_leave_loading.""" from apps.accounts.models import Staff diff --git a/apps/workflow/tasks.py b/apps/workflow/tasks.py index d325869d8..9efa2b120 100644 --- a/apps/workflow/tasks.py +++ b/apps/workflow/tasks.py @@ -13,7 +13,7 @@ from django.db import close_old_connections from apps.workflow.api.xero.client import quota_floor_breached -from apps.workflow.api.xero.sync import sync_single_contact, sync_single_invoice +from apps.workflow.api.xero.seed import sync_single_contact, sync_single_invoice from apps.workflow.exceptions import ( AlreadyLoggedException, ) diff --git a/apps/workflow/tests/test_localdate_regression.py b/apps/workflow/tests/test_localdate_regression.py index 73670ce23..c19e60446 100644 --- a/apps/workflow/tests/test_localdate_regression.py +++ b/apps/workflow/tests/test_localdate_regression.py @@ -45,8 +45,9 @@ class FreezeTimeSanityTests(TestCase): def test_utc_and_nz_disagree_on_the_chosen_moment(self): with freeze_time(FROZEN_UTC_MOMENT): - # noqa: localdate intentional — this assertion exists to prove the - # UTC/NZ-disagree premise that every other test in this file relies on. + # This assertion exists to prove the UTC/NZ-disagree premise that + # every other test in this file relies on. The suppression that + # actually silences the checker is on the assertEqual line below. self.assertEqual( timezone.now().date(), UTC_DATE ) # noqa: localdate test fixture asserts UTC date deliberately diff --git a/apps/workflow/tests/test_sync_clients.py b/apps/workflow/tests/test_sync_clients.py index c223ca7d9..d25380862 100644 --- a/apps/workflow/tests/test_sync_clients.py +++ b/apps/workflow/tests/test_sync_clients.py @@ -195,7 +195,7 @@ def test_archived_contact_creates_separate_record( merged_to=self.active_xero_id, ) - from apps.workflow.api.xero.sync import sync_companies + from apps.workflow.api.xero.transforms import sync_companies result = sync_companies([archived_contact]) @@ -228,7 +228,7 @@ def test_active_contact_name_collision_still_raises( status="ACTIVE", ) - from apps.workflow.api.xero.sync import sync_companies + from apps.workflow.api.xero.transforms import sync_companies with self.assertRaises(ValueError) as ctx: sync_companies([conflicting_contact]) @@ -251,7 +251,7 @@ def test_archived_contact_with_existing_xero_id_updates_in_place( status="ARCHIVED", ) - from apps.workflow.api.xero.sync import sync_companies + from apps.workflow.api.xero.transforms import sync_companies result = sync_companies([same_id_contact]) diff --git a/apps/workflow/tests/test_xero_app_active.py b/apps/workflow/tests/test_xero_app_active.py index e0a030118..ba2ec248a 100644 --- a/apps/workflow/tests/test_xero_app_active.py +++ b/apps/workflow/tests/test_xero_app_active.py @@ -83,7 +83,7 @@ def test_swap_invalidates_tenant_id_cache(self) -> None: from apps.workflow.api.xero.active_app import swap_active from apps.workflow.api.xero.constants import TENANT_ID_CACHE_KEY - a = _row(client_id="a1", is_active=True) # noqa: F841 + _row(client_id="a1", is_active=True) b = _row(client_id="b1", is_active=False) cache.set(TENANT_ID_CACHE_KEY, "tenant-from-a") with patch("apps.workflow.api.xero.active_app._restart_sibling_workers"): diff --git a/apps/workflow/tests/test_xero_setup_command.py b/apps/workflow/tests/test_xero_setup_command.py index a09a2fc0d..a33ff0cd4 100644 --- a/apps/workflow/tests/test_xero_setup_command.py +++ b/apps/workflow/tests/test_xero_setup_command.py @@ -244,12 +244,12 @@ def test_run_setup_always_calls_demo_item_provisioning( @patch("apps.workflow.management.commands.xero.CompanyDefaults.get_solo") def test_production_setup_validates_without_creating_xero_items( self, - mock_get_solo, - mock_identity_api_cls, - mock_accounting_api_cls, - _mock_cache_set, - mock_get_provider, - ): + mock_get_solo: Mock, + mock_identity_api_cls: Mock, + mock_accounting_api_cls: Mock, + _mock_cache_set: Mock, + mock_get_provider: Mock, + ) -> None: theme_id = UUID("11111111-2222-3333-4444-555555555555") company = SimpleNamespace( xero_tenant_id=None, @@ -275,16 +275,18 @@ def test_production_setup_validates_without_creating_xero_items( ] cmd = Command() - cmd._ensure_demo_xero_items_exist = Mock() - cmd._validate_production_xero_items = Mock() - with patch( - "apps.workflow.management.commands.xero.get_payroll_calendars", - return_value=[{"name": "Weekly", "id": "calendar-live"}], + with ( + patch.object(cmd, "_ensure_demo_xero_items_exist") as mock_ensure_demo, + patch.object(cmd, "_validate_production_xero_items") as mock_validate_prod, + patch( + "apps.workflow.management.commands.xero.get_payroll_calendars", + return_value=[{"name": "Weekly", "id": "calendar-live"}], + ), ): cmd.run_setup() - cmd._ensure_demo_xero_items_exist.assert_not_called() - cmd._validate_production_xero_items.assert_called_once_with("Weekly") + mock_ensure_demo.assert_not_called() + mock_validate_prod.assert_called_once_with("Weekly") self.assertEqual(company.xero_tenant_id, "tenant-123") self.assertEqual( company.save.call_args_list[-1].kwargs["update_fields"], diff --git a/docketworks/settings_test.py b/docketworks/settings_test.py index 50fada16d..7089a4880 100644 --- a/docketworks/settings_test.py +++ b/docketworks/settings_test.py @@ -9,6 +9,9 @@ import os +# Django settings modules are consumed by attribute lookup, so the star-import +# is the mechanism, not a shortcut: every base setting must land in this +# module's namespace before the overrides below narrow the DB credentials. from .settings import * # noqa: F401, F403 from .settings import DATABASES diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 7d218211b..55217bc35 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -4,6 +4,12 @@ import pluginVue from 'eslint-plugin-vue' import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' export default defineConfigWithVueTs( + { + linterOptions: { + reportUnusedDisableDirectives: 'error', + }, + }, + { name: 'app/files-to-lint', files: ['**/*.{ts,mts,tsx,vue}'], @@ -34,6 +40,21 @@ export default defineConfigWithVueTs( vueTsConfigs.recommended, skipFormatting, + { + name: 'app/rules', + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + ignoreRestSiblings: true, + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + { name: 'app/pages-routing', files: ['src/pages/**/*.vue'], @@ -41,4 +62,12 @@ export default defineConfigWithVueTs( 'vue/multi-word-component-names': 'off', }, }, + + { + name: 'app/vendored-ui', + files: ['src/components/ui/**/*.vue'], + rules: { + 'vue/multi-word-component-names': 'off', + }, + }, ) diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue index 4e4da09f5..a6e1491ef 100644 --- a/frontend/src/components/DataTable.vue +++ b/frontend/src/components/DataTable.vue @@ -1,4 +1,4 @@ - diff --git a/frontend/src/config/adminPages.ts b/frontend/src/config/adminPages.ts index 83a4de90b..4b370d91a 100644 --- a/frontend/src/config/adminPages.ts +++ b/frontend/src/config/adminPages.ts @@ -7,6 +7,7 @@ import { Bot, Brain, ExternalLink, + GraduationCap, KeyRound, MonitorPlay, Wrench, @@ -77,6 +78,13 @@ const adminPagesConfig = [ icon: Brain, view: 'AdminAIProvidersView', }, + { + key: 'notebooklm-links', + label: 'NotebookLM Links', + title: 'NotebookLM Links', + icon: GraduationCap, + view: 'AdminNotebookLmLinksView', + }, { key: 'xero-apps', label: 'Xero Apps', diff --git a/frontend/src/services/notebookLmLinkService.ts b/frontend/src/services/notebookLmLinkService.ts new file mode 100644 index 000000000..92530489a --- /dev/null +++ b/frontend/src/services/notebookLmLinkService.ts @@ -0,0 +1,74 @@ +import { schemas } from '@/api/generated/api' +import { api } from '@/api/client' +import { debugLog } from '@/utils/debug' +import { z } from 'zod' + +export type NotebookLmLink = z.infer +export type NotebookLmLinkCreateUpdate = z.infer + +export class NotebookLmLinkService { + private static instance: NotebookLmLinkService + + public static getInstance(): NotebookLmLinkService { + if (!NotebookLmLinkService.instance) { + NotebookLmLinkService.instance = new NotebookLmLinkService() + } + return NotebookLmLinkService.instance + } + + private constructor() {} + + async getLinks(): Promise { + try { + return await api.workflow_notebook_lm_links_list() + } catch (error) { + debugLog('Failed to fetch NotebookLM links:', error) + throw error + } + } + + async createLink(linkData: NotebookLmLinkCreateUpdate): Promise { + try { + const created = await api.workflow_notebook_lm_links_create(linkData) + return schemas.NotebookLmLink.parse(created) + } catch (error) { + debugLog('Failed to create NotebookLM link:', error) + throw error + } + } + + async updateLink( + id: number, + linkData: Partial, + ): Promise { + try { + const updated = await api.workflow_notebook_lm_links_partial_update(linkData, { + params: { id }, + }) + return schemas.NotebookLmLink.parse(updated) + } catch (error) { + debugLog(`Failed to update NotebookLM link ${id}:`, error) + throw error + } + } + + async deleteLink(id: number): Promise { + try { + await api.workflow_notebook_lm_links_destroy(undefined, { params: { id } }) + } catch (error) { + debugLog(`Failed to delete NotebookLM link ${id}:`, error) + throw error + } + } + + async getLink(id: number): Promise { + try { + return await api.workflow_notebook_lm_links_retrieve({ params: { id } }) + } catch (error) { + debugLog(`Failed to get NotebookLM link ${id}:`, error) + throw error + } + } +} + +export const notebookLmLinkService = NotebookLmLinkService.getInstance() diff --git a/frontend/src/stores/notebookLmLinks.ts b/frontend/src/stores/notebookLmLinks.ts new file mode 100644 index 000000000..aba10c53e --- /dev/null +++ b/frontend/src/stores/notebookLmLinks.ts @@ -0,0 +1,37 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/api/client' +import { schemas } from '@/api/generated/api' +import type { z } from 'zod' + +type NotebookLmLink = z.infer + +export const useNotebookLmLinksStore = defineStore('notebookLmLinks', () => { + const links = ref([]) + const isLoaded = ref(false) + const isLoading = ref(false) + const error = ref(null) + + async function loadLinks() { + isLoading.value = true + error.value = null + try { + // The `menu` action returns the enabled links the current user may see; + // restriction filtering happens server-side. + links.value = await api.workflow_notebook_lm_links_menu_list() + isLoaded.value = true + } catch (e) { + error.value = (e as Error)?.message || 'Failed to load NotebookLM links' + } finally { + isLoading.value = false + } + } + + return { + links, + isLoaded, + isLoading, + error, + loadLinks, + } +}) diff --git a/frontend/src/views/AdminNotebookLmLinksView.vue b/frontend/src/views/AdminNotebookLmLinksView.vue new file mode 100644 index 000000000..04318b199 --- /dev/null +++ b/frontend/src/views/AdminNotebookLmLinksView.vue @@ -0,0 +1,214 @@ + + + From a17e8a7e4118d27e4fa0d0ae4f19b03f067020cd Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Thu, 23 Jul 2026 13:55:51 +1200 Subject: [PATCH 41/66] chore: salvage Google Docs read/write tooling to scripts/ Rebuild the manual-maintenance capability deleted with the KAN-294 migration scratch (only the screenshot pusher had been salvaged): - read_google_doc.py: export a Google Doc as Markdown. - write_google_doc.py: import Markdown as a Doc with a revisionId safety net that refuses to overwrite a human-edited doc (seed/import/trash/status); per-instance manifest is gitignored runtime state. Both use the app auth convention (GCP_CREDENTIALS + CompanyDefaults.company_email, GCP_DELEGATED_SUBJECT override), matching explore_google_drive.py. Supports the Operations Manual cleanup (KAN-302). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DDssHZ17GwD2wtPyac7tEm --- .gitignore | 3 + scripts/README.md | 2 + scripts/read_google_doc.py | 60 +++++++++ scripts/write_google_doc.py | 242 ++++++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 scripts/read_google_doc.py create mode 100644 scripts/write_google_doc.py diff --git a/.gitignore b/.gitignore index 0d5c84a69..e428146f0 100644 --- a/.gitignore +++ b/.gitignore @@ -275,3 +275,6 @@ session-replays/ docs/.codesight/ frontend/.codesight/ *.tsv + +# Local state for scripts/write_google_doc.py (per-instance runtime data) +scripts/google_doc_manifest.json diff --git a/scripts/README.md b/scripts/README.md index 1e28605b9..d63d67383 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,6 +20,8 @@ Run manually for periodic code quality analysis: Require `GCP_CREDENTIALS` env var pointing to a service account JSON file: - **`explore_google_drive.py`** — Browse the MSM Google Drive layout (Shared Drives included). No args lists Shared Drives; pass a driveId to walk its tree. +- **`read_google_doc.py`** — Print a Google Doc's content as Markdown. Args: ``. +- **`write_google_doc.py`** — Write/replace a Google Doc from Markdown, with a revisionId safety net that refuses to overwrite a doc a human has edited. Subcommands: `import `, `seed <doc_id>`, `trash <doc_id>`, `status`. - **`set_doc_screenshot.py`** — Push a captured PNG into a Google Doc at its `{{screenshot:<id>}}` marker (the push half of the screenshot pipeline; capture half is `frontend/scripts/capture-screenshots.ts`). Args: `<doc_id> <screenshot_id> <png_path>`. - **`create_master_template.py`** — Create/manage Google Sheets quote templates - **`get_gapi_token.py`** — Print a Google API access token for debugging diff --git a/scripts/read_google_doc.py b/scripts/read_google_doc.py new file mode 100644 index 000000000..62a8b16b1 --- /dev/null +++ b/scripts/read_google_doc.py @@ -0,0 +1,60 @@ +"""Print a Google Doc's text (exported as Markdown) via the service account. + +Read companion to explore_google_drive.py — that lists the Drive tree, this +reads a document's content. Same delegated auth (GCP_CREDENTIALS + +CompanyDefaults.company_email, GCP_DELEGATED_SUBJECT override). + +Usage: + GCP_CREDENTIALS=<key.json> python scripts/read_google_doc.py <doc_id> +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from google.oauth2 import service_account +from googleapiclient.discovery import build + +from apps.workflow.models import CompanyDefaults + +SCOPES = [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/documents", +] + + +def build_drive(): + key_file = os.getenv("GCP_CREDENTIALS") + if not key_file: + raise RuntimeError("GCP_CREDENTIALS environment variable not set") + if not os.path.exists(key_file): + raise RuntimeError(f"Google service account key file not found: {key_file}") + subject = ( + os.getenv("GCP_DELEGATED_SUBJECT") or CompanyDefaults.get_solo().company_email + ) + if not subject: + raise RuntimeError( + "No impersonation subject: set GCP_DELEGATED_SUBJECT or populate " + "CompanyDefaults.company_email in Settings." + ) + creds = service_account.Credentials.from_service_account_file( + key_file, scopes=SCOPES + ).with_subject(subject) + return build("drive", "v3", credentials=creds) + + +def read_doc(doc_id: str) -> str: + data = ( + build_drive().files().export(fileId=doc_id, mimeType="text/markdown").execute() + ) + return data.decode("utf-8") if isinstance(data, bytes) else str(data) + + +if __name__ == "__main__": + print(read_doc(sys.argv[1])) diff --git a/scripts/write_google_doc.py b/scripts/write_google_doc.py new file mode 100644 index 000000000..a87324769 --- /dev/null +++ b/scripts/write_google_doc.py @@ -0,0 +1,242 @@ +"""Write/replace a Google Doc from Markdown, WITH an overwrite safety net. + +Write companion to read_google_doc.py. Imports a Markdown file as a Google Doc +(headings, bold, lists, tables and {{screenshot:id}} markers survive), and will +only ever replace or trash a doc that: + (a) this tool created or was told to manage (recorded in the manifest), AND + (b) has NOT been edited since this tool last wrote it + (current Docs content revisionId == the revisionId recorded after our write). + +The signal is the Docs content revisionId (edit history), NOT modifiedTime: +revisionId changes only on a real content edit, so it ignores the async metadata +mtime bump Drive applies after an import. lastModifyingUser is useless here — the +service account writes by impersonating a human, so every change shows that +human's address regardless of who actually made it. + +Any doc not in the manifest (human-authored / pre-existing), or any manifest doc +whose revisionId has changed (a human edited it), is REFUSED. To manage an +existing human doc, `seed` it first (baselines its current revision); a later +`import` then replaces it, refusing if a human edited it in between. + +Auth follows the app convention (GCP_CREDENTIALS + CompanyDefaults.company_email, +GCP_DELEGATED_SUBJECT override), same as read_google_doc.py. + +Usage: + write_google_doc.py import <md_path> <folder_id> <title> + write_google_doc.py seed <doc_id> # baseline an existing doc so it can be managed + write_google_doc.py trash <doc_id> # trash a managed doc (if unedited since our write) + write_google_doc.py status # show manifest vs live state +""" + +import io +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseUpload + +from apps.workflow.models import CompanyDefaults + +SCOPES = [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/documents", +] +# Per-instance state (which docs this tool manages + their post-write revisionId). +# Gitignored — it is runtime data, not source. +MANIFEST = os.path.join(os.path.dirname(__file__), "google_doc_manifest.json") + + +def _clients(): + key_file = os.getenv("GCP_CREDENTIALS") + if not key_file: + raise RuntimeError("GCP_CREDENTIALS environment variable not set") + if not os.path.exists(key_file): + raise RuntimeError(f"Google service account key file not found: {key_file}") + subject = ( + os.getenv("GCP_DELEGATED_SUBJECT") or CompanyDefaults.get_solo().company_email + ) + if not subject: + raise RuntimeError( + "No impersonation subject: set GCP_DELEGATED_SUBJECT or populate " + "CompanyDefaults.company_email in Settings." + ) + creds = service_account.Credentials.from_service_account_file( + key_file, scopes=SCOPES + ).with_subject(subject) + return build("drive", "v3", credentials=creds), build( + "docs", "v1", credentials=creds + ) + + +drive, docs = _clients() + + +def load() -> dict: + if os.path.exists(MANIFEST): + with open(MANIFEST) as fh: + return json.load(fh) + return {} + + +def save(manifest: dict) -> None: + with open(MANIFEST, "w") as fh: + json.dump(manifest, fh, indent=2, sort_keys=True) + + +def revid(doc_id: str) -> str: + """Docs content revisionId — changes only on a real content edit.""" + return ( + docs.documents() + .get(documentId=doc_id, fields="revisionId") + .execute()["revisionId"] + ) + + +def find_in_folder(folder_id: str, title: str) -> list: + return ( + drive.files() + .list( + q=( + f"name = '{title}' and '{folder_id}' in parents and trashed = false " + "and mimeType = 'application/vnd.google-apps.document'" + ), + fields="files(id)", + includeItemsFromAllDrives=True, + supportsAllDrives=True, + ) + .execute() + .get("files", []) + ) + + +class OverwriteRefused(Exception): + pass + + +def check_unedited(doc_id: str, manifest: dict) -> None: + """Raise unless doc_id is managed by this tool and unedited since our write.""" + rec = manifest.get(doc_id) + if rec is None: + raise OverwriteRefused(f"{doc_id} is not managed by this tool. Refusing.") + if rec["revisionId"] != revid(doc_id): + raise OverwriteRefused( + f"{doc_id} ('{rec['title']}') has been edited since this tool wrote " + f"it (revisionId changed). Refusing to touch a human edit." + ) + + +def do_import(md_path: str, folder_id: str, title: str) -> str: + manifest = load() + existing = find_in_folder(folder_id, title) + if existing: + doc_id = existing[0]["id"] + check_unedited(doc_id, manifest) # refuses if human-edited or unmanaged + drive.files().update( + fileId=doc_id, body={"trashed": True}, supportsAllDrives=True + ).execute() + del manifest[doc_id] + + with open(md_path, "rb") as fh: + media = MediaIoBaseUpload( + io.BytesIO(fh.read()), mimetype="text/markdown", resumable=False + ) + created = ( + drive.files() + .create( + body={ + "name": title, + "mimeType": "application/vnd.google-apps.document", + "parents": [folder_id], + }, + media_body=media, + fields="id,webViewLink", + supportsAllDrives=True, + ) + .execute() + ) + manifest[created["id"]] = { + "title": title, + "folder_id": folder_id, + "revisionId": revid(created["id"]), + } + save(manifest) + print(f"created: {created['webViewLink']}") + return created["id"] + + +def trash(doc_id: str) -> None: + """Trash a managed doc, refusing if a human has edited it.""" + manifest = load() + check_unedited(doc_id, manifest) + drive.files().update( + fileId=doc_id, body={"trashed": True}, supportsAllDrives=True + ).execute() + title = manifest.pop(doc_id)["title"] + save(manifest) + print(f"trashed '{title}' ({doc_id})") + + +def seed(doc_id: str) -> None: + """Baseline an existing doc at its current revision so it may be managed + (until a human next edits it).""" + manifest = load() + f = ( + drive.files() + .get(fileId=doc_id, fields="id,name,parents", supportsAllDrives=True) + .execute() + ) + manifest[f["id"]] = { + "title": f["name"], + "folder_id": f["parents"][0], + "revisionId": revid(f["id"]), + } + save(manifest) + print(f"seeded {f['id']} '{f['name']}'") + + +def status() -> None: + manifest = load() + print(f"{len(manifest)} docs under management:") + for doc_id, rec in manifest.items(): + try: + state = "unchanged" if revid(doc_id) == rec["revisionId"] else "EDITED" + except Exception: + state = "MISSING/TRASHED" + print(f" {rec['title']:42} {state}") + + +def main() -> int: + cmd = sys.argv[1] if len(sys.argv) > 1 else "" + if cmd == "import": + try: + do_import(sys.argv[2], sys.argv[3], sys.argv[4]) + except OverwriteRefused as e: + print(f"SAFETY NET — REFUSED: {e}") + return 3 + elif cmd == "trash": + try: + trash(sys.argv[2]) + except OverwriteRefused as e: + print(f"SAFETY NET — REFUSED: {e}") + return 3 + elif cmd == "seed": + seed(sys.argv[2]) + elif cmd == "status": + status() + else: + print(f"unknown command: {cmd!r} (use import/seed/trash/status)") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7d3091488955c405562ee68f19efe1fdec58e8fa Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Thu, 23 Jul 2026 14:44:18 +1200 Subject: [PATCH 42/66] fix: address CodeRabbit review on PR #489 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged all ten CodeRabbit comments; seven were valid. Resources menu is no longer office-staff gated. The `menu` endpoint serves any authenticated staff member and NotebookLmRestriction.NONE means "all staff", but the navbar hid the whole dropdown behind is_office_staff, so shop-floor staff could never reach an unrestricted link. The process read endpoints are already IsAuthenticated and the routes are requiresAuth only, so procedures and forms open up with it — that is the point of the ops manual. Covered by a navbar test that fails if the gate returns. NotebookLM frontend: the store called the generated client directly, so menu loading bypassed the service layer; and the navbar's shared store was loaded once at startup and never refreshed, leaving admin creates, edits and deletes invisible until a page reload. Price extraction: neither provider actually inherited PriceExtractionProvider, so the factory's return type was wrong for both and Mistral had no model_name at all. Both now inherit the ABC, Mistral's hardcoded "mistral-ocr-latest" becomes a constant the factory can override like Gemini's, and the two resolved violations are dropped from the mypy baseline. Scripts: explore_google_drive.py no longer requests the unused `documents` scope, and set_doc_screenshot.py deletes the temp upload rather than trashing it — trashing left the "anyone/reader" grant live. .gitignore now matches docs/plans/* so the !_template.md negation is reachable; git never descends into an excluded directory. Rejected two: the PDF golden fixture is already deterministic (BaseTestCase loads no PhoneEndpoint rows and normalized_number is unique), and apps/workflow/__init__.py was generated — re-running update_init.py leaves it unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wHxbSZWq7V7jMkBi3Lq1N --- .gitignore | 8 +++- apps/quoting/services/ai_price_extraction.py | 33 ++++---------- apps/quoting/services/providers/base.py | 24 +++++++++++ .../services/providers/gemini_provider.py | 3 +- .../services/providers/mistral_provider.py | 11 +++-- .../quoting/tests/test_ai_price_extraction.py | 36 ++++++++++++++++ frontend/src/components/AppNavbar.vue | 8 ++-- .../components/__tests__/AppNavbar.test.ts | 43 +++++++++++++++---- .../src/services/notebookLmLinkService.ts | 13 ++++++ .../stores/__tests__/notebookLmLinks.test.ts | 42 ++++++++++++++++++ frontend/src/stores/notebookLmLinks.ts | 12 ++---- .../src/views/AdminNotebookLmLinksView.vue | 12 +++++- mypy-baseline.txt | 2 - scripts/explore_google_drive.py | 5 +-- scripts/set_doc_screenshot.py | 7 ++- 15 files changed, 197 insertions(+), 62 deletions(-) create mode 100644 apps/quoting/services/providers/base.py create mode 100644 frontend/src/stores/__tests__/notebookLmLinks.test.ts diff --git a/.gitignore b/.gitignore index e428146f0..cd8d8a644 100644 --- a/.gitignore +++ b/.gitignore @@ -57,8 +57,12 @@ instance/ # Sphinx documentation docs/_build/ -# Planning artefacts (Claude Code plan-mode files; local-only) -docs/plans/ +# Planning artefacts (Claude Code plan-mode files; local-only). +# Match the contents rather than the directory itself — git never descends +# into an excluded directory, so `docs/plans/` would make the negation below +# unreachable. +docs/plans/* +!docs/plans/_template.md # PyBuilder target/ diff --git a/apps/quoting/services/ai_price_extraction.py b/apps/quoting/services/ai_price_extraction.py index 6735befa4..df1dee301 100644 --- a/apps/quoting/services/ai_price_extraction.py +++ b/apps/quoting/services/ai_price_extraction.py @@ -1,42 +1,23 @@ -import abc import logging from typing import Any, Dict, Optional, Tuple from apps.workflow.enums import AIProviderTypes +from .providers.base import PriceExtractionProvider from .providers.gemini_provider import ( GEMINI_FLASH_MODEL, GeminiPriceExtractionProvider, ) # from .providers.claude_provider import ClaudePriceExtractionProvider -from .providers.mistral_provider import MistralPriceExtractionProvider +from .providers.mistral_provider import ( + MISTRAL_OCR_MODEL, + MistralPriceExtractionProvider, +) logger = logging.getLogger(__name__) -class PriceExtractionProvider(abc.ABC): - """Abstract base class for AI price extraction providers.""" - - provider_name: str - model_name: str - - @abc.abstractmethod - def extract_price_data( - self, file_path: str, content_type: Optional[str] = None - ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - """ - Extract price data from a supplier price list file. - - Args: - file_path: Path to the price list file - content_type: MIME type of the file - - Returns: - Tuple containing extracted data dict and error message if any - """ - - class PriceExtractionFactory: """Factory for creating AI price extraction providers.""" @@ -46,7 +27,9 @@ def create_provider( ) -> PriceExtractionProvider: """Create a provider instance based on type.""" if provider_type == AIProviderTypes.MISTRAL: - return MistralPriceExtractionProvider(api_key) + return MistralPriceExtractionProvider( + api_key, model_name or MISTRAL_OCR_MODEL + ) elif provider_type == AIProviderTypes.GOOGLE: return GeminiPriceExtractionProvider( api_key, model_name or GEMINI_FLASH_MODEL diff --git a/apps/quoting/services/providers/base.py b/apps/quoting/services/providers/base.py new file mode 100644 index 000000000..cd9970be8 --- /dev/null +++ b/apps/quoting/services/providers/base.py @@ -0,0 +1,24 @@ +import abc +from typing import Any, Dict, Optional, Tuple + + +class PriceExtractionProvider(abc.ABC): + """Abstract base class for AI price extraction providers.""" + + provider_name: str + model_name: str + + @abc.abstractmethod + def extract_price_data( + self, file_path: str, content_type: Optional[str] = None + ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """ + Extract price data from a supplier price list file. + + Args: + file_path: Path to the price list file + content_type: MIME type of the file + + Returns: + Tuple containing extracted data dict and error message if any + """ diff --git a/apps/quoting/services/providers/gemini_provider.py b/apps/quoting/services/providers/gemini_provider.py index aa162ecec..0e52526f7 100644 --- a/apps/quoting/services/providers/gemini_provider.py +++ b/apps/quoting/services/providers/gemini_provider.py @@ -12,6 +12,7 @@ from apps.workflow.models import CompanyDefaults +from .base import PriceExtractionProvider from .common import clean_json_response, create_extraction_prompt, log_token_usage logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ GEMINI_FLASH_MODEL = "gemini-flash-latest" -class GeminiPriceExtractionProvider: +class GeminiPriceExtractionProvider(PriceExtractionProvider): """Gemini AI provider for price extraction from PDF documents.""" provider_name = "Gemini" diff --git a/apps/quoting/services/providers/mistral_provider.py b/apps/quoting/services/providers/mistral_provider.py index a864725c5..b3e1792d4 100644 --- a/apps/quoting/services/providers/mistral_provider.py +++ b/apps/quoting/services/providers/mistral_provider.py @@ -8,8 +8,12 @@ from mistralai.client.sdk import Mistral +from .base import PriceExtractionProvider + logger = logging.getLogger(__name__) +MISTRAL_OCR_MODEL = "mistral-ocr-latest" + def encode_pdf(pdf_path): """Encode the PDF file to base64.""" @@ -21,13 +25,14 @@ def encode_pdf(pdf_path): return None -class MistralPriceExtractionProvider: +class MistralPriceExtractionProvider(PriceExtractionProvider): """Mistral AI provider for price extraction using OCR""" provider_name = "Mistral" - def __init__(self, api_key: str): + def __init__(self, api_key: str, model_name: str = MISTRAL_OCR_MODEL): self.api_key = api_key + self.model_name = model_name def _extract_supplier_from_text(self, text: str) -> str: """Extract supplier name from the OCR text.""" @@ -305,7 +310,7 @@ def extract_price_data( raise ValueError("Failed to encode PDF file") # Process the document with OCR ocr_response = client.ocr.process( - model="mistral-ocr-latest", + model=self.model_name, document={ "type": "document_url", "document_url": f"data:application/pdf;base64,{base64_pdf}", diff --git a/apps/quoting/tests/test_ai_price_extraction.py b/apps/quoting/tests/test_ai_price_extraction.py index f6de54276..3d40e734d 100644 --- a/apps/quoting/tests/test_ai_price_extraction.py +++ b/apps/quoting/tests/test_ai_price_extraction.py @@ -1,10 +1,15 @@ from django.test import SimpleTestCase from apps.quoting.services.ai_price_extraction import PriceExtractionFactory +from apps.quoting.services.providers.base import PriceExtractionProvider from apps.quoting.services.providers.gemini_provider import ( GEMINI_FLASH_MODEL, GeminiPriceExtractionProvider, ) +from apps.quoting.services.providers.mistral_provider import ( + MISTRAL_OCR_MODEL, + MistralPriceExtractionProvider, +) from apps.workflow.enums import AIProviderTypes @@ -34,3 +39,34 @@ def test_factory_preserves_an_explicit_gemini_model(self) -> None: ) self.assertEqual(provider.model_name, "gemini-pro-latest") + + +class ProviderContractTests(SimpleTestCase): + """Every provider the factory can return honours the shared contract.""" + + def test_factory_returns_a_provider_with_a_model_name(self) -> None: + for provider_type in (AIProviderTypes.GOOGLE, AIProviderTypes.MISTRAL): + with self.subTest(provider_type=provider_type): + provider = PriceExtractionFactory.create_provider( + provider_type, + "test-api-key", + "", + ) + + self.assertIsInstance(provider, PriceExtractionProvider) + self.assertTrue(provider.provider_name) + self.assertTrue(provider.model_name) + + def test_mistral_defaults_to_the_rolling_ocr_alias(self) -> None: + provider = MistralPriceExtractionProvider("test-api-key") + + self.assertEqual(provider.model_name, MISTRAL_OCR_MODEL) + + def test_factory_preserves_an_explicit_mistral_model(self) -> None: + provider = PriceExtractionFactory.create_provider( + AIProviderTypes.MISTRAL, + "test-api-key", + "mistral-ocr-2505", + ) + + self.assertEqual(provider.model_name, "mistral-ocr-2505") diff --git a/frontend/src/components/AppNavbar.vue b/frontend/src/components/AppNavbar.vue index 584f5b371..458d36358 100644 --- a/frontend/src/components/AppNavbar.vue +++ b/frontend/src/components/AppNavbar.vue @@ -140,9 +140,10 @@ </Transition> </div> - <div class="relative" @click.stop v-if="userInfo.is_office_staff"> + <div class="relative" @click.stop> <button @click="toggleDropdown('resources')" + data-automation-id="AppNavbar-resources" class="flex items-center text-gray-700 hover:text-blue-600 transition-colors text-sm font-medium px-3 py-2 rounded-md duration-200" > <ShieldCheck class="w-4 h-4 mr-1" /> Resources @@ -594,7 +595,7 @@ </router-link> </div> - <div class="border-t border-gray-200" v-if="userInfo.is_office_staff"></div> + <div class="border-t border-gray-200"></div> <div class="space-y-2"> <div class="bg-gray-50 rounded-md" v-if="isOfficeStaff"> @@ -656,9 +657,10 @@ </Transition> </div> - <div class="bg-gray-50 rounded-md" v-if="isOfficeStaff"> + <div class="bg-gray-50 rounded-md"> <button @click="toggleMobileSection('resources')" + data-automation-id="AppNavbar-resources-mobile" class="w-full flex items-center justify-between px-3 py-2 text-gray-700 hover:text-blue-600 transition-colors font-medium" > <span class="flex items-center space-x-2"> diff --git a/frontend/src/components/__tests__/AppNavbar.test.ts b/frontend/src/components/__tests__/AppNavbar.test.ts index af2e4eaf1..7e8f6f711 100644 --- a/frontend/src/components/__tests__/AppNavbar.test.ts +++ b/frontend/src/components/__tests__/AppNavbar.test.ts @@ -1,15 +1,17 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { mount } from '@vue/test-utils' import { createMemoryHistory, createRouter } from 'vue-router' import { createPinia } from 'pinia' +const { navLinks, mockUserInfo } = vi.hoisted(() => ({ + navLinks: { value: [] as Array<{ id: number; name: string; url: string }> }, + mockUserInfo: { value: { is_office_staff: true, is_superuser: false } }, +})) + vi.mock('@/composables/useAppLayout', () => ({ useAppLayout: () => ({ - userInfo: ref({ - is_office_staff: true, - is_superuser: false, - }), + userInfo: ref(mockUserInfo.value), handleLogout: vi.fn(), }), })) @@ -24,10 +26,6 @@ vi.mock('@/stores/processDocuments', () => ({ }), })) -const { navLinks } = vi.hoisted(() => ({ - navLinks: { value: [] as Array<{ id: number; name: string; url: string }> }, -})) - vi.mock('@/stores/notebookLmLinks', () => ({ useNotebookLmLinksStore: () => ({ get links() { @@ -80,6 +78,10 @@ describe('AppNavbar search URL sync', () => { }) describe('AppNavbar NotebookLM training links', () => { + beforeEach(() => { + mockUserInfo.value = { is_office_staff: true, is_superuser: false } + }) + async function openResourcesDropdown() { const router = buildRouter() await router.push('/kanban') @@ -132,4 +134,27 @@ describe('AppNavbar NotebookLM training links', () => { expect(trainingAnchors(wrapper)).toHaveLength(0) }) + + it('shows the Resources menu to non-office staff', async () => { + // The `menu` endpoint serves any authenticated staff member and + // NotebookLmRestriction.NONE means "all staff", so the whole Resources + // menu — chatbots, procedures and forms — must not be office-gated. + mockUserInfo.value = { is_office_staff: false, is_superuser: false } + navLinks.value = [ + { id: 1, name: 'MSM Manual', url: 'https://notebooklm.google.com/notebook/aaa' }, + ] + + const wrapper = await openResourcesDropdown() + const anchors = trainingAnchors(wrapper) + + expect(anchors).toHaveLength(1) + expect(anchors[0].attributes('href')).toBe('https://notebooklm.google.com/notebook/aaa') + + // Guards against the mock silently failing to apply: office-only + // navigation must still be hidden for this user. + const purchasesButton = wrapper + .findAll('button') + .find((button) => button.text().includes('Purchases')) + expect(purchasesButton).toBeUndefined() + }) }) diff --git a/frontend/src/services/notebookLmLinkService.ts b/frontend/src/services/notebookLmLinkService.ts index 92530489a..340d3aa48 100644 --- a/frontend/src/services/notebookLmLinkService.ts +++ b/frontend/src/services/notebookLmLinkService.ts @@ -27,6 +27,19 @@ export class NotebookLmLinkService { } } + /** + * The enabled links the current user is allowed to see. Restriction + * filtering happens server-side, so this is what the navbar renders. + */ + async getMenuLinks(): Promise<NotebookLmLink[]> { + try { + return await api.workflow_notebook_lm_links_menu_list() + } catch (error) { + debugLog('Failed to fetch NotebookLM menu links:', error) + throw error + } + } + async createLink(linkData: NotebookLmLinkCreateUpdate): Promise<NotebookLmLink> { try { const created = await api.workflow_notebook_lm_links_create(linkData) diff --git a/frontend/src/stores/__tests__/notebookLmLinks.test.ts b/frontend/src/stores/__tests__/notebookLmLinks.test.ts new file mode 100644 index 000000000..7fb33ae7b --- /dev/null +++ b/frontend/src/stores/__tests__/notebookLmLinks.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const { getMenuLinks } = vi.hoisted(() => ({ getMenuLinks: vi.fn() })) + +vi.mock('@/services/notebookLmLinkService', () => ({ + notebookLmLinkService: { getMenuLinks }, +})) + +import { useNotebookLmLinksStore } from '../notebookLmLinks' + +describe('notebookLmLinks store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + getMenuLinks.mockReset() + }) + + it('loads the menu links through the service layer', async () => { + const links = [{ id: 1, name: 'MSM Manual', url: 'https://notebooklm.google.com/notebook/aaa' }] + getMenuLinks.mockResolvedValue(links) + + const store = useNotebookLmLinksStore() + await store.loadLinks() + + expect(getMenuLinks).toHaveBeenCalledTimes(1) + expect(store.links).toEqual(links) + expect(store.isLoaded).toBe(true) + expect(store.isLoading).toBe(false) + expect(store.error).toBeNull() + }) + + it('surfaces a failure without leaving the store loading', async () => { + getMenuLinks.mockRejectedValue(new Error('network down')) + + const store = useNotebookLmLinksStore() + await store.loadLinks() + + expect(store.error).toBe('network down') + expect(store.isLoaded).toBe(false) + expect(store.isLoading).toBe(false) + }) +}) diff --git a/frontend/src/stores/notebookLmLinks.ts b/frontend/src/stores/notebookLmLinks.ts index aba10c53e..cac53e08c 100644 --- a/frontend/src/stores/notebookLmLinks.ts +++ b/frontend/src/stores/notebookLmLinks.ts @@ -1,10 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { api } from '@/api/client' -import { schemas } from '@/api/generated/api' -import type { z } from 'zod' - -type NotebookLmLink = z.infer<typeof schemas.NotebookLmLink> +import { notebookLmLinkService, type NotebookLmLink } from '@/services/notebookLmLinkService' export const useNotebookLmLinksStore = defineStore('notebookLmLinks', () => { const links = ref<NotebookLmLink[]>([]) @@ -16,12 +12,10 @@ export const useNotebookLmLinksStore = defineStore('notebookLmLinks', () => { isLoading.value = true error.value = null try { - // The `menu` action returns the enabled links the current user may see; - // restriction filtering happens server-side. - links.value = await api.workflow_notebook_lm_links_menu_list() + links.value = await notebookLmLinkService.getMenuLinks() isLoaded.value = true } catch (e) { - error.value = (e as Error)?.message || 'Failed to load NotebookLM links' + error.value = e instanceof Error ? e.message : 'Failed to load NotebookLM links' } finally { isLoading.value = false } diff --git a/frontend/src/views/AdminNotebookLmLinksView.vue b/frontend/src/views/AdminNotebookLmLinksView.vue index 04318b199..edc3b7d28 100644 --- a/frontend/src/views/AdminNotebookLmLinksView.vue +++ b/frontend/src/views/AdminNotebookLmLinksView.vue @@ -117,9 +117,15 @@ import { type NotebookLmLink, type NotebookLmLinkCreateUpdate, } from '@/services/notebookLmLinkService' +import { useNotebookLmLinksStore } from '@/stores/notebookLmLinks' const notebookLmLinkService = NotebookLmLinkService.getInstance() +// The navbar renders the shared menu store, which is loaded once at app +// startup — refresh it after every mutation so admin edits show up without +// a page reload. +const notebookLmLinksStore = useNotebookLmLinksStore() + const links = ref<NotebookLmLink[]>([]) const isLoading = ref(false) const error = ref<string | null>(null) @@ -178,8 +184,9 @@ const handleSave = async (linkData: NotebookLmLink & NotebookLmLinkCreateUpdate) } closeModal() await fetchLinks() + await notebookLmLinksStore.loadLinks() } catch (error: unknown) { - const errMessage = (error as Error).message || 'An unknown error occurred.' + const errMessage = error instanceof Error ? error.message : 'An unknown error occurred.' toast.error('Failed to save link.', { description: errMessage, }) @@ -197,8 +204,9 @@ const deleteLink = async () => { await notebookLmLinkService.deleteLink(Number(linkToDelete.value.id)) toast.success('Link deleted successfully.') await fetchLinks() + await notebookLmLinksStore.loadLinks() } catch (error: unknown) { - const errMessage = (error as Error).message || 'An unknown error occurred.' + const errMessage = error instanceof Error ? error.message : 'An unknown error occurred.' toast.error('Failed to delete link.', { description: errMessage, }) diff --git a/mypy-baseline.txt b/mypy-baseline.txt index c412849ae..86ffa1d48 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -792,8 +792,6 @@ apps/accounting/services/wip_service.py:0: error: Argument "key" to "sorted" has apps/accounting/services/wip_service.py:0: error: Incompatible return value type (got "object", expected "SupportsDunderLT[Any] | SupportsDunderGT[Any]") [return-value] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "MistralPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "GeminiPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] apps/quoting/services/ai_price_extraction.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/services/ai_price_extraction.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "int" [call-overload] apps/quoting/services/ai_price_extraction.py:0: note: Possible overload variants: diff --git a/scripts/explore_google_drive.py b/scripts/explore_google_drive.py index 5c6ace6f3..caa126470 100644 --- a/scripts/explore_google_drive.py +++ b/scripts/explore_google_drive.py @@ -38,10 +38,7 @@ from apps.workflow.models import CompanyDefaults -SCOPES = [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/documents", -] +SCOPES = ["https://www.googleapis.com/auth/drive"] FOLDER_MIME = "application/vnd.google-apps.folder" diff --git a/scripts/set_doc_screenshot.py b/scripts/set_doc_screenshot.py index c7208f9c4..4c45cb163 100644 --- a/scripts/set_doc_screenshot.py +++ b/scripts/set_doc_screenshot.py @@ -143,8 +143,11 @@ def main(doc_id: str, screenshot_id: str, png_path: str) -> int: }, ).execute() finally: - # Docs keeps its own copy of the image; the source upload is transient. - drive.files().update(fileId=fid, body={"trashed": True}).execute() + # Docs keeps its own copy of the image, so the source upload is + # transient. Delete rather than trash: the upload is shared + # "anyone/reader" so Docs can fetch it, and trashing does not revoke + # that grant. + drive.files().delete(fileId=fid).execute() after = docs.documents().get(documentId=doc_id).execute() n_images = len(after.get("inlineObjects", {})) From 26063f8ce86ad6ba5dc12eb5e52212ea73371309 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Thu, 23 Jul 2026 15:08:49 +1200 Subject: [PATCH 43/66] fix: harden the Google Docs write tooling (CodeRabbit round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_in_folder() interpolated the title and folder id straight into a Drive query, so an ordinary title like "Driver's Handbook" terminated the string literal early and made the whole query invalid. Escape both through a q_literal() helper. status() reported every failure as MISSING/TRASHED, so a 403 or a network blip looked like a deleted document. Only a 404 is a real "it isn't there"; anything else now propagates rather than being reported as a state we did not actually observe. read_google_doc.py no longer requests the `documents` scope — it exports through Drive and never touches a Docs API resource. Also parameterised the manifest containers with a ManifestEntry TypedDict instead of bare dict/list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wHxbSZWq7V7jMkBi3Lq1N --- scripts/read_google_doc.py | 9 +++++---- scripts/write_google_doc.py | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/scripts/read_google_doc.py b/scripts/read_google_doc.py index 62a8b16b1..83e33c1e3 100644 --- a/scripts/read_google_doc.py +++ b/scripts/read_google_doc.py @@ -23,10 +23,11 @@ from apps.workflow.models import CompanyDefaults -SCOPES = [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/documents", -] +# Drive only: read_doc() exports through the Drive API and never touches a Docs +# API resource. (Narrowing further to drive.readonly would need that scope +# authorised on the domain-wide-delegation client in the Workspace admin +# console, so it is not a safe unilateral change.) +SCOPES = ["https://www.googleapis.com/auth/drive"] def build_drive(): diff --git a/scripts/write_google_doc.py b/scripts/write_google_doc.py index a87324769..dd7367a34 100644 --- a/scripts/write_google_doc.py +++ b/scripts/write_google_doc.py @@ -32,6 +32,7 @@ import json import os import sys +from typing import TypedDict sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") @@ -42,10 +43,17 @@ from google.oauth2 import service_account from googleapiclient.discovery import build +from googleapiclient.errors import HttpError from googleapiclient.http import MediaIoBaseUpload from apps.workflow.models import CompanyDefaults +# One manifest entry: what this tool wrote, where, and the revision it left +# behind (the edit-detection baseline). +ManifestEntry = TypedDict( + "ManifestEntry", {"title": str, "folder_id": str, "revisionId": str} +) + SCOPES = [ "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/documents", @@ -80,14 +88,14 @@ def _clients(): drive, docs = _clients() -def load() -> dict: +def load() -> dict[str, ManifestEntry]: if os.path.exists(MANIFEST): with open(MANIFEST) as fh: return json.load(fh) return {} -def save(manifest: dict) -> None: +def save(manifest: dict[str, ManifestEntry]) -> None: with open(MANIFEST, "w") as fh: json.dump(manifest, fh, indent=2, sort_keys=True) @@ -101,12 +109,23 @@ def revid(doc_id: str) -> str: ) -def find_in_folder(folder_id: str, title: str) -> list: +def q_literal(value: str) -> str: + """Escape a value for use inside a Drive query string literal. + + Drive's query grammar takes backslash escapes, so a perfectly ordinary + title like "Driver's Handbook" would otherwise terminate the literal early + and make the whole query invalid. + """ + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def find_in_folder(folder_id: str, title: str) -> list[dict[str, str]]: return ( drive.files() .list( q=( - f"name = '{title}' and '{folder_id}' in parents and trashed = false " + f"name = '{q_literal(title)}' " + f"and '{q_literal(folder_id)}' in parents and trashed = false " "and mimeType = 'application/vnd.google-apps.document'" ), fields="files(id)", @@ -122,7 +141,7 @@ class OverwriteRefused(Exception): pass -def check_unedited(doc_id: str, manifest: dict) -> None: +def check_unedited(doc_id: str, manifest: dict[str, ManifestEntry]) -> None: """Raise unless doc_id is managed by this tool and unedited since our write.""" rec = manifest.get(doc_id) if rec is None: @@ -209,7 +228,12 @@ def status() -> None: for doc_id, rec in manifest.items(): try: state = "unchanged" if revid(doc_id) == rec["revisionId"] else "EDITED" - except Exception: + except HttpError as exc: + # Only a genuine "it isn't there" is a status. A 403, a quota error + # or a network failure means we do not know the state, and + # reporting it as MISSING/TRASHED would be a lie. + if exc.status_code != 404: + raise state = "MISSING/TRASHED" print(f" {rec['title']:42} {state}") From 5a53488c0ac71d02c7d566bc89d128fded4f6371 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Thu, 23 Jul 2026 15:15:48 +1200 Subject: [PATCH 44/66] style: describe the code, not the change, in three comments The scope, screenshot-cleanup and gitignore comments were narrating the edit that introduced them rather than explaining the code a later reader will find. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wHxbSZWq7V7jMkBi3Lq1N --- .gitignore | 5 ++--- scripts/read_google_doc.py | 5 +---- scripts/set_doc_screenshot.py | 7 +++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index cd8d8a644..c6e5710e7 100644 --- a/.gitignore +++ b/.gitignore @@ -58,9 +58,8 @@ instance/ docs/_build/ # Planning artefacts (Claude Code plan-mode files; local-only). -# Match the contents rather than the directory itself — git never descends -# into an excluded directory, so `docs/plans/` would make the negation below -# unreachable. +# The pattern must match the contents, not the directory: git never descends +# into an excluded directory, which would leave the negation unreachable. docs/plans/* !docs/plans/_template.md diff --git a/scripts/read_google_doc.py b/scripts/read_google_doc.py index 83e33c1e3..932b6bf0e 100644 --- a/scripts/read_google_doc.py +++ b/scripts/read_google_doc.py @@ -23,10 +23,7 @@ from apps.workflow.models import CompanyDefaults -# Drive only: read_doc() exports through the Drive API and never touches a Docs -# API resource. (Narrowing further to drive.readonly would need that scope -# authorised on the domain-wide-delegation client in the Workspace admin -# console, so it is not a safe unilateral change.) +# read_doc() exports through the Drive API and never touches a Docs resource. SCOPES = ["https://www.googleapis.com/auth/drive"] diff --git a/scripts/set_doc_screenshot.py b/scripts/set_doc_screenshot.py index 4c45cb163..0d1ca72d3 100644 --- a/scripts/set_doc_screenshot.py +++ b/scripts/set_doc_screenshot.py @@ -143,10 +143,9 @@ def main(doc_id: str, screenshot_id: str, png_path: str) -> int: }, ).execute() finally: - # Docs keeps its own copy of the image, so the source upload is - # transient. Delete rather than trash: the upload is shared - # "anyone/reader" so Docs can fetch it, and trashing does not revoke - # that grant. + # The upload is world-readable so Docs can fetch it, and Docs keeps its + # own copy once inserted. Only a permanent delete revokes that public + # grant — trashing leaves it live. drive.files().delete(fileId=fid).execute() after = docs.documents().get(documentId=doc_id).execute() From cd9a1cbf2c375dcd09804d8071aef59688efde2b Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Thu, 23 Jul 2026 15:42:22 +1200 Subject: [PATCH 45/66] fix: stop Mistral extraction losing item codes and dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MistralPriceExtractionProvider emitted `supplier_item_code` and a dict `dimensions`. PDFDataValidationService._sanitize_single_product reads `item_no` and runs `dimensions` through _clean_text, which stringifies whatever it gets. So on the Mistral path every imported product lost its supplier item code, and the dimensions column received a Python repr: item_no : '' dimensions : "{'width': '1200', 'length': '2400', 'thickness': '1.2mm', ...}" Mistral is priority 1 in get_prioritized_active_providers and every instance template provisions a Mistral row, so this affects any client using it. Gemini was unaffected — it already emits the importer's names. The provider is the producer, so the fix goes there rather than teaching the sanitiser a second field name (ADR 0015). `dimensions` is now rendered to the display string the importer stores, and `price_unit` is stated explicitly at the value the sanitiser already defaulted it to. The existing OCR test asserted the provider's own dict, which is why this survived: rename the field on both sides and it stays green. Added a test that crosses into the sanitiser, where the contract actually lives. Annotating PDFDataValidationService.__init__ to call it from a typed test resolved 4 baselined violations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wHxbSZWq7V7jMkBi3Lq1N --- apps/quoting/services/pdf_data_validation.py | 6 +- .../services/providers/mistral_provider.py | 36 ++++++++-- apps/quoting/tests/test_ocr_fixtures.py | 69 +++++++++++++++++-- mypy-baseline.txt | 4 -- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/apps/quoting/services/pdf_data_validation.py b/apps/quoting/services/pdf_data_validation.py index be03be4f3..a85e58f73 100644 --- a/apps/quoting/services/pdf_data_validation.py +++ b/apps/quoting/services/pdf_data_validation.py @@ -16,9 +16,9 @@ class PDFDataValidationService: and duplicate detection for supplier products. """ - def __init__(self): - self.validation_errors = [] - self.warnings = [] + def __init__(self) -> None: + self.validation_errors: List[str] = [] + self.warnings: List[str] = [] def validate_extracted_data( self, data: Dict[str, Any] diff --git a/apps/quoting/services/providers/mistral_provider.py b/apps/quoting/services/providers/mistral_provider.py index b3e1792d4..15714db7f 100644 --- a/apps/quoting/services/providers/mistral_provider.py +++ b/apps/quoting/services/providers/mistral_provider.py @@ -15,6 +15,28 @@ MISTRAL_OCR_MODEL = "mistral-ocr-latest" +def _format_dimensions(parsed: Dict[str, Optional[str]]) -> str: + """Render parsed dimensions as the display string the importer stores. + + PDFDataValidationService writes `dimensions` straight to a text column via + _clean_text, which stringifies whatever it is given — so anything but a + string here lands in the database as its Python repr. + """ + sized = [ + part + for part in (parsed["thickness"], parsed["width"], parsed["length"]) + if part + ] + if sized: + return " x ".join(sized) + + # Nothing sheet- or tube-shaped was parsed; round stock carries a diameter. + diameter = parsed["diameter"] + if not diameter: + return "" + return f"dia {diameter}" + + def encode_pdf(pdf_path): """Encode the PDF file to base64.""" try: @@ -178,19 +200,19 @@ def _extract_products_from_markdown_tables( # Create variant ID from description variant_id = description.replace(" ", "_").replace("/", "_")[:100] + # Field names here are a contract with + # PDFDataValidationService._sanitize_single_product — it + # reads item_no, price_unit and a string dimensions, and + # silently drops anything named differently. product = { "description": description, - "supplier_item_code": item_code, + "item_no": item_code, "variant_id": variant_id, "unit_price": unit_price, + "price_unit": "each", "category": current_category, "specifications": dimensions["specifications"], - "dimensions": { - "width": dimensions["width"], - "length": dimensions["length"], - "thickness": dimensions.get("thickness"), - "diameter": dimensions.get("diameter"), - }, + "dimensions": _format_dimensions(dimensions), "product_name": ( f"{current_category} - {description}" if current_category diff --git a/apps/quoting/tests/test_ocr_fixtures.py b/apps/quoting/tests/test_ocr_fixtures.py index a7f98531c..a0ff3d19c 100644 --- a/apps/quoting/tests/test_ocr_fixtures.py +++ b/apps/quoting/tests/test_ocr_fixtures.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch +from apps.quoting.services.pdf_data_validation import PDFDataValidationService from apps.quoting.services.providers.mistral_provider import ( MistralPriceExtractionProvider, ) @@ -24,6 +25,26 @@ def _ocr_response(self): ) return SimpleNamespace(pages=[page]) + def _ocr_response_with_item_code(self) -> SimpleNamespace: + """As above, but with a supplier item code in the description. + + The main fixture's descriptions carry no code, so item_no is legitimately + empty there and cannot show whether the code survives the import. + """ + page = SimpleNamespace( + markdown=( + "Customer: | Morris Sheetmetal |\n" + "Date: | 2026-05-22 |\n\n" + "# Aluminium Sheet\n\n" + "| Description | Price |\n" + "| --- | --- |\n" + "| UA1130 1.2mm x 1200 x 2400 5005 Sheet | $71.07 |\n\n" + "**WM Aluminium Ltd**" + ), + text="", + ) + return SimpleNamespace(pages=[page]) + @patch("apps.quoting.services.providers.mistral_provider.Mistral") def test_price_parsing_with_mocked_ocr_response(self, mock_mistral_class): """Catches OCR parser drift without making a live Mistral API call.""" @@ -71,21 +92,55 @@ def test_price_parsing_with_mocked_ocr_response(self, mock_mistral_class): first_item, { "description": "1.2mm x 1200 x 2400 5005 Sheet", - "supplier_item_code": "", + "item_no": "", "variant_id": "1.2mm_x_1200_x_2400_5005_Sheet", "unit_price": 71.07, + "price_unit": "each", "category": "Aluminium Sheet", "specifications": "1.2mm x 1200 x 2400 5005 Sheet", - "dimensions": { - "width": "1200", - "length": "2400", - "thickness": "1.2mm", - "diameter": None, - }, + "dimensions": "1.2mm x 1200 x 2400", "product_name": ("Aluminium Sheet - 1.2mm x 1200 x 2400 5005 Sheet"), }, ) + @patch("apps.quoting.services.providers.mistral_provider.Mistral") + def test_extracted_items_survive_the_import_sanitiser( + self, mock_mistral_class: Mock + ) -> None: + """The import sanitiser must keep the fields Mistral extracts. + + The provider names its fields for + PDFDataValidationService._sanitize_single_product, and a mismatch is + silent — the field is simply absent from the sanitised product. So this + asserts across that boundary, on the last hop before import, rather + than on the provider's own dict, which would agree with itself after a + rename. + """ + mock_client = Mock() + mock_client.ocr.process.return_value = self._ocr_response_with_item_code() + mock_mistral_class.return_value = mock_client + provider = MistralPriceExtractionProvider(api_key="dummy_key_for_testing") + + with ( + patch( + "apps.quoting.services.providers.mistral_provider.os.path.exists", + return_value=True, + ), + patch( + "apps.quoting.services.providers.mistral_provider.encode_pdf", + return_value="mock_base64", + ), + ): + result, error = provider.extract_price_data("mock_file_path.pdf") + + self.assertIsNone(error) + assert result is not None + + sanitised = PDFDataValidationService().sanitize_product_data(result["items"]) + + self.assertEqual(sanitised[0]["item_no"], "UA1130") + self.assertEqual(sanitised[0]["dimensions"], "1.2mm x 1200 x 2400") + if __name__ == "__main__": unittest.main() diff --git a/mypy-baseline.txt b/mypy-baseline.txt index 86ffa1d48..f37dd7267 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -343,8 +343,6 @@ apps/quoting/tests_utils.py:0: error: Function is missing a return type annotati apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/services/pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/services/pdf_data_validation.py:0: note: Use "-> None" if function does not return a value apps/quoting/services/pdf_data_validation.py:0: error: Returning Any from function declared to return "str" [no-any-return] apps/purchasing/services/stock_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] apps/purchasing/services/stock_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] @@ -1714,7 +1712,6 @@ apps/quoting/tests_mcp.py:0: note: Use "-> None" if function does not return a v apps/quoting/tests_mcp.py:0: error: Call to untyped function "get_queryset" in typed context [no-untyped-call] apps/quoting/tests/test_pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/tests/test_pdf_data_validation.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests/test_pdf_data_validation.py:0: error: Call to untyped function "PDFDataValidationService" in typed context [no-untyped-call] apps/quoting/tests/test_pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/tests/test_pdf_data_validation.py:0: note: Use "-> None" if function does not return a value apps/quoting/tests/test_pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -2877,7 +2874,6 @@ apps/job/services/chat_service.py:0: error: Item "None" of "Company | None" has apps/job/services/chat_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] apps/job/services/chat_service.py:0: error: Cannot call function of unknown type [operator] apps/quoting/views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/views.py:0: error: Call to untyped function "PDFDataValidationService" in typed context [no-untyped-call] apps/quoting/views.py:0: error: Call to untyped function "PDFImportService" in typed context [no-untyped-call] apps/quoting/scrapers/steel_and_tube.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/scrapers/steel_and_tube.py:0: error: "None" has no attribute "get" [attr-defined] From 8c67dec0dc81ae04d7c56255e0782d1404fa9b86 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Thu, 23 Jul 2026 17:42:55 +1200 Subject: [PATCH 46/66] fix: send terms on Xero quotes --- apps/workflow/accounting/provider.py | 5 + apps/workflow/accounting/quote_pdf_service.py | 74 +++++++ apps/workflow/accounting/types.py | 14 ++ apps/workflow/accounting/xero/provider.py | 52 +++++ .../accounting/xero/readonly_provider.py | 6 + apps/workflow/fixtures/company_defaults.json | 1 + .../fixtures/company_defaults_prospect.json | 1 + .../commands/inspect_xero_quote_pdf.py | 33 ++++ .../0014_companydefaults_xero_quote_terms.py | 56 ++++++ apps/workflow/models/company_defaults.py | 32 ++- apps/workflow/models/settings_metadata.py | 1 + apps/workflow/serializers.py | 13 ++ .../tests/test_company_defaults_api.py | 88 +++++++++ .../tests/test_company_defaults_schema.py | 12 +- .../tests/test_localdate_regression.py | 3 +- .../tests/test_xero_branding_themes.py | 53 ++++- .../test_xero_document_error_handling.py | 3 +- .../tests/test_xero_document_raw_json.py | 3 +- .../tests/test_xero_instance_templates.py | 7 + apps/workflow/tests/test_xero_quote_pdf.py | 183 ++++++++++++++++++ .../tests/test_xero_readonly_provider.py | 3 +- apps/workflow/views/xero/xero_base_manager.py | 8 + .../workflow/views/xero/xero_quote_manager.py | 27 +++ docs/client_onboarding.md | 18 +- docs/instance-setup-demo.md | 8 + docs/instance-setup-production.md | 12 +- docs/restore-prod-to-nonprod.md | 7 +- frontend/docs/e2e_testing_strategy.md | 9 +- frontend/schema.yml | 33 ++-- frontend/src/api/generated/api.ts | 3 + frontend/src/components/SectionForm.vue | 90 ++++++++- .../components/__tests__/SectionForm.test.ts | 86 ++++++++ frontend/tests/job/job-xero-quote.spec.ts | 151 ++++++++++++--- 33 files changed, 1029 insertions(+), 66 deletions(-) create mode 100644 apps/workflow/accounting/quote_pdf_service.py create mode 100644 apps/workflow/management/commands/inspect_xero_quote_pdf.py create mode 100644 apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py create mode 100644 apps/workflow/tests/test_xero_quote_pdf.py diff --git a/apps/workflow/accounting/provider.py b/apps/workflow/accounting/provider.py index b659291ab..0e12f4406 100644 --- a/apps/workflow/accounting/provider.py +++ b/apps/workflow/accounting/provider.py @@ -18,6 +18,7 @@ InvoicePayload, POPayload, QuotePayload, + QuotePdfDocument, ) logger = logging.getLogger(__name__) @@ -96,6 +97,10 @@ def delete_quote(self, external_id: str) -> DocumentResult: """Delete/void a quote in the accounting system.""" ... + def download_quote_pdf(self, external_id: str) -> QuotePdfDocument: + """Download the provider-rendered quote PDF for inspection.""" + ... + def create_purchase_order(self, payload: POPayload) -> DocumentResult: """Create a purchase order in the accounting system.""" ... diff --git a/apps/workflow/accounting/quote_pdf_service.py b/apps/workflow/accounting/quote_pdf_service.py new file mode 100644 index 000000000..72450b3e5 --- /dev/null +++ b/apps/workflow/accounting/quote_pdf_service.py @@ -0,0 +1,74 @@ +"""Inspection of provider-rendered quote PDFs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from pypdf import PdfReader + +from apps.workflow.accounting.registry import get_provider +from apps.workflow.models import CompanyDefaults +from apps.workflow.services.error_persistence import persist_app_error + + +@dataclass(frozen=True) +class QuotePdfInspection: + """Structured evidence from a provider-rendered quote PDF.""" + + quote_id: str + remote_branding_theme_id: str | None + configured_branding_theme_id: str | None + page_count: int + contains_expected_text: bool + + +def inspect_quote_pdf( + quote_id: UUID, + expected_text: str, +) -> QuotePdfInspection: + """Inspect the real provider PDF without exposing its customer text.""" + normalized_expected_text = " ".join(expected_text.split()) + if not normalized_expected_text: + raise ValueError("Expected quote PDF text must not be empty") + + document = None + try: + provider = get_provider() + document = provider.download_quote_pdf(str(quote_id)) + reader = PdfReader(document.temporary_file_path) + page_text: list[str] = [] + for page in reader.pages: + extracted = page.extract_text() + if extracted is not None: + page_text.append(extracted) + + if not page_text: + raise ValueError(f"Quote {quote_id} PDF contains no extractable text") + + normalized_document_text = " ".join("\n".join(page_text).split()) + compact_expected_text = "".join(normalized_expected_text.split()) + compact_document_text = "".join(normalized_document_text.split()) + configured_theme_id = CompanyDefaults.get_solo().xero_sales_branding_theme_id + return QuotePdfInspection( + quote_id=document.external_id, + remote_branding_theme_id=document.document_theme_external_id, + configured_branding_theme_id=( + str(configured_theme_id) if configured_theme_id is not None else None + ), + page_count=len(reader.pages), + contains_expected_text=( + normalized_expected_text in normalized_document_text + or compact_expected_text in compact_document_text + ), + ) + except Exception as exc: + persist_app_error(exc) + raise + finally: + if document is not None: + try: + document.temporary_file_path.unlink(missing_ok=True) + except Exception as exc: + persist_app_error(exc) + raise diff --git a/apps/workflow/accounting/types.py b/apps/workflow/accounting/types.py index d62f05063..de79e1b95 100644 --- a/apps/workflow/accounting/types.py +++ b/apps/workflow/accounting/types.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from decimal import Decimal +from pathlib import Path @dataclass(frozen=True) @@ -15,6 +16,18 @@ class DocumentTheme: is_default: bool +@dataclass(frozen=True) +class QuotePdfDocument: + """A provider-rendered quote PDF and its presentation metadata. + + ``temporary_file_path`` is owned by the caller and must be removed after use. + """ + + external_id: str + document_theme_external_id: str | None + temporary_file_path: Path + + @dataclass class DocumentLineItem: """A single line item on an invoice, quote, or purchase order.""" @@ -53,6 +66,7 @@ class QuotePayload: date: date expiry_date: date document_theme_external_id: str + terms: str currency_code: str = "NZD" reference: str | None = None status: str = "DRAFT" diff --git a/apps/workflow/accounting/xero/provider.py b/apps/workflow/accounting/xero/provider.py index 599642ca8..316a1d9e2 100644 --- a/apps/workflow/accounting/xero/provider.py +++ b/apps/workflow/accounting/xero/provider.py @@ -4,6 +4,7 @@ import logging from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING from uuid import UUID @@ -23,6 +24,7 @@ InvoicePayload, POPayload, QuotePayload, + QuotePdfDocument, ) logger = logging.getLogger("xero") @@ -303,6 +305,7 @@ def create_quote(self, payload: QuotePayload) -> DocumentResult: status=payload.status, reference=payload.reference, branding_theme_id=payload.document_theme_external_id, + terms=payload.terms, ) response = api.create_quotes( @@ -352,6 +355,55 @@ def delete_quote(self, external_id: str) -> DocumentResult: persist_app_error(exc) return self._make_error_result(exc) + def download_quote_pdf(self, external_id: str) -> QuotePdfDocument: + """Download Xero's native quote PDF and report the applied theme.""" + from apps.workflow.accounting.types import QuotePdfDocument + + try: + quote_id = str(UUID(external_id)) + api, tenant_id = self._get_api() + response = api.get_quote(tenant_id, quote_id) + if len(response.quotes) != 1: + raise ValueError( + f"Xero returned {len(response.quotes)} quotes for {quote_id}" + ) + + quote = response.quotes[0] + if not isinstance(quote.quote_id, str): + raise ValueError(f"Xero quote {quote_id} is missing its identifier") + returned_quote_id = str(UUID(quote.quote_id)) + if returned_quote_id != quote_id: + raise ValueError( + f"Xero returned quote {returned_quote_id} for requested {quote_id}" + ) + + if quote.branding_theme_id is None: + document_theme_external_id = None + elif isinstance(quote.branding_theme_id, str): + document_theme_external_id = str(UUID(quote.branding_theme_id)) + else: + raise ValueError( + f"Xero quote {quote_id} has an invalid branding theme identifier" + ) + + downloaded_path = api.get_quote_as_pdf(tenant_id, quote_id) + if not isinstance(downloaded_path, str): + raise TypeError( + f"Xero quote PDF download returned {type(downloaded_path).__name__}" + ) + temporary_file_path = Path(downloaded_path) + if not temporary_file_path.is_file(): + raise FileNotFoundError(temporary_file_path) + + return QuotePdfDocument( + external_id=returned_quote_id, + document_theme_external_id=document_theme_external_id, + temporary_file_path=temporary_file_path, + ) + except Exception as exc: + persist_app_error(exc) + raise + def _create_or_update_purchase_order(self, payload: POPayload) -> DocumentResult: """Shared implementation for PO create and update.""" from xero_python.accounting.models import Contact, PurchaseOrder diff --git a/apps/workflow/accounting/xero/readonly_provider.py b/apps/workflow/accounting/xero/readonly_provider.py index 5d6db4cd4..82485f537 100644 --- a/apps/workflow/accounting/xero/readonly_provider.py +++ b/apps/workflow/accounting/xero/readonly_provider.py @@ -29,6 +29,7 @@ InvoicePayload, POPayload, QuotePayload, + QuotePdfDocument, ) logger = logging.getLogger("xero") @@ -154,6 +155,11 @@ def delete_quote(self, external_id: str) -> DocumentResult: _log_suppressed("delete_quote", external_id) return DocumentResult(success=True, external_id=external_id) + def download_quote_pdf(self, external_id: str) -> QuotePdfDocument: + raise RuntimeError( + "XERO_READONLY: a native Xero quote PDF cannot be downloaded" + ) + def _create_or_update_purchase_order(self, payload: POPayload) -> DocumentResult: raise RuntimeError( "XERO_READONLY: real Xero PO helper reached — a write override is missing" diff --git a/apps/workflow/fixtures/company_defaults.json b/apps/workflow/fixtures/company_defaults.json index c8b081d8a..47770951e 100644 --- a/apps/workflow/fixtures/company_defaults.json +++ b/apps/workflow/fixtures/company_defaults.json @@ -45,6 +45,7 @@ "xero_tenant_id": "00000000-0000-0000-0000-000000000000", "xero_shortcode": null, "xero_sales_branding_theme_id": null, + "xero_quote_terms": "Terms of trade can be found on our website: https://www.democompany.example.com/terms-of-trade", "enable_xero_sync": false, "xero_automated_day_floor": 100, "job_delta_soft_fail": true, diff --git a/apps/workflow/fixtures/company_defaults_prospect.json b/apps/workflow/fixtures/company_defaults_prospect.json index ba9a0c9d8..0f481bb06 100644 --- a/apps/workflow/fixtures/company_defaults_prospect.json +++ b/apps/workflow/fixtures/company_defaults_prospect.json @@ -45,6 +45,7 @@ "xero_tenant_id": null, "xero_shortcode": null, "xero_sales_branding_theme_id": null, + "xero_quote_terms": "Terms of trade can be found on our website: __URL__/terms-of-trade", "enable_xero_sync": false, "xero_automated_day_floor": 100, "job_delta_soft_fail": true, diff --git a/apps/workflow/management/commands/inspect_xero_quote_pdf.py b/apps/workflow/management/commands/inspect_xero_quote_pdf.py new file mode 100644 index 000000000..8d16009fe --- /dev/null +++ b/apps/workflow/management/commands/inspect_xero_quote_pdf.py @@ -0,0 +1,33 @@ +"""Inspect a native Xero quote PDF for an expected text marker.""" + +from __future__ import annotations + +import json +from argparse import ArgumentParser +from dataclasses import asdict +from uuid import UUID + +from django.core.management.base import BaseCommand, CommandError, CommandParser + +from apps.workflow.accounting.quote_pdf_service import inspect_quote_pdf + + +class Command(BaseCommand): + """Expose quote PDF inspection as a structured operational command.""" + + help = "Inspect a provider-rendered quote PDF for expected text" + + def add_arguments(self, parser: ArgumentParser | CommandParser) -> None: + parser.add_argument("quote_id", type=UUID) + parser.add_argument("--expected-text", required=True) + + def handle(self, *args: object, **options: object) -> None: + quote_id = options["quote_id"] + expected_text = options["expected_text"] + if not isinstance(quote_id, UUID): + raise CommandError("quote_id must be a UUID") + if not isinstance(expected_text, str) or not expected_text.strip(): + raise CommandError("--expected-text must not be empty") + + inspection = inspect_quote_pdf(quote_id, expected_text) + self.stdout.write(json.dumps(asdict(inspection), sort_keys=True)) diff --git a/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py b/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py new file mode 100644 index 000000000..8c6559a5c --- /dev/null +++ b/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py @@ -0,0 +1,56 @@ +from django.db import migrations, models + + +def populate_default_quote_terms(apps, schema_editor): + CompanyDefaults = apps.get_model("workflow", "CompanyDefaults") + for defaults in CompanyDefaults.objects.exclude(company_url__isnull=True): + if defaults.xero_quote_terms is not None: + continue + if not defaults.company_url: + continue + company_url = defaults.company_url.rstrip("/") + defaults.xero_quote_terms = ( + "Terms of trade can be found on our website: " + f"{company_url}/terms-of-trade" + ) + defaults.save(update_fields=["xero_quote_terms"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow", "0013_notebooklmlink"), + ] + + operations = [ + migrations.AlterField( + model_name="companydefaults", + name="xero_sales_branding_theme_id", + field=models.UUIDField( + blank=True, + help_text=( + "Controls the layout and presentation of every quote and sales " + "invoice created in Xero. It is configured during Xero setup and " + "required before sales documents can be created." + ), + null=True, + verbose_name="Xero sales branding theme", + ), + ), + migrations.AddField( + model_name="companydefaults", + name="xero_quote_terms", + field=models.TextField( + blank=True, + help_text=( + "Terms sent on every quote created by DocketWorks. Initially " + "derived from the company website's /terms-of-trade page. Copy " + "the same text to Xero's Terms (Quotes) setting so quotes created " + "directly in Xero during an outage use the same terms." + ), + max_length=4000, + null=True, + verbose_name="Xero quote terms", + ), + ), + migrations.RunPython(populate_default_quote_terms, migrations.RunPython.noop), + ] diff --git a/apps/workflow/models/company_defaults.py b/apps/workflow/models/company_defaults.py index d7efbfc2d..a69a0aa99 100644 --- a/apps/workflow/models/company_defaults.py +++ b/apps/workflow/models/company_defaults.py @@ -6,6 +6,14 @@ from solo.models import SingletonModel +def default_xero_quote_terms(company_url: str) -> str: + """Build the initial quote terms from the configured company website.""" + return ( + "Terms of trade can be found on our website: " + f"{company_url.rstrip('/')}/terms-of-trade" + ) + + class CompanyDefaults(SingletonModel): company_name = models.CharField(max_length=255) company_acronym = models.CharField( @@ -132,10 +140,21 @@ class CompanyDefaults(SingletonModel): blank=True, verbose_name="Xero sales branding theme", help_text=( - "Branding theme applied to every quote and sales invoice created in " - "Xero. Select a theme containing the required terms and conditions; " - "it is configured during Xero setup and required before sales " - "documents can be created." + "Controls the layout and presentation of every quote and sales invoice " + "created in Xero. It is configured during Xero setup and required " + "before sales documents can be created." + ), + ) + xero_quote_terms = models.TextField( + max_length=4000, + null=True, + blank=True, + verbose_name="Xero quote terms", + help_text=( + "Terms sent on every quote created by DocketWorks. Initially derived " + "from the company website's /terms-of-trade page. Copy the same text to " + "Xero's Terms (Quotes) setting so quotes created directly in Xero during " + "an outage use the same terms." ), ) enable_xero_sync = models.BooleanField( @@ -364,6 +383,11 @@ def save( update_fields: Iterable[str] | None = None, **kwargs: object, ) -> None: + if self.xero_quote_terms is None and self.company_url: + self.xero_quote_terms = default_xero_quote_terms(self.company_url) + if update_fields is not None: + update_fields = (*update_fields, "xero_quote_terms") + # Check if annual_leave_loading changed - if so, recompute all staff wage_rates loading_changed = False if self.pk: diff --git a/apps/workflow/models/settings_metadata.py b/apps/workflow/models/settings_metadata.py index 0637d43ca..15b85b593 100644 --- a/apps/workflow/models/settings_metadata.py +++ b/apps/workflow/models/settings_metadata.py @@ -142,6 +142,7 @@ def get_section_info(cls, key: str) -> tuple[str, str, int] | None: "xero_tenant_id": "xero", "xero_shortcode": "xero", "xero_sales_branding_theme_id": "xero", + "xero_quote_terms": "xero", "enable_xero_sync": "xero", "xero_automated_day_floor": "xero", "xero_payroll_calendar_name": "xero", diff --git a/apps/workflow/serializers.py b/apps/workflow/serializers.py index c8964aef0..044cc91c1 100644 --- a/apps/workflow/serializers.py +++ b/apps/workflow/serializers.py @@ -69,6 +69,13 @@ class CompanyDefaultsSerializer(serializers.ModelSerializer): logo_wide = serializers.ImageField(required=False, allow_null=True, write_only=True) logo_url = serializers.SerializerMethodField(read_only=True) logo_wide_url = serializers.SerializerMethodField(read_only=True) + xero_quote_terms = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=4000, + trim_whitespace=False, + ) optional_url_fields = ( "master_quote_template_url", "gdrive_quotes_folder_url", @@ -89,6 +96,12 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: pass return attrs + def validate_xero_quote_terms(self, value: str | None) -> str: + """Reject an explicitly cleared quote-terms mirror.""" + if value is None or not value.strip(): + raise serializers.ValidationError("Xero quote terms must not be blank.") + return value + class Meta: model = CompanyDefaults fields = "__all__" diff --git a/apps/workflow/tests/test_company_defaults_api.py b/apps/workflow/tests/test_company_defaults_api.py index 08970a506..5d99fe38d 100644 --- a/apps/workflow/tests/test_company_defaults_api.py +++ b/apps/workflow/tests/test_company_defaults_api.py @@ -79,3 +79,91 @@ def test_patch_persists_and_clears_xero_sales_branding_theme(self) -> None: self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNone(payload["xero_sales_branding_theme_id"]) + + def test_patch_persists_multiline_xero_quote_terms_exactly(self) -> None: + terms = "First line\n\n Indented final line " + + response = self.client.patch( + "/api/company-defaults/", + {"xero_quote_terms": terms}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(payload["xero_quote_terms"], terms) + self.assertEqual(CompanyDefaults.get_solo().xero_quote_terms, terms) + + def test_company_url_initializes_quote_terms_once(self) -> None: + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + company_url=None, + xero_quote_terms=None, + ) + CompanyDefaults.clear_cache() + + response = self.client.patch( + "/api/company-defaults/", + {"company_url": "https://example.co.nz/"}, + format="json", + ) + payload = response.json() + + expected_terms = ( + "Terms of trade can be found on our website: " + "https://example.co.nz/terms-of-trade" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(payload["xero_quote_terms"], expected_terms) + + response = self.client.patch( + "/api/company-defaults/", + {"company_url": "https://new.example.co.nz"}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(payload["xero_quote_terms"], expected_terms) + + def test_patch_rejects_blank_xero_quote_terms(self) -> None: + response = self.client.patch( + "/api/company-defaults/", + {"xero_quote_terms": " \n "}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + payload["xero_quote_terms"], + ["Xero quote terms must not be blank."], + ) + + def test_patch_rejects_null_xero_quote_terms(self) -> None: + response = self.client.patch( + "/api/company-defaults/", + {"xero_quote_terms": None}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + payload["xero_quote_terms"], + ["Xero quote terms must not be blank."], + ) + + def test_patch_rejects_xero_quote_terms_over_4000_characters(self) -> None: + response = self.client.patch( + "/api/company-defaults/", + {"xero_quote_terms": "x" * 4001}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + payload["xero_quote_terms"], + ["Ensure this field has no more than 4000 characters."], + ) diff --git a/apps/workflow/tests/test_company_defaults_schema.py b/apps/workflow/tests/test_company_defaults_schema.py index 79b510188..a85f4c5c9 100644 --- a/apps/workflow/tests/test_company_defaults_schema.py +++ b/apps/workflow/tests/test_company_defaults_schema.py @@ -242,4 +242,14 @@ def test_xero_section_exposes_sales_branding_theme_selector(self) -> None: self.assertEqual(theme_field["type"], "xero_branding_theme") self.assertEqual(theme_field["label"], "Xero Sales Branding Theme") self.assertFalse(theme_field["read_only"]) - self.assertIn("terms and conditions", theme_field["help_text"]) + self.assertIn("layout and presentation", theme_field["help_text"]) + + terms_field = next( + field + for field in xero_section["fields"] + if field["key"] == "xero_quote_terms" + ) + self.assertEqual(terms_field["type"], "textarea") + self.assertEqual(terms_field["label"], "Xero Quote Terms") + self.assertFalse(terms_field["read_only"]) + self.assertIn("Terms (Quotes)", terms_field["help_text"]) diff --git a/apps/workflow/tests/test_localdate_regression.py b/apps/workflow/tests/test_localdate_regression.py index c19e60446..5e1d43d64 100644 --- a/apps/workflow/tests/test_localdate_regression.py +++ b/apps/workflow/tests/test_localdate_regression.py @@ -335,7 +335,8 @@ def test_build_payload_uses_nz_local_date(self): patch.object(manager, "get_line_items", return_value=[]), ): payload = manager.build_payload( - document_theme_external_id=DOCUMENT_THEME_ID + document_theme_external_id=DOCUMENT_THEME_ID, + terms="Client-approved quote terms", ) self.assertEqual(payload.date, NZ_DATE) diff --git a/apps/workflow/tests/test_xero_branding_themes.py b/apps/workflow/tests/test_xero_branding_themes.py index 2147f8818..eecb49b3f 100644 --- a/apps/workflow/tests/test_xero_branding_themes.py +++ b/apps/workflow/tests/test_xero_branding_themes.py @@ -28,6 +28,7 @@ from apps.workflow.views.xero.xero_view import XeroAuthenticationResult THEME_ID = "11111111-2222-3333-4444-555555555555" +QUOTE_TERMS = "Client-approved quote terms" class XeroBrandingThemeProviderTests(BaseTestCase): @@ -76,7 +77,7 @@ def test_invoice_create_payload_includes_branding_theme_id( @patch("apps.workflow.accounting.xero.provider.process_xero_data", return_value={}) @patch.object(XeroAccountingProvider, "_get_api") - def test_quote_create_payload_includes_branding_theme_without_terms( + def test_quote_create_payload_includes_branding_theme_and_terms( self, mock_get_api: Mock, _mock_process: Mock ) -> None: api = Mock() @@ -104,6 +105,7 @@ def test_quote_create_payload_includes_branding_theme_without_terms( date=date(2026, 7, 16), expiry_date=date(2026, 8, 15), document_theme_external_id=THEME_ID, + terms=QUOTE_TERMS, ) result = XeroAccountingProvider().create_quote(payload) @@ -111,7 +113,7 @@ def test_quote_create_payload_includes_branding_theme_without_terms( self.assertTrue(result.success) sent = api.create_quotes.call_args.kwargs["quotes"]["Quotes"][0] self.assertEqual(sent["BrandingThemeID"], THEME_ID) - self.assertNotIn("Terms", sent) + self.assertEqual(sent["Terms"], QUOTE_TERMS) @patch.object(XeroAccountingProvider, "_get_api") def test_list_document_themes_preserves_xero_order_and_default( @@ -147,7 +149,8 @@ class XeroBrandingThemeConfigurationTests(BaseTestCase): def setUp(self) -> None: defaults = CompanyDefaults.get_solo() CompanyDefaults.objects.filter(pk=defaults.pk).update( - xero_sales_branding_theme_id=None + xero_sales_branding_theme_id=None, + xero_quote_terms=QUOTE_TERMS, ) CompanyDefaults.clear_cache() @@ -219,6 +222,50 @@ def test_configured_theme_does_not_add_a_xero_read(self) -> None: self.assertEqual(selected_id, THEME_ID) manager.provider.list_document_themes.assert_not_called() + def test_quote_creation_stops_when_terms_are_unconfigured(self) -> None: + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=uuid.UUID(THEME_ID), + xero_quote_terms=None, + ) + CompanyDefaults.clear_cache() + manager = XeroQuoteManager( + company=self.company, job=self.job, staff=self.test_staff + ) + manager.provider = Mock() + + result = manager.create_document() + + self.assertFalse(result["success"]) + self.assertEqual(result["status"], 400) + self.assertEqual(result["error_type"], "configuration_error") + error = result["error"] + assert error is not None + self.assertIn("Configure Xero quote terms", error) + manager.provider.create_quote.assert_not_called() + + def test_quote_creation_stops_when_terms_exceed_xero_limit(self) -> None: + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=uuid.UUID(THEME_ID), + xero_quote_terms="x" * 4001, + ) + CompanyDefaults.clear_cache() + manager = XeroQuoteManager( + company=self.company, job=self.job, staff=self.test_staff + ) + manager.provider = Mock() + + result = manager.create_document() + + self.assertFalse(result["success"]) + self.assertEqual(result["status"], 400) + self.assertEqual(result["error_type"], "configuration_error") + error = result["error"] + assert error is not None + self.assertIn("no more than 4000 characters", error) + manager.provider.create_quote.assert_not_called() + class SalesBrandingThemeResolutionTests(BaseTestCase): """Migration and setup share one provider-order selection contract.""" diff --git a/apps/workflow/tests/test_xero_document_error_handling.py b/apps/workflow/tests/test_xero_document_error_handling.py index e94f372b3..1ecba3a96 100644 --- a/apps/workflow/tests/test_xero_document_error_handling.py +++ b/apps/workflow/tests/test_xero_document_error_handling.py @@ -30,7 +30,8 @@ class XeroDocumentManagerErrorContractTests(BaseTestCase): def setUp(self) -> None: defaults = CompanyDefaults.get_solo() CompanyDefaults.objects.filter(pk=defaults.pk).update( - xero_sales_branding_theme_id=uuid.UUID(THEME_ID) + xero_sales_branding_theme_id=uuid.UUID(THEME_ID), + xero_quote_terms="Client-approved quote terms", ) CompanyDefaults.clear_cache() diff --git a/apps/workflow/tests/test_xero_document_raw_json.py b/apps/workflow/tests/test_xero_document_raw_json.py index bf8359d1e..0d2183346 100644 --- a/apps/workflow/tests/test_xero_document_raw_json.py +++ b/apps/workflow/tests/test_xero_document_raw_json.py @@ -49,7 +49,8 @@ def setUp(self): # provider call; without it create_document stops at the config guard. defaults = CompanyDefaults.get_solo() CompanyDefaults.objects.filter(pk=defaults.pk).update( - xero_sales_branding_theme_id=uuid.uuid4() + xero_sales_branding_theme_id=uuid.uuid4(), + xero_quote_terms="Client-approved quote terms", ) CompanyDefaults.clear_cache() diff --git a/apps/workflow/tests/test_xero_instance_templates.py b/apps/workflow/tests/test_xero_instance_templates.py index cba661833..24e14c20c 100644 --- a/apps/workflow/tests/test_xero_instance_templates.py +++ b/apps/workflow/tests/test_xero_instance_templates.py @@ -59,6 +59,13 @@ def test_demo_seed_fixtures_load_current_demo_contract(self) -> None: defaults = CompanyDefaults.objects.get(pk=1) self.assertEqual(defaults.company_name, "Demo Company") + self.assertEqual( + defaults.xero_quote_terms, + ( + "Terms of trade can be found on our website: " + "https://www.democompany.example.com/terms-of-trade" + ), + ) self.assertEqual( str(defaults.shop_company_id), "00000000-0000-0000-0000-000000000001", diff --git a/apps/workflow/tests/test_xero_quote_pdf.py b/apps/workflow/tests/test_xero_quote_pdf.py new file mode 100644 index 000000000..0e204c460 --- /dev/null +++ b/apps/workflow/tests/test_xero_quote_pdf.py @@ -0,0 +1,183 @@ +"""Tests for native Xero quote PDF inspection.""" + +from __future__ import annotations + +import json +import tempfile +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 + +from django.core.management import call_command +from django.test import SimpleTestCase +from reportlab.pdfgen import canvas + +from apps.testing import BaseTestCase +from apps.workflow.accounting.quote_pdf_service import ( + QuotePdfInspection, + inspect_quote_pdf, +) +from apps.workflow.accounting.types import QuotePdfDocument +from apps.workflow.accounting.xero.provider import XeroAccountingProvider +from apps.workflow.models import CompanyDefaults + +EXPECTED_TERMS = "Terms of trade can be found" +REMOTE_THEME_ID = "11111111-2222-3333-4444-555555555555" + + +def _write_pdf(text_lines: list[str]) -> Path: + temporary = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + temporary.close() + pdf_path = Path(temporary.name) + document = canvas.Canvas(str(pdf_path)) + vertical_position = 800 + for line in text_lines: + document.drawString(40, vertical_position, line) + vertical_position -= 20 + document.save() + return pdf_path + + +class XeroQuotePdfProviderTests(SimpleTestCase): + """The provider must use Xero's rendered PDF, not recreate a local document.""" + + @patch.object(XeroAccountingProvider, "_get_api") + def test_download_quote_pdf_returns_xero_file_and_theme( + self, mock_get_api: Mock + ) -> None: + quote_id = str(uuid4()) + pdf_path = _write_pdf([EXPECTED_TERMS]) + api = Mock() + api.get_quote.return_value = SimpleNamespace( + quotes=[ + SimpleNamespace( + quote_id=quote_id, + branding_theme_id=REMOTE_THEME_ID, + ) + ] + ) + api.get_quote_as_pdf.return_value = str(pdf_path) + mock_get_api.return_value = (api, "tenant-id") + + result = XeroAccountingProvider().download_quote_pdf(quote_id) + + self.assertEqual(result.external_id, quote_id) + self.assertEqual(result.document_theme_external_id, REMOTE_THEME_ID) + self.assertEqual(result.temporary_file_path, pdf_path) + api.get_quote.assert_called_once_with("tenant-id", quote_id) + api.get_quote_as_pdf.assert_called_once_with("tenant-id", quote_id) + pdf_path.unlink() + + +class QuotePdfInspectionTests(BaseTestCase): + """PDF rendering can regress despite a correct BrandingThemeID payload.""" + + def setUp(self) -> None: + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=UUID(REMOTE_THEME_ID) + ) + CompanyDefaults.clear_cache() + + def _provider_for_pdf(self, quote_id: UUID, pdf_path: Path) -> Mock: + provider = Mock() + provider.download_quote_pdf.return_value = QuotePdfDocument( + external_id=str(quote_id), + document_theme_external_id=REMOTE_THEME_ID, + temporary_file_path=pdf_path, + ) + return provider + + @patch("apps.workflow.accounting.quote_pdf_service.get_provider") + def test_terms_marker_survives_pdf_line_wrapping( + self, mock_get_provider: Mock + ) -> None: + quote_id = uuid4() + pdf_path = _write_pdf(["Terms of trade", "can be found online"]) + mock_get_provider.return_value = self._provider_for_pdf(quote_id, pdf_path) + + result = inspect_quote_pdf(quote_id, EXPECTED_TERMS) + + self.assertTrue(result.contains_expected_text) + self.assertEqual(result.page_count, 1) + self.assertEqual(result.remote_branding_theme_id, REMOTE_THEME_ID) + self.assertEqual(result.configured_branding_theme_id, REMOTE_THEME_ID) + self.assertFalse(pdf_path.exists()) + + @patch("apps.workflow.accounting.quote_pdf_service.get_provider") + def test_missing_or_differently_cased_terms_marker_is_red( + self, mock_get_provider: Mock + ) -> None: + quote_id = uuid4() + pdf_path = _write_pdf(["TERMS OF TRADE CAN BE FOUND online"]) + mock_get_provider.return_value = self._provider_for_pdf(quote_id, pdf_path) + + result = inspect_quote_pdf(quote_id, EXPECTED_TERMS) + + self.assertFalse(result.contains_expected_text) + self.assertFalse(pdf_path.exists()) + + @patch("apps.workflow.accounting.quote_pdf_service.get_provider") + def test_terms_marker_survives_xero_text_layer_without_word_spaces( + self, mock_get_provider: Mock + ) -> None: + quote_id = uuid4() + pdf_path = _write_pdf(["Termsoftradecanbefoundonline"]) + mock_get_provider.return_value = self._provider_for_pdf(quote_id, pdf_path) + + result = inspect_quote_pdf(quote_id, EXPECTED_TERMS) + + self.assertTrue(result.contains_expected_text) + self.assertFalse(pdf_path.exists()) + + @patch("apps.workflow.accounting.quote_pdf_service.get_provider") + def test_unreadable_pdf_still_removes_download( + self, mock_get_provider: Mock + ) -> None: + quote_id = uuid4() + temporary = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + temporary.write(b"not a PDF") + temporary.close() + pdf_path = Path(temporary.name) + mock_get_provider.return_value = self._provider_for_pdf(quote_id, pdf_path) + + with self.assertRaises(Exception): + inspect_quote_pdf(quote_id, EXPECTED_TERMS) + + self.assertFalse(pdf_path.exists()) + + +class InspectXeroQuotePdfCommandTests(SimpleTestCase): + """The E2E subprocess contract must remain structured and parseable.""" + + @patch("apps.workflow.management.commands.inspect_xero_quote_pdf.inspect_quote_pdf") + def test_command_emits_one_json_result(self, mock_inspect: Mock) -> None: + quote_id = uuid4() + mock_inspect.return_value = QuotePdfInspection( + quote_id=str(quote_id), + remote_branding_theme_id=REMOTE_THEME_ID, + configured_branding_theme_id=REMOTE_THEME_ID, + page_count=2, + contains_expected_text=False, + ) + output = StringIO() + + call_command( + "inspect_xero_quote_pdf", + str(quote_id), + expected_text=EXPECTED_TERMS, + stdout=output, + ) + + self.assertEqual( + json.loads(output.getvalue()), + { + "configured_branding_theme_id": REMOTE_THEME_ID, + "contains_expected_text": False, + "page_count": 2, + "quote_id": str(quote_id), + "remote_branding_theme_id": REMOTE_THEME_ID, + }, + ) diff --git a/apps/workflow/tests/test_xero_readonly_provider.py b/apps/workflow/tests/test_xero_readonly_provider.py index e90f57316..26f43f2c0 100644 --- a/apps/workflow/tests/test_xero_readonly_provider.py +++ b/apps/workflow/tests/test_xero_readonly_provider.py @@ -123,7 +123,8 @@ def setUp(self) -> None: # provider call; without it create_document stops at the config guard. defaults = CompanyDefaults.get_solo() CompanyDefaults.objects.filter(pk=defaults.pk).update( - xero_sales_branding_theme_id=uuid.uuid4() + xero_sales_branding_theme_id=uuid.uuid4(), + xero_quote_terms="Client-approved quote terms", ) CompanyDefaults.clear_cache() diff --git a/apps/workflow/views/xero/xero_base_manager.py b/apps/workflow/views/xero/xero_base_manager.py index 37f97623c..47630d5ab 100644 --- a/apps/workflow/views/xero/xero_base_manager.py +++ b/apps/workflow/views/xero/xero_base_manager.py @@ -126,6 +126,14 @@ def get_xero_sales_branding_theme_id() -> str | None: return None return str(theme_id) + @staticmethod + def get_xero_quote_terms() -> str | None: + """Return the terms explicitly sent on API-created Xero quotes.""" + terms = CompanyDefaults.get_solo().xero_quote_terms + if terms is None or not terms.strip(): + return None + return terms + def validate_company(self): """ Ensures the company exists and is synced with Xero. diff --git a/apps/workflow/views/xero/xero_quote_manager.py b/apps/workflow/views/xero/xero_quote_manager.py index 3a2697575..82194a704 100644 --- a/apps/workflow/views/xero/xero_quote_manager.py +++ b/apps/workflow/views/xero/xero_quote_manager.py @@ -132,6 +132,7 @@ def build_payload( breakdown: bool = True, *, document_theme_external_id: str, + terms: str, ) -> QuotePayload: """Build a provider-agnostic quote payload from the job and company.""" if not self.job: @@ -147,6 +148,7 @@ def build_payload( date=today, expiry_date=today + timedelta(days=30), document_theme_external_id=document_theme_external_id, + terms=terms, reference=( self.job.order_number if hasattr(self.job, "order_number") and self.job.order_number @@ -180,9 +182,34 @@ def create_document(self, breakdown: bool = True) -> XeroDocumentResponse: "status": 400, } + terms = self.get_xero_quote_terms() + if terms is None: + return { + "success": False, + "error": ( + "Configure Xero quote terms in Company Settings before " + "creating a quote. Xero does not apply its quote terms " + "default to API-created quotes." + ), + "error_type": "configuration_error", + "status": 400, + } + + if len(terms) > 4000: + return { + "success": False, + "error": ( + "Xero quote terms must be no more than 4000 characters. " + "Shorten them in Company Settings before creating a quote." + ), + "error_type": "configuration_error", + "status": 400, + } + payload = self.build_payload( breakdown=breakdown, document_theme_external_id=document_theme_external_id, + terms=terms, ) result = self.provider.create_quote(payload) diff --git a/docs/client_onboarding.md b/docs/client_onboarding.md index dd68fc37f..b5717da76 100644 --- a/docs/client_onboarding.md +++ b/docs/client_onboarding.md @@ -90,10 +90,15 @@ The client needs a Xero subscription. DocketWorks handles jobs and delegates inv - Used for leave, admin time, training, etc. **Sales Branding Theme** (Settings > Invoice settings): -- Ensure one branding theme contains the terms and conditions required on both - quotes and invoices -- Select the terms-bearing theme in DocketWorks Company Settings before - production finalisation. Demo seeding may select the first available theme. +- Configure the client's required quote and invoice presentation. +- Enter the approved quote wording in Xero's **Terms (Quotes)** field. +- Select the theme in DocketWorks Company Settings before production + finalisation. Demo seeding may select the first available theme. +- Review the DocketWorks **Xero quote terms** initially generated from the + company website's `/terms-of-trade` page, and replace it if the approved + wording differs. DocketWorks sends this copy on API-created quotes; Xero's + copy is required for emergency quotes created directly in Xero. Keep the two + fields manually in sync whenever the wording changes. ### 2b. You create the Xero Developer App @@ -260,8 +265,9 @@ In Admin > Settings, configure: - Starting job/PO numbers and PO prefix - Google Drive folder IDs (Shared Drive, How We Work, SOPs, Reference Library) - Quote template ID and quotes folder ID (if applicable) -- Xero sales branding theme (confirm the selected theme contains the required - quote and invoice terms) +- Xero sales branding theme (controls quote and invoice presentation) +- Xero quote terms (copy the approved wording exactly to both DocketWorks and + Xero **Terms (Quotes)**; keep both fields in sync) - KPI thresholds (optional, can be tuned later) ### 7c. Create Shop Jobs diff --git a/docs/instance-setup-demo.md b/docs/instance-setup-demo.md index b97e88bd5..e8bebe942 100644 --- a/docs/instance-setup-demo.md +++ b/docs/instance-setup-demo.md @@ -38,6 +38,11 @@ scripts/server/dw-run.sh <client>-uat python scripts/restore_checks/check_xero_a Log in as `defaultadmin@example.com` / `Default-admin-password`, open Admin > Xero, and complete the existing OAuth flow. +In Admin > Settings, enter demo wording in **Xero quote terms** that includes +the exact text `Terms of trade can be found`. Copy the same wording to Xero +**Terms (Quotes)**. DocketWorks sends its copy on API-created quotes; Xero's +copy covers quotes created directly in Xero. + ## 4. Finalise onboarding ```bash @@ -55,6 +60,7 @@ after correcting the cause. After a monthly Xero Demo Company reset, run `xero --setup --seed-xero`; setup discovers the replacement tenant and updates CompanyDefaults and the cache. +Restore the matching Xero **Terms (Quotes)** wording after the reset. ## 5. Verify @@ -63,6 +69,8 @@ discovers the replacement tenant and updates CompanyDefaults and the cache. - Admin > Xero reports connected. - A normal Xero sync completes without errors. - A test job, timesheet, quote, and invoice work as expected. +- The native Xero PDF for a DocketWorks-created quote contains + `Terms of trade can be found`. Logins: diff --git a/docs/instance-setup-production.md b/docs/instance-setup-production.md index c7139f29c..5fc4ff2f9 100644 --- a/docs/instance-setup-production.md +++ b/docs/instance-setup-production.md @@ -47,8 +47,11 @@ Log in as `defaultadmin@example.com` / `Default-admin-password`, open Admin > Xero, and complete the existing OAuth flow. In Admin > Settings, explicitly select the live Xero sales branding theme that -contains the client's required quote and invoice terms. Production finalisation -does not select the first theme automatically. +controls the client's required quote and invoice presentation. Enter the +approved quote wording in DocketWorks **Xero quote terms** (review the initial +wording generated from the company website's `/terms-of-trade` page), then copy +it exactly to Xero **Terms (Quotes)** for emergency quotes created directly in Xero. +Production finalisation does not select the first theme automatically. ## 4. Finalise onboarding @@ -71,7 +74,10 @@ disabled. Fix the source configuration and rerun the same command. - Exactly nine shop jobs are present. - Admin > Xero reports connected. - A normal Xero sync completes without errors. -- Test quote and invoice PDFs use the selected terms-bearing theme. +- Test quote and invoice PDFs use the selected branding theme. +- A DocketWorks-created quote PDF contains the configured quote terms. +- DocketWorks **Xero quote terms** and Xero **Terms (Quotes)** contain the same + approved wording. - Password reset email works. - Change the default admin password and have imported staff reset theirs. diff --git a/docs/restore-prod-to-nonprod.md b/docs/restore-prod-to-nonprod.md index f60c1ef7c..6c0a3a761 100644 --- a/docs/restore-prod-to-nonprod.md +++ b/docs/restore-prod-to-nonprod.md @@ -314,8 +314,11 @@ API working: 174 active jobs, 23 archived ``` Open one recreated quote and one recreated invoice in Xero and confirm their -PDFs use the selected destination branding theme and contain the required -terms. A successful API seed alone does not verify document presentation. +PDFs use the selected destination branding theme. Confirm the DocketWorks-created +quote PDF contains the quote terms configured in DocketWorks, and copy the same +wording to the destination Xero organisation's **Terms (Quotes)** field for +direct-Xero fallback quotes. A successful API seed alone does not verify +document content or presentation. #### Snapshot Verified Database diff --git a/frontend/docs/e2e_testing_strategy.md b/frontend/docs/e2e_testing_strategy.md index 327fee172..01b0f4351 100644 --- a/frontend/docs/e2e_testing_strategy.md +++ b/frontend/docs/e2e_testing_strategy.md @@ -13,7 +13,14 @@ SMTP traffic) is accepted because mocked integrations have repeatedly hidden real-world breakage. - **Xero** — real Xero **demo company** in dev/UAT. Tests - create/delete invoices, quotes, POs against the demo org. + create/delete invoices, quotes, POs against the demo org. DocketWorks + sends the configured Xero quote terms in the quote API payload. In the + demo company only, those terms must contain the exact text + `Terms of trade can be found`; the quote E2E requires the native Xero + PDF to contain it. This marker is a demo-only fixture contract, not a + validation rule for production wording. Xero's Terms (Quotes) setting + is manually kept in sync only as a fallback for quotes created directly + in Xero. - **AI providers** (Claude / Gemini / Mistral) — real API calls. Tests consume credits. - **Email** — real SMTP to a test recipient. Catches template/auth diff --git a/frontend/schema.yml b/frontend/schema.yml index ce828350d..3a695fbf6 100644 --- a/frontend/schema.yml +++ b/frontend/schema.yml @@ -10701,6 +10701,10 @@ components: type: string nullable: true readOnly: true + xero_quote_terms: + type: string + nullable: true + maxLength: 4000 company_name: type: string readOnly: true @@ -10833,10 +10837,9 @@ components: format: uuid nullable: true title: Xero sales branding theme - description: Branding theme applied to every quote and sales invoice created - in Xero. Select a theme containing the required terms and conditions; - it is configured during Xero setup and required before sales documents - can be created. + description: Controls the layout and presentation of every quote and sales + invoice created in Xero. It is configured during Xero setup and required + before sales documents can be created. enable_xero_sync: type: boolean description: Gate for Xero sync. Defaults True (prod). Dev fixture sets @@ -11087,6 +11090,10 @@ components: format: binary writeOnly: true nullable: true + xero_quote_terms: + type: string + nullable: true + maxLength: 4000 company_acronym: type: string nullable: true @@ -11218,10 +11225,9 @@ components: format: uuid nullable: true title: Xero sales branding theme - description: Branding theme applied to every quote and sales invoice created - in Xero. Select a theme containing the required terms and conditions; - it is configured during Xero setup and required before sales documents - can be created. + description: Controls the layout and presentation of every quote and sales + invoice created in Xero. It is configured during Xero setup and required + before sales documents can be created. enable_xero_sync: type: boolean description: Gate for Xero sync. Defaults True (prod). Dev fixture sets @@ -16960,6 +16966,10 @@ components: format: binary writeOnly: true nullable: true + xero_quote_terms: + type: string + nullable: true + maxLength: 4000 company_acronym: type: string nullable: true @@ -17091,10 +17101,9 @@ components: format: uuid nullable: true title: Xero sales branding theme - description: Branding theme applied to every quote and sales invoice created - in Xero. Select a theme containing the required terms and conditions; - it is configured during Xero setup and required before sales documents - can be created. + description: Controls the layout and presentation of every quote and sales + invoice created in Xero. It is configured during Xero setup and required + before sales documents can be created. enable_xero_sync: type: boolean description: Gate for Xero sync. Defaults True (prod). Dev fixture sets diff --git a/frontend/src/api/generated/api.ts b/frontend/src/api/generated/api.ts index ec40a0bfe..3bef4ed34 100644 --- a/frontend/src/api/generated/api.ts +++ b/frontend/src/api/generated/api.ts @@ -905,6 +905,7 @@ const CompanyDefaults = z.object({ id: z.number().int(), logo_url: z.string().nullable(), logo_wide_url: z.string().nullable(), + xero_quote_terms: z.string().max(4000).nullish(), company_name: z.string(), company_acronym: z.string().max(10).nullish(), time_markup: z.number().gt(-1000).lt(1000).optional(), @@ -971,6 +972,7 @@ const CompanyDefaults = z.object({ const CompanyDefaultsRequest = z.object({ logo: z.instanceof(File).nullish(), logo_wide: z.instanceof(File).nullish(), + xero_quote_terms: z.string().max(4000).nullish(), company_acronym: z.string().max(10).nullish(), time_markup: z.number().gt(-1000).lt(1000).optional(), materials_markup: z.number().gt(-1000).lt(1000).optional(), @@ -1035,6 +1037,7 @@ const PatchedCompanyDefaultsRequest = z .object({ logo: z.instanceof(File).nullable(), logo_wide: z.instanceof(File).nullable(), + xero_quote_terms: z.string().max(4000).nullable(), company_acronym: z.string().max(10).nullable(), time_markup: z.number().gt(-1000).lt(1000), materials_markup: z.number().gt(-1000).lt(1000), diff --git a/frontend/src/components/SectionForm.vue b/frontend/src/components/SectionForm.vue index 810d159b5..9beae5721 100644 --- a/frontend/src/components/SectionForm.vue +++ b/frontend/src/components/SectionForm.vue @@ -1,7 +1,75 @@ <template> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4"> <template v-for="field in genericFieldsForRender" :key="field.key"> + <div + v-if="field.key === XERO_QUOTE_TERMS_KEY" + class="md:col-span-2 flex flex-col gap-2 text-sm" + > + <div class="flex flex-wrap items-center justify-between gap-2"> + <label + :for="xeroQuoteTermsInputId" + class="flex items-center gap-2 font-medium text-gray-700" + > + <component :is="field.icon" class="w-4 h-4 text-indigo-400" /> + {{ field.label }} + </label> + <a + :href="XERO_INVOICE_SETTINGS_URL" + target="_blank" + rel="noopener noreferrer" + class="inline-flex items-center gap-1 text-xs font-medium text-indigo-600 hover:underline" + data-automation-id="SectionForm-xero-quote-terms-open-xero" + > + Open Xero Invoice Settings + <ExternalLink class="w-3.5 h-3.5" aria-hidden="true" /> + </a> + </div> + + <Textarea + :id="xeroQuoteTermsInputId" + v-model="localForm[field.key] as string | undefined" + class="min-h-40 resize-y text-sm" + :class="{ 'bg-gray-100 cursor-not-allowed': field.readOnly }" + :data-automation-id="`SectionForm-${section}-field-${field.key}`" + :readonly="field.readOnly" + :maxlength="XERO_QUOTE_TERMS_MAX_LENGTH" + :aria-invalid="xeroQuoteTermsBlank" + :aria-describedby="xeroQuoteTermsDescribedBy" + /> + + <div class="flex items-start justify-between gap-4"> + <div :id="xeroQuoteTermsHelpId" class="space-y-1 text-xs text-gray-500"> + <p v-if="field.help_text">{{ field.help_text }}</p> + <p>Plain text sent exactly as entered. Final layout is controlled by Xero.</p> + <p> + DocketWorks sends these terms in the quote API payload. Manually keep Xero's Terms + (Quotes) setting in sync as an emergency fallback for quotes created directly in Xero. + </p> + </div> + <span + :id="xeroQuoteTermsCountId" + class="shrink-0 text-xs tabular-nums text-gray-500" + :class="{ + 'text-amber-700': xeroQuoteTermsCharacterCount >= XERO_QUOTE_TERMS_WARNING_LENGTH, + }" + data-automation-id="SectionForm-xero-quote-terms-count" + > + {{ xeroQuoteTermsCharacterCount.toLocaleString() }} / + {{ XERO_QUOTE_TERMS_MAX_LENGTH.toLocaleString() }} characters + </span> + </div> + <p + v-if="xeroQuoteTermsBlank" + :id="xeroQuoteTermsValidationId" + class="text-xs text-red-600" + data-automation-id="SectionForm-xero-quote-terms-validation" + > + Enter quote terms before creating quotes in Xero. + </p> + </div> + <label + v-else class="flex flex-col gap-1 text-sm font-medium" :class="FIELD_COL_SPAN_OVERRIDES[field.key] === 2 ? 'md:col-span-2' : ''" > @@ -220,7 +288,7 @@ import Input from '@/components/ui/input/Input.vue' import Textarea from '@/components/ui/textarea/Textarea.vue' import Checkbox from '@/components/ui/checkbox/Checkbox.vue' import Calendar from '@/components/ui/calendar/Calendar.vue' -import { Clock, Upload, Trash2 } from 'lucide-vue-next' +import { Clock, ExternalLink, Upload, Trash2 } from 'lucide-vue-next' import { ref, computed, watch } from 'vue' import { z } from 'zod' import { api } from '@/api/client' @@ -242,6 +310,26 @@ const emit = defineEmits<{ (e: 'update:modelValue', value: Record<string, unknow const { getFieldsForSection, getSpecialHandler } = useSettingsSchema() const localForm = ref({ ...props.modelValue }) +const XERO_QUOTE_TERMS_KEY = 'xero_quote_terms' +const XERO_QUOTE_TERMS_MAX_LENGTH = 4000 +const XERO_QUOTE_TERMS_WARNING_LENGTH = 3600 +const XERO_INVOICE_SETTINGS_URL = 'https://go.xero.com/Settings/InvoiceSettings/' +const xeroQuoteTermsInputId = 'SectionForm-xero-field-xero_quote_terms-input' +const xeroQuoteTermsHelpId = 'SectionForm-xero-field-xero_quote_terms-help' +const xeroQuoteTermsCountId = 'SectionForm-xero-field-xero_quote_terms-count' +const xeroQuoteTermsValidationId = 'SectionForm-xero-field-xero_quote_terms-validation' +const xeroQuoteTermsValue = computed(() => { + const value = localForm.value[XERO_QUOTE_TERMS_KEY] + return typeof value === 'string' ? value : '' +}) +const xeroQuoteTermsCharacterCount = computed(() => xeroQuoteTermsValue.value.length) +const xeroQuoteTermsBlank = computed(() => xeroQuoteTermsValue.value.trim().length === 0) +const xeroQuoteTermsDescribedBy = computed(() => { + const ids = [xeroQuoteTermsHelpId, xeroQuoteTermsCountId] + if (xeroQuoteTermsBlank.value) ids.push(xeroQuoteTermsValidationId) + return ids.join(' ') +}) + // Layout overrides — purely a frontend concern. The backend says *what* a // setting is (label, type, section, help_text); this dict says *how* the // form lays it out on a 2-column grid. Default is 1; entries here only diff --git a/frontend/src/components/__tests__/SectionForm.test.ts b/frontend/src/components/__tests__/SectionForm.test.ts index f1dc12adc..34c579e06 100644 --- a/frontend/src/components/__tests__/SectionForm.test.ts +++ b/frontend/src/components/__tests__/SectionForm.test.ts @@ -154,6 +154,92 @@ describe('SectionForm', () => { ]) }) + it('renders an accessible plain-text Xero quote terms editor', async () => { + settingsFields.splice(0, settingsFields.length, { + key: 'xero_quote_terms', + label: 'Quote Terms', + type: 'textarea', + required: false, + help_text: 'Terms and conditions sent with Xero quotes.', + section: 'xero', + icon: 'span', + readOnly: false, + }) + + const wrapper = mount(SectionForm, { + props: { + section: 'xero', + modelValue: { xero_quote_terms: null }, + }, + }) + + const textarea = wrapper.get('[data-automation-id="SectionForm-xero-field-xero_quote_terms"]') + expect(textarea.element.tagName).toBe('TEXTAREA') + expect(textarea.element.parentElement?.className).toContain('md:col-span-2') + expect(textarea.attributes('maxlength')).toBe('4000') + expect(textarea.classes()).toContain('min-h-40') + expect(textarea.classes()).toContain('resize-y') + expect(textarea.attributes('aria-invalid')).toBe('true') + expect(textarea.attributes('aria-describedby')).toBe( + [ + 'SectionForm-xero-field-xero_quote_terms-help', + 'SectionForm-xero-field-xero_quote_terms-count', + 'SectionForm-xero-field-xero_quote_terms-validation', + ].join(' '), + ) + expect(wrapper.get('[data-automation-id="SectionForm-xero-quote-terms-count"]').text()).toBe( + '0 / 4,000 characters', + ) + expect( + wrapper.get('[data-automation-id="SectionForm-xero-quote-terms-validation"]').text(), + ).toContain('Enter quote terms') + + const xeroSettingsLink = wrapper.get( + '[data-automation-id="SectionForm-xero-quote-terms-open-xero"]', + ) + expect(xeroSettingsLink.attributes()).toMatchObject({ + href: 'https://go.xero.com/Settings/InvoiceSettings/', + target: '_blank', + rel: 'noopener noreferrer', + }) + expect(wrapper.text()).toContain('DocketWorks sends these terms in the quote API payload') + expect(wrapper.text()).toContain('emergency fallback') + }) + + it('preserves Xero quote terms whitespace and warns near the character limit', async () => { + settingsFields.splice(0, settingsFields.length, { + key: 'xero_quote_terms', + label: 'Quote Terms', + type: 'textarea', + required: false, + help_text: 'Terms and conditions sent with Xero quotes.', + section: 'xero', + icon: 'span', + readOnly: false, + }) + + const wrapper = mount(SectionForm, { + props: { + section: 'xero', + modelValue: { xero_quote_terms: null }, + }, + }) + const textarea = wrapper.get('[data-automation-id="SectionForm-xero-field-xero_quote_terms"]') + const exactTerms = ' First line\nSecond line ' + + await textarea.setValue(exactTerms) + + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ xero_quote_terms: exactTerms }]) + expect(textarea.attributes('aria-invalid')).toBe('false') + expect(textarea.attributes('aria-describedby')).not.toContain('validation') + + await textarea.setValue('x'.repeat(3600)) + + const counter = wrapper.get('[data-automation-id="SectionForm-xero-quote-terms-count"]') + expect(counter.text()).toBe('3,600 / 4,000 characters') + expect(counter.classes()).toContain('text-amber-700') + }) + it('preserves a configured branding theme that Xero no longer returns', async () => { settingsFields.splice(0, settingsFields.length, { key: 'xero_sales_branding_theme_id', diff --git a/frontend/tests/job/job-xero-quote.spec.ts b/frontend/tests/job/job-xero-quote.spec.ts index abe453df4..41964c0f4 100644 --- a/frontend/tests/job/job-xero-quote.spec.ts +++ b/frontend/tests/job/job-xero-quote.spec.ts @@ -1,6 +1,68 @@ +import { spawnSync } from 'child_process' +import path from 'path' +import { fileURLToPath } from 'url' +import { z } from 'zod' +import { schemas } from '@/api/generated/api' import { test, expect } from '../fixtures/auth' import { autoId } from '../fixtures/helpers' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '../../..') +const managePy = path.join(repoRoot, 'manage.py') +const expectedTermsText = 'Terms of trade can be found' + +const quotePdfInspectionSchema = z.object({ + quote_id: z.string().uuid(), + remote_branding_theme_id: z.string().uuid().nullable(), + configured_branding_theme_id: z.string().uuid().nullable(), + page_count: z.number().int().positive(), + contains_expected_text: z.boolean(), +}) + +type QuotePdfInspection = z.infer<typeof quotePdfInspectionSchema> + +const inspectXeroQuotePdf = (quoteId: string): QuotePdfInspection => { + const result = spawnSync( + 'python', + [managePy, 'inspect_xero_quote_pdf', quoteId, '--expected-text', expectedTermsText], + { + cwd: repoRoot, + encoding: 'utf8', + timeout: 120_000, + }, + ) + + if (result.error) { + throw new Error( + `Xero quote PDF inspection could not run: ${result.error.message}\n${result.stderr}\n${result.stdout}`, + ) + } + if (result.status !== 0) { + throw new Error( + `Xero quote PDF inspection failed with exit code ${result.status}:\n${result.stderr}\n${result.stdout}`, + ) + } + + const finalOutputLine = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .at(-1) + if (!finalOutputLine) { + throw new Error('Xero quote PDF inspection produced no JSON output') + } + + let output: unknown + try { + output = JSON.parse(finalOutputLine) + } catch (error) { + throw new Error(`Xero quote PDF inspection final output was not JSON: ${finalOutputLine}`, { + cause: error, + }) + } + return quotePdfInspectionSchema.parse(output) +} + const getJobIdFromUrl = (url: string): string => { const match = url.match(/\/jobs\/([a-f0-9-]+)/i) if (!match) { @@ -153,7 +215,7 @@ const normalizeQuoteUiLines = async (page: import('@playwright/test').Page) => { } test.describe('job xero quote', () => { - test.setTimeout(120000) + test.setTimeout(240000) test('create quote in Xero from Quote tab', async ({ authenticatedPage: page, @@ -161,6 +223,22 @@ test.describe('job xero quote', () => { }) => { const jobId = getJobIdFromUrl(sharedEditJobUrl) + const pingResponse = await page.request.get('/api/xero/ping/') + if (!pingResponse.ok()) { + throw new Error( + `Xero status check failed: ${pingResponse.status()} ${pingResponse.statusText()}`, + ) + } + const xeroStatus = schemas.XeroPingResponse.parse(await pingResponse.json()) + if (!xeroStatus.connected) { + throw new Error('Xero is not connected. Connect the demo organisation from /xero.') + } + if (xeroStatus.xero_readonly) { + throw new Error( + 'XERO_READONLY is enabled; this E2E requires write access to the Xero demo organisation.', + ) + } + await page.goto(sharedEditJobUrl) await page.waitForLoadState('networkidle') @@ -184,41 +262,56 @@ test.describe('job xero quote', () => { console.log(`[Quote UI preflight after] ${quoteUiAfter.summary}`) const createQuoteButton = page.getByRole('button', { name: 'Create Quote' }) - if ((await createQuoteButton.count()) > 0) { - await createQuoteButton.click() - await expect(page.getByText('Export Quote to Xero')).toBeVisible({ timeout: 10000 }) - - const responsePromise = page.waitForResponse( - (response) => { - return ( - response.url().includes(`/api/xero/create_quote/${jobId}`) && - response.request().method() === 'POST' - ) - }, - { timeout: 120000 }, - ) + await expect( + createQuoteButton, + 'The fresh E2E job unexpectedly already has a Xero quote', + ).toBeVisible() + await createQuoteButton.click() + await expect(page.getByText('Export Quote to Xero')).toBeVisible({ timeout: 10000 }) - await page.getByRole('button', { name: 'Send Total Only' }).click() - const response = await responsePromise - if (!response.ok()) { - const body = await response.text() - throw new Error( - `Xero quote create failed: ${response.status()} ${response.statusText()} ${body} | ${quoteSummary} | ${quoteUiAfter.summary}`, + const responsePromise = page.waitForResponse( + (response) => { + return ( + response.url().includes(`/api/xero/create_quote/${jobId}`) && + response.request().method() === 'POST' ) - } + }, + { timeout: 120000 }, + ) - const responseBody = await response.json().catch(() => null) - if (responseBody && responseBody.success === false) { - const errorMessage = - typeof responseBody.error === 'string' && responseBody.error.trim() - ? responseBody.error - : JSON.stringify(responseBody) - throw new Error(`Xero quote create failed: ${errorMessage} | ${quoteSummary}`) - } + await page.getByRole('button', { name: 'Send Total Only' }).click() + const response = await responsePromise + if (!response.ok()) { + const body = await response.text() + throw new Error( + `Xero quote create failed: ${response.status()} ${response.statusText()} ${body} | ${quoteSummary} | ${quoteUiAfter.summary}`, + ) + } + + const responseBody = schemas.XeroDocumentSuccessResponse.parse(await response.json()) + if (!responseBody.success) { + throw new Error(`Xero quote create reported failure | ${quoteSummary}`) } + console.log(`[Xero quote] Created quote ID: ${responseBody.xero_id}`) + await expect(page.getByRole('button', { name: /Open in Xero/ })).toBeVisible({ timeout: 20000, }) + + const inspection = inspectXeroQuotePdf(responseBody.xero_id) + expect(inspection.quote_id).toBe(responseBody.xero_id) + + const diagnostics = [ + `pages=${inspection.page_count}`, + `remote_theme=${inspection.remote_branding_theme_id ?? 'null'}`, + `configured_theme=${inspection.configured_branding_theme_id ?? 'null'}`, + ].join(' ') + // A matching theme ID alone can hide regressions where configured terms + // are omitted from the API payload or the native Xero PDF. + expect( + inspection.contains_expected_text, + `Xero-rendered quote PDF does not contain "${expectedTermsText}" (${diagnostics})`, + ).toBe(true) }) }) From 506860dee2818aba1d115c5241abbf7ee9c3adfe Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 13:03:27 +1200 Subject: [PATCH 47/66] fix: make xero_quote_terms non-null and simplify PDF cleanup Address CodeRabbit review on PR #490. Root cause of two findings was modelling a required setting as nullable. Xero quote terms are mandatory (quote creation 400s without them) and both fixtures already seed a value, so the null state and its once-only auto-derive branch were dead. Make the field non-null with a default: - The request schema no longer advertises a nullable it rejects (finding 3). - Saving any Xero setting can no longer 400 the whole PATCH by round-tripping a null terms value through AdminCompanySectionView.save(). Remove the try/except/finally from inspect_quote_pdf entirely (finding 1): a failed unlink in finally masked the real PDF error. Unlink only on the success path; let errors propagate as a traceback from the ops command. Declined finding 2 (URL-component terms builder): its fix drops the URL path, turning https://sites.google.com/view/acme into https://sites.google.com/terms-of-trade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UTKr8tHRW5iam4YriPSzc --- apps/workflow/accounting/quote_pdf_service.py | 69 ++++++++----------- .../0014_companydefaults_xero_quote_terms.py | 23 ++++--- apps/workflow/models/company_defaults.py | 26 +++---- apps/workflow/serializers.py | 12 ++-- .../tests/test_company_defaults_api.py | 34 +++------ apps/workflow/tests/test_xero_quote_pdf.py | 9 ++- apps/workflow/views/xero/xero_base_manager.py | 2 +- frontend/schema.yml | 5 +- frontend/src/api/generated/api.ts | 6 +- frontend/src/components/SectionForm.vue | 5 +- .../components/__tests__/SectionForm.test.ts | 4 +- 11 files changed, 86 insertions(+), 109 deletions(-) diff --git a/apps/workflow/accounting/quote_pdf_service.py b/apps/workflow/accounting/quote_pdf_service.py index 72450b3e5..a65074c04 100644 --- a/apps/workflow/accounting/quote_pdf_service.py +++ b/apps/workflow/accounting/quote_pdf_service.py @@ -9,7 +9,6 @@ from apps.workflow.accounting.registry import get_provider from apps.workflow.models import CompanyDefaults -from apps.workflow.services.error_persistence import persist_app_error @dataclass(frozen=True) @@ -32,43 +31,35 @@ def inspect_quote_pdf( if not normalized_expected_text: raise ValueError("Expected quote PDF text must not be empty") - document = None - try: - provider = get_provider() - document = provider.download_quote_pdf(str(quote_id)) - reader = PdfReader(document.temporary_file_path) - page_text: list[str] = [] - for page in reader.pages: - extracted = page.extract_text() - if extracted is not None: - page_text.append(extracted) + provider = get_provider() + document = provider.download_quote_pdf(str(quote_id)) + reader = PdfReader(document.temporary_file_path) + page_text: list[str] = [] + for page in reader.pages: + extracted = page.extract_text() + if extracted is not None: + page_text.append(extracted) - if not page_text: - raise ValueError(f"Quote {quote_id} PDF contains no extractable text") + if not page_text: + raise ValueError(f"Quote {quote_id} PDF contains no extractable text") - normalized_document_text = " ".join("\n".join(page_text).split()) - compact_expected_text = "".join(normalized_expected_text.split()) - compact_document_text = "".join(normalized_document_text.split()) - configured_theme_id = CompanyDefaults.get_solo().xero_sales_branding_theme_id - return QuotePdfInspection( - quote_id=document.external_id, - remote_branding_theme_id=document.document_theme_external_id, - configured_branding_theme_id=( - str(configured_theme_id) if configured_theme_id is not None else None - ), - page_count=len(reader.pages), - contains_expected_text=( - normalized_expected_text in normalized_document_text - or compact_expected_text in compact_document_text - ), - ) - except Exception as exc: - persist_app_error(exc) - raise - finally: - if document is not None: - try: - document.temporary_file_path.unlink(missing_ok=True) - except Exception as exc: - persist_app_error(exc) - raise + normalized_document_text = " ".join("\n".join(page_text).split()) + compact_expected_text = "".join(normalized_expected_text.split()) + compact_document_text = "".join(normalized_document_text.split()) + configured_theme_id = CompanyDefaults.get_solo().xero_sales_branding_theme_id + inspection = QuotePdfInspection( + quote_id=document.external_id, + remote_branding_theme_id=document.document_theme_external_id, + configured_branding_theme_id=( + str(configured_theme_id) if configured_theme_id is not None else None + ), + page_count=len(reader.pages), + contains_expected_text=( + normalized_expected_text in normalized_document_text + or compact_expected_text in compact_document_text + ), + ) + # Only on the success path: a failure above leaves the file for inspection, + # and losing a temp file matters less than losing the error that caused it. + document.temporary_file_path.unlink(missing_ok=True) + return inspection diff --git a/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py b/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py index 8c6559a5c..ceb325550 100644 --- a/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py +++ b/apps/workflow/migrations/0014_companydefaults_xero_quote_terms.py @@ -1,11 +1,18 @@ from django.db import migrations, models +# Kept as a literal so this migration stays self-contained; the model's copy of +# the same starting text may drift without changing what was written here. +DEFAULT_QUOTE_TERMS = "Terms of trade can be found on our website." + def populate_default_quote_terms(apps, schema_editor): + """Point rows with a known website at its terms-of-trade page. + + AddField has already given every row DEFAULT_QUOTE_TERMS, so this only + upgrades the ones where a company URL makes a more specific sentence possible. + """ CompanyDefaults = apps.get_model("workflow", "CompanyDefaults") for defaults in CompanyDefaults.objects.exclude(company_url__isnull=True): - if defaults.xero_quote_terms is not None: - continue if not defaults.company_url: continue company_url = defaults.company_url.rstrip("/") @@ -40,15 +47,15 @@ class Migration(migrations.Migration): model_name="companydefaults", name="xero_quote_terms", field=models.TextField( - blank=True, + default=DEFAULT_QUOTE_TERMS, help_text=( - "Terms sent on every quote created by DocketWorks. Initially " - "derived from the company website's /terms-of-trade page. Copy " - "the same text to Xero's Terms (Quotes) setting so quotes created " - "directly in Xero during an outage use the same terms." + "Terms sent on every quote created by DocketWorks. Required — " + "Xero does not apply its own Terms (Quotes) default to quotes " + "created through the API. Copy the same text to Xero's Terms " + "(Quotes) setting so quotes created directly in Xero during an " + "outage use the same terms." ), max_length=4000, - null=True, verbose_name="Xero quote terms", ), ), diff --git a/apps/workflow/models/company_defaults.py b/apps/workflow/models/company_defaults.py index a69a0aa99..9fc58ce54 100644 --- a/apps/workflow/models/company_defaults.py +++ b/apps/workflow/models/company_defaults.py @@ -5,13 +5,9 @@ from django.db.models.base import ModelBase from solo.models import SingletonModel - -def default_xero_quote_terms(company_url: str) -> str: - """Build the initial quote terms from the configured company website.""" - return ( - "Terms of trade can be found on our website: " - f"{company_url.rstrip('/')}/terms-of-trade" - ) +# Starting point for an installation that has not had its terms written yet. +# Real wording is seeded per client by the fixtures and edited in Company Settings. +DEFAULT_XERO_QUOTE_TERMS = "Terms of trade can be found on our website." class CompanyDefaults(SingletonModel): @@ -147,14 +143,13 @@ class CompanyDefaults(SingletonModel): ) xero_quote_terms = models.TextField( max_length=4000, - null=True, - blank=True, + default=DEFAULT_XERO_QUOTE_TERMS, verbose_name="Xero quote terms", help_text=( - "Terms sent on every quote created by DocketWorks. Initially derived " - "from the company website's /terms-of-trade page. Copy the same text to " - "Xero's Terms (Quotes) setting so quotes created directly in Xero during " - "an outage use the same terms." + "Terms sent on every quote created by DocketWorks. Required — Xero does " + "not apply its own Terms (Quotes) default to quotes created through the " + "API. Copy the same text to Xero's Terms (Quotes) setting so quotes " + "created directly in Xero during an outage use the same terms." ), ) enable_xero_sync = models.BooleanField( @@ -383,11 +378,6 @@ def save( update_fields: Iterable[str] | None = None, **kwargs: object, ) -> None: - if self.xero_quote_terms is None and self.company_url: - self.xero_quote_terms = default_xero_quote_terms(self.company_url) - if update_fields is not None: - update_fields = (*update_fields, "xero_quote_terms") - # Check if annual_leave_loading changed - if so, recompute all staff wage_rates loading_changed = False if self.pk: diff --git a/apps/workflow/serializers.py b/apps/workflow/serializers.py index 044cc91c1..ea12bc8d4 100644 --- a/apps/workflow/serializers.py +++ b/apps/workflow/serializers.py @@ -71,8 +71,6 @@ class CompanyDefaultsSerializer(serializers.ModelSerializer): logo_wide_url = serializers.SerializerMethodField(read_only=True) xero_quote_terms = serializers.CharField( required=False, - allow_blank=True, - allow_null=True, max_length=4000, trim_whitespace=False, ) @@ -96,9 +94,13 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: pass return attrs - def validate_xero_quote_terms(self, value: str | None) -> str: - """Reject an explicitly cleared quote-terms mirror.""" - if value is None or not value.strip(): + def validate_xero_quote_terms(self, value: str) -> str: + """Reject whitespace-only terms. + + DRF rejects null and "" from the field itself, but trim_whitespace=False + means its blank check is a bare ``value == ""`` and lets " \\n " through. + """ + if not value.strip(): raise serializers.ValidationError("Xero quote terms must not be blank.") return value diff --git a/apps/workflow/tests/test_company_defaults_api.py b/apps/workflow/tests/test_company_defaults_api.py index 5d99fe38d..c3ad6c02c 100644 --- a/apps/workflow/tests/test_company_defaults_api.py +++ b/apps/workflow/tests/test_company_defaults_api.py @@ -94,37 +94,25 @@ def test_patch_persists_multiline_xero_quote_terms_exactly(self) -> None: self.assertEqual(payload["xero_quote_terms"], terms) self.assertEqual(CompanyDefaults.get_solo().xero_quote_terms, terms) - def test_company_url_initializes_quote_terms_once(self) -> None: - defaults = CompanyDefaults.get_solo() - CompanyDefaults.objects.filter(pk=defaults.pk).update( - company_url=None, - xero_quote_terms=None, - ) - CompanyDefaults.clear_cache() + def test_patch_of_other_xero_field_round_trips_existing_terms(self) -> None: + """The settings form PATCHes every field in a section, terms included. - response = self.client.patch( - "/api/company-defaults/", - {"company_url": "https://example.co.nz/"}, - format="json", - ) - payload = response.json() - - expected_terms = ( - "Terms of trade can be found on our website: " - "https://example.co.nz/terms-of-trade" - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(payload["xero_quote_terms"], expected_terms) + A round-trip of the stored terms alongside another field must not be + mistaken for an attempt to clear them, or no Xero setting is editable. + """ + terms = CompanyDefaults.get_solo().xero_quote_terms + self.assertTrue(terms) response = self.client.patch( "/api/company-defaults/", - {"company_url": "https://new.example.co.nz"}, + {"xero_quote_terms": terms, "xero_shortcode": "ABC123"}, format="json", ) payload = response.json() self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(payload["xero_quote_terms"], expected_terms) + self.assertEqual(payload["xero_shortcode"], "ABC123") + self.assertEqual(payload["xero_quote_terms"], terms) def test_patch_rejects_blank_xero_quote_terms(self) -> None: response = self.client.patch( @@ -151,7 +139,7 @@ def test_patch_rejects_null_xero_quote_terms(self) -> None: self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( payload["xero_quote_terms"], - ["Xero quote terms must not be blank."], + ["This field may not be null."], ) def test_patch_rejects_xero_quote_terms_over_4000_characters(self) -> None: diff --git a/apps/workflow/tests/test_xero_quote_pdf.py b/apps/workflow/tests/test_xero_quote_pdf.py index 0e204c460..382d13e4f 100644 --- a/apps/workflow/tests/test_xero_quote_pdf.py +++ b/apps/workflow/tests/test_xero_quote_pdf.py @@ -12,6 +12,7 @@ from django.core.management import call_command from django.test import SimpleTestCase +from pypdf.errors import PdfReadError from reportlab.pdfgen import canvas from apps.testing import BaseTestCase @@ -133,20 +134,22 @@ def test_terms_marker_survives_xero_text_layer_without_word_spaces( self.assertFalse(pdf_path.exists()) @patch("apps.workflow.accounting.quote_pdf_service.get_provider") - def test_unreadable_pdf_still_removes_download( + def test_unreadable_pdf_raises_and_keeps_the_download( self, mock_get_provider: Mock ) -> None: + """The read error is what the operator needs, not a tidy temp directory.""" quote_id = uuid4() temporary = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) temporary.write(b"not a PDF") temporary.close() pdf_path = Path(temporary.name) + self.addCleanup(pdf_path.unlink, True) mock_get_provider.return_value = self._provider_for_pdf(quote_id, pdf_path) - with self.assertRaises(Exception): + with self.assertRaises(PdfReadError): inspect_quote_pdf(quote_id, EXPECTED_TERMS) - self.assertFalse(pdf_path.exists()) + self.assertTrue(pdf_path.exists()) class InspectXeroQuotePdfCommandTests(SimpleTestCase): diff --git a/apps/workflow/views/xero/xero_base_manager.py b/apps/workflow/views/xero/xero_base_manager.py index 47630d5ab..768877f6e 100644 --- a/apps/workflow/views/xero/xero_base_manager.py +++ b/apps/workflow/views/xero/xero_base_manager.py @@ -130,7 +130,7 @@ def get_xero_sales_branding_theme_id() -> str | None: def get_xero_quote_terms() -> str | None: """Return the terms explicitly sent on API-created Xero quotes.""" terms = CompanyDefaults.get_solo().xero_quote_terms - if terms is None or not terms.strip(): + if not terms.strip(): return None return terms diff --git a/frontend/schema.yml b/frontend/schema.yml index 3a695fbf6..83ad73b28 100644 --- a/frontend/schema.yml +++ b/frontend/schema.yml @@ -10703,7 +10703,6 @@ components: readOnly: true xero_quote_terms: type: string - nullable: true maxLength: 4000 company_name: type: string @@ -11092,7 +11091,7 @@ components: nullable: true xero_quote_terms: type: string - nullable: true + minLength: 1 maxLength: 4000 company_acronym: type: string @@ -16968,7 +16967,7 @@ components: nullable: true xero_quote_terms: type: string - nullable: true + minLength: 1 maxLength: 4000 company_acronym: type: string diff --git a/frontend/src/api/generated/api.ts b/frontend/src/api/generated/api.ts index 3bef4ed34..8419775bd 100644 --- a/frontend/src/api/generated/api.ts +++ b/frontend/src/api/generated/api.ts @@ -905,7 +905,7 @@ const CompanyDefaults = z.object({ id: z.number().int(), logo_url: z.string().nullable(), logo_wide_url: z.string().nullable(), - xero_quote_terms: z.string().max(4000).nullish(), + xero_quote_terms: z.string().max(4000).optional(), company_name: z.string(), company_acronym: z.string().max(10).nullish(), time_markup: z.number().gt(-1000).lt(1000).optional(), @@ -972,7 +972,7 @@ const CompanyDefaults = z.object({ const CompanyDefaultsRequest = z.object({ logo: z.instanceof(File).nullish(), logo_wide: z.instanceof(File).nullish(), - xero_quote_terms: z.string().max(4000).nullish(), + xero_quote_terms: z.string().min(1).max(4000).optional(), company_acronym: z.string().max(10).nullish(), time_markup: z.number().gt(-1000).lt(1000).optional(), materials_markup: z.number().gt(-1000).lt(1000).optional(), @@ -1037,7 +1037,7 @@ const PatchedCompanyDefaultsRequest = z .object({ logo: z.instanceof(File).nullable(), logo_wide: z.instanceof(File).nullable(), - xero_quote_terms: z.string().max(4000).nullable(), + xero_quote_terms: z.string().min(1).max(4000), company_acronym: z.string().max(10).nullable(), time_markup: z.number().gt(-1000).lt(1000), materials_markup: z.number().gt(-1000).lt(1000), diff --git a/frontend/src/components/SectionForm.vue b/frontend/src/components/SectionForm.vue index 9beae5721..fb38f2832 100644 --- a/frontend/src/components/SectionForm.vue +++ b/frontend/src/components/SectionForm.vue @@ -318,10 +318,7 @@ const xeroQuoteTermsInputId = 'SectionForm-xero-field-xero_quote_terms-input' const xeroQuoteTermsHelpId = 'SectionForm-xero-field-xero_quote_terms-help' const xeroQuoteTermsCountId = 'SectionForm-xero-field-xero_quote_terms-count' const xeroQuoteTermsValidationId = 'SectionForm-xero-field-xero_quote_terms-validation' -const xeroQuoteTermsValue = computed(() => { - const value = localForm.value[XERO_QUOTE_TERMS_KEY] - return typeof value === 'string' ? value : '' -}) +const xeroQuoteTermsValue = computed(() => localForm.value[XERO_QUOTE_TERMS_KEY] as string) const xeroQuoteTermsCharacterCount = computed(() => xeroQuoteTermsValue.value.length) const xeroQuoteTermsBlank = computed(() => xeroQuoteTermsValue.value.trim().length === 0) const xeroQuoteTermsDescribedBy = computed(() => { diff --git a/frontend/src/components/__tests__/SectionForm.test.ts b/frontend/src/components/__tests__/SectionForm.test.ts index 34c579e06..4ee2f9f1b 100644 --- a/frontend/src/components/__tests__/SectionForm.test.ts +++ b/frontend/src/components/__tests__/SectionForm.test.ts @@ -169,7 +169,7 @@ describe('SectionForm', () => { const wrapper = mount(SectionForm, { props: { section: 'xero', - modelValue: { xero_quote_terms: null }, + modelValue: { xero_quote_terms: '' }, }, }) @@ -221,7 +221,7 @@ describe('SectionForm', () => { const wrapper = mount(SectionForm, { props: { section: 'xero', - modelValue: { xero_quote_terms: null }, + modelValue: { xero_quote_terms: '' }, }, }) const textarea = wrapper.get('[data-automation-id="SectionForm-xero-field-xero_quote_terms"]') From fed04cb9a3d302187db01699048d546b4adecfe2 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 13:14:30 +1200 Subject: [PATCH 48/66] fix: treat all-blank quote PDF as unreadable, not terms-absent CodeRabbit incremental review: extract_text() returns "" for blank or image-only pages, so an all-blank PDF produced a non-empty page_text list, skipped the no-extractable-text guard, and reported the marker absent while deleting the diagnostic file. Keep only pages with real text so the guard fires. Add a blank-page regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UTKr8tHRW5iam4YriPSzc --- apps/workflow/accounting/quote_pdf_service.py | 5 ++++- apps/workflow/tests/test_xero_quote_pdf.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/workflow/accounting/quote_pdf_service.py b/apps/workflow/accounting/quote_pdf_service.py index a65074c04..9e4158a17 100644 --- a/apps/workflow/accounting/quote_pdf_service.py +++ b/apps/workflow/accounting/quote_pdf_service.py @@ -37,7 +37,10 @@ def inspect_quote_pdf( page_text: list[str] = [] for page in reader.pages: extracted = page.extract_text() - if extracted is not None: + # A blank or image-only page extracts to "" — keep only pages with real + # text so an all-blank PDF raises below rather than reporting the marker + # absent and deleting the diagnostic file. + if extracted is not None and extracted.strip(): page_text.append(extracted) if not page_text: diff --git a/apps/workflow/tests/test_xero_quote_pdf.py b/apps/workflow/tests/test_xero_quote_pdf.py index 382d13e4f..1374e1a2a 100644 --- a/apps/workflow/tests/test_xero_quote_pdf.py +++ b/apps/workflow/tests/test_xero_quote_pdf.py @@ -151,6 +151,21 @@ def test_unreadable_pdf_raises_and_keeps_the_download( self.assertTrue(pdf_path.exists()) + @patch("apps.workflow.accounting.quote_pdf_service.get_provider") + def test_blank_pages_raise_rather_than_reporting_terms_absent( + self, mock_get_provider: Mock + ) -> None: + """An all-blank PDF is a failed render, not a quote missing its terms.""" + quote_id = uuid4() + pdf_path = _write_pdf([]) + self.addCleanup(pdf_path.unlink, True) + mock_get_provider.return_value = self._provider_for_pdf(quote_id, pdf_path) + + with self.assertRaisesRegex(ValueError, "no extractable text"): + inspect_quote_pdf(quote_id, EXPECTED_TERMS) + + self.assertTrue(pdf_path.exists()) + class InspectXeroQuotePdfCommandTests(SimpleTestCase): """The E2E subprocess contract must remain structured and parseable.""" From 3dc0a8a87eea4e6e4a44470bf0d9d997e89354ec Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 15:53:40 +1200 Subject: [PATCH 49/66] refactor(frontend): adopt debug library as single logging gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the homegrown src/utils/debug.ts (debugLog) with the `debug` npm library as the one logging gate for both the Vue app and the Playwright E2E suite. Each module owns a <domain>:<feature> namespace (job:autosave, kanban:core, ...); enable via localStorage.debug='job:*' in the browser or DEBUG=e2e:kanban for node E2E. Silent by default, loud on demand — feature logs are preserved-but-quiet instead of kept-as-noise or deleted-and-lost. - Migrate every debugLog call site across 92 source files to namespaced debug() loggers; delete the homegrown module (atomic, no shim, ADR 0017). - Fix 19 vitest files that mocked the old module; kanban log-assertions replaced by the behavioural assertions they duplicated. - Add tests/fixtures/debug-forwarder.ts: a gated, namespaced browser->test console forwarder wired into the auth fixture (DEBUG=e2e:<area>), replacing ad-hoc per-test page.on('console') handlers. - Whole-suite E2E cleanup: tests console.log ~148 -> 12 (bad-state keeps only); redundant-with-assertion logs deleted, feature narration gated. - Record the discipline: ADR 0031 (single logging gate) and ADR 0032 (prefer libraries over homegrown), plus frontend/CLAUDE.md rule 31 and new frontend/tests/CLAUDE.md. Verified: type-check, vitest (361), lint, build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UTKr8tHRW5iam4YriPSzc --- CLAUDE.md | 1 + ...31-single-logging-gate-debug-namespaces.md | 43 ++++++ .../0032-prefer-libraries-over-homegrown.md | 36 +++++ docs/adr/README.md | 2 + frontend/CLAUDE.md | 1 + frontend/package-lock.json | 8 +- frontend/package.json | 2 + frontend/src/App.vue | 26 ++-- frontend/src/__tests__/App.test.ts | 4 - .../src/__tests__/debug-forwarder.test.ts | 51 +++++++ frontend/src/api/client.ts | 42 +++--- frontend/src/components/AIProvidersDialog.vue | 40 +++--- frontend/src/components/CompanyLookup.vue | 6 +- frontend/src/components/KanbanColumn.vue | 8 +- frontend/src/components/PersonSelector.vue | 40 +++--- frontend/src/components/StaffPanel.vue | 14 +- .../src/components/board/WorkshopModeView.vue | 6 +- frontend/src/components/job/CameraModal.vue | 8 +- frontend/src/components/job/JobActualTab.vue | 18 +-- .../src/components/job/JobAttachmentsTab.vue | 34 ++--- .../src/components/job/JobEstimateTab.vue | 6 +- frontend/src/components/job/JobPdfDialog.vue | 8 +- .../src/components/job/JobPricingGrids.vue | 6 +- frontend/src/components/job/JobQuoteTab.vue | 18 +-- .../src/components/job/JobQuotingChatTab.vue | 20 +-- .../src/components/job/JobSettingsTab.vue | 17 +-- frontend/src/components/job/JobViewTabs.vue | 8 +- .../src/components/job/WorkshopPdfViewer.vue | 6 +- .../JobSettingsTab.companyChange.test.ts | 4 - .../JobSettingsTab.labourRate.test.ts | 4 - .../__tests__/JobSettingsTab.urgent.test.ts | 4 - .../purchasing/AddressAutocompleteInput.vue | 8 +- .../purchasing/AllocationCellEditor.vue | 6 +- .../purchasing/PickupAddressSelector.vue | 8 +- .../components/purchasing/PoLinesTable.vue | 8 +- .../components/shared/SmartCostLinesTable.vue | 56 ++++---- .../SmartCostLinesTable.kindInference.test.ts | 4 - .../__tests__/SmartCostLinesTable.test.ts | 4 - .../SmartCostLinesTable.timeGuard.test.ts | 4 - .../timesheet/SmartTimesheetTable.vue | 8 +- .../components/timesheet/SummaryDrawer.vue | 24 ++-- .../__tests__/useOptimizedDragAndDrop.test.ts | 8 -- .../__tests__/useOptimizedKanban.test.ts | 34 ----- .../__tests__/usePersonManagement.test.ts | 4 - frontend/src/composables/useAppLayout.ts | 6 +- frontend/src/composables/useCamera.ts | 8 +- frontend/src/composables/useCompanyLookup.ts | 10 +- .../composables/useCreateCostLineFromEmpty.ts | 8 +- .../src/composables/useDeviceDetection.ts | 6 +- frontend/src/composables/useDragAndDrop.ts | 16 ++- frontend/src/composables/useJobAutoSync.ts | 14 +- frontend/src/composables/useJobAutosave.ts | 11 +- frontend/src/composables/useJobCache.ts | 22 +-- frontend/src/composables/useJobETags.ts | 12 +- .../src/composables/useJobHeaderAutosave.ts | 1 - .../src/composables/useJobNotifications.ts | 10 +- .../composables/useOptimizedDragAndDrop.ts | 16 ++- .../src/composables/useOptimizedKanban.ts | 74 +++++----- .../src/composables/usePersonManagement.ts | 38 +++--- .../composables/usePickupAddressManagement.ts | 52 ++++---- frontend/src/composables/usePoETags.ts | 10 +- frontend/src/composables/useQuoteImport.ts | 10 +- frontend/src/composables/useSettingsSchema.ts | 12 +- .../src/composables/useSmartCostLineDelete.ts | 16 ++- .../src/composables/useTimesheetSummary.ts | 8 +- frontend/src/composables/useXeroApps.ts | 8 +- frontend/src/composables/useXeroAuth.ts | 16 ++- frontend/src/pages/jobs/[id]/(index).vue | 20 +-- frontend/src/pages/jobs/create.vue | 20 +-- frontend/src/pages/purchasing/po/(index).vue | 6 +- frontend/src/pages/purchasing/po/[id].vue | 118 ++++++++-------- frontend/src/pages/purchasing/pricing.vue | 7 +- frontend/src/pages/purchasing/stock.vue | 18 +-- frontend/src/pages/quoting/chat.vue | 30 +++-- frontend/src/pages/reports/kpi.vue | 16 ++- frontend/src/pages/timesheets/daily.vue | 18 +-- frontend/src/pages/timesheets/entry.vue | 126 +++++++++--------- frontend/src/pages/timesheets/weekly.vue | 45 +++---- frontend/src/plugins/axios.ts | 8 +- .../src/router/__tests__/auth-guard.test.ts | 4 - .../src/router/__tests__/not-found.test.ts | 4 - .../__tests__/sessionReplayService.test.ts | 4 - frontend/src/services/aiProviderService.ts | 16 ++- .../src/services/company-defaults.service.ts | 10 +- frontend/src/services/companyService.ts | 8 +- frontend/src/services/costline.service.ts | 28 ++-- .../src/services/job-aging-report.service.ts | 6 +- frontend/src/services/job.service.ts | 28 ++-- frontend/src/services/kpi.service.ts | 12 +- .../src/services/notebookLmLinkService.ts | 16 ++- .../payroll-reconciliation-report.service.ts | 6 +- frontend/src/services/quote-chat.service.ts | 16 ++- .../services/sales-pipeline-report.service.ts | 6 +- .../src/services/searchTelemetry.service.ts | 6 +- frontend/src/services/sessionReplayService.ts | 10 +- .../src/services/settings-schema.service.ts | 14 +- .../staff-performance-report.service.ts | 8 +- frontend/src/services/timesheet.service.ts | 12 +- frontend/src/services/wip-report.service.ts | 6 +- .../src/services/workshop-schedule.service.ts | 14 +- frontend/src/stores/__tests__/auth.test.ts | 4 - frontend/src/stores/__tests__/jobs.test.ts | 4 - .../src/stores/__tests__/timesheet.test.ts | 4 - frontend/src/stores/auth.ts | 10 +- frontend/src/stores/costing.ts | 12 +- frontend/src/stores/deliveryReceiptStore.ts | 30 +++-- frontend/src/stores/feature-flags.ts | 6 +- frontend/src/stores/jobs.ts | 36 ++--- frontend/src/stores/purchaseOrderStore.ts | 22 +-- frontend/src/stores/timesheet.ts | 57 ++++---- frontend/src/utils/dateUtils.ts | 8 +- frontend/src/utils/debug.ts | 27 ---- frontend/src/utils/error-handler.ts | 12 +- frontend/src/utils/safetyUtils.ts | 9 +- frontend/src/views/AdminMonthEnd.vue | 6 +- frontend/src/views/AdminView.vue | 6 +- frontend/src/views/WorkshopKanbanView.vue | 6 +- frontend/src/views/WorkshopView.vue | 6 +- .../views/__tests__/QuotingChatView.test.ts | 4 - .../__tests__/WeeklyTimesheetView.test.ts | 4 - .../purchasing/__tests__/StockView.test.ts | 2 - frontend/tests/CLAUDE.md | 3 + frontend/tests/company-defaults.spec.ts | 13 +- frontend/tests/fixtures/auth.ts | 20 +-- frontend/tests/fixtures/debug-forwarder.ts | 74 ++++++++++ frontend/tests/fixtures/helpers.ts | 1 - .../tests/job/create-estimate-entry.spec.ts | 5 - .../job/create-job-with-new-company.spec.ts | 19 ++- frontend/tests/job/create-job.spec.ts | 31 +++-- frontend/tests/job/edit-job-settings.spec.ts | 34 +---- frontend/tests/job/job-xero-quote.spec.ts | 11 +- frontend/tests/kanban/debug-drag-bugs.spec.ts | 25 ++-- .../purchasing/create-purchase-order.spec.ts | 12 +- .../tests/purchasing/pickup-address.spec.ts | 27 ++-- .../tests/purchasing/po-created-by.spec.ts | 3 - frontend/tests/reports/companies.spec.ts | 16 +-- frontend/tests/reports/job-movement.spec.ts | 2 - .../reports/payroll-reconciliation.spec.ts | 14 +- frontend/tests/reports/sales-forecast.spec.ts | 2 - frontend/tests/reports/wip-report.spec.ts | 2 - frontend/tests/staff/create-staff.spec.ts | 2 - .../tests/staff/staff-wage-loading.spec.ts | 20 +-- .../timesheet/create-timesheet-entry.spec.ts | 24 ++-- frontend/tests/timesheet/performance.spec.ts | 47 +++---- .../timesheet/workshop-my-time-view.spec.ts | 1 - 145 files changed, 1295 insertions(+), 1105 deletions(-) create mode 100644 docs/adr/0031-single-logging-gate-debug-namespaces.md create mode 100644 docs/adr/0032-prefer-libraries-over-homegrown.md create mode 100644 frontend/src/__tests__/debug-forwarder.test.ts delete mode 100644 frontend/src/utils/debug.ts create mode 100644 frontend/tests/CLAUDE.md create mode 100644 frontend/tests/fixtures/debug-forwarder.ts diff --git a/CLAUDE.md b/CLAUDE.md index 831fe7257..6c6a1c706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ Major architectural decisions are recorded in [`docs/adr/`](docs/adr/README.md). - **0015** — When a consumer finds malformed data, fix the data (migration). Consumers stay strict; never add a read-side fallback. - **0020** — Backend owns data, calculations, and external systems; frontend owns presentation. The boundary is the kind of value, not the layer of code. - **0021** — Frontend reads/writes the API only via the generated client; raw `fetch`/`axios` is forbidden. +- **0032** — Less code is better: prefer a maintained library over a homegrown implementation. Writing your own for something a library provides needs an explicit, recorded reason it isn't a library. CLAUDE.md is the operational layer (session behaviour, code-style gotchas, architecture facts). ADRs explain *why*. diff --git a/docs/adr/0031-single-logging-gate-debug-namespaces.md b/docs/adr/0031-single-logging-gate-debug-namespaces.md new file mode 100644 index 000000000..7cac61e6b --- /dev/null +++ b/docs/adr/0031-single-logging-gate-debug-namespaces.md @@ -0,0 +1,43 @@ +# 0031 — One logging gate: the `debug` library with namespaces + +All frontend and E2E diagnostic logging flows through the `debug` library under a `<domain>:<feature>` namespace; there is one gate and one enable mechanism. + +## Status + +Accepted + +## Context + +The Vue app and the Playwright suite carried two uncoordinated logging mechanisms: a homegrown `src/utils/debug.ts` wrapper on the app side and ad-hoc `console.log` plus per-test `page.on('console')` handlers on the test side. Enabling diagnostics was global — a single on/off flag with no way to select one feature — so turning logging on drowned the signal, and dev-mode E2E runs were buried under app narration surfaced by scattered console forwarders. The homegrown wrapper reimplemented, worse, exactly what the `debug` library already provides: per-namespace selection, wildcard enabling, and zero cost when disabled. Retiring it in favour of the library is a first application of ADR 0032 (prefer libraries over homegrown implementations). + +## Decision + +Diagnostic logging goes through the `debug` library and nothing else, on both the app and test sides. + +- Each module declares one namespaced logger: `import debug from 'debug'; const log = debug('job:autosave')`. Namespaces are `<domain>:<feature>`, lower-kebab, colon depth ≤ 2, feature-scoped — files serving one feature share a namespace. App domains in use: `app auth api job kanban po timesheet xero quote company person cost report workshop staff ai session search settings admin`. The test side uses `e2e:<area>`. +- The homegrown `src/utils/debug.ts` (`debugLog`) is deleted and every call site migrated to `debug` in one PR — no alias, no shim, no catch-all namespace (ADR 0017). +- Logging is silent by default. Enable in the browser with `localStorage.debug='job:*'`; enable for node E2E with `DEBUG=e2e:kanban`. +- App→test log surfacing goes through one gated, namespaced forwarder (`frontend/tests/fixtures/debug-forwarder.ts`, wired into the `page` fixture in `frontend/tests/fixtures/auth.ts`), opt-in via `DEBUG=e2e:<area>`. Per-test `page.on('console')` handlers are not used. + +Existing and legacy log statements follow a three-way rule: + +1. **Never delete genuine feature narration.** Gate it behind a namespace so it is silent-but-preserved. +2. **Delete a log** only when it is redundant with a neighbouring assertion or the failure trace. +3. **Keep ungated** the bad-state / error-branch / skip-notice logs — they only fire when something is already wrong. + +## Why + +One gate with per-feature selectivity is the whole point: a developer enables exactly the namespace they are debugging and sees only that, in the browser or in an E2E run, without editing code. Collapsing two mechanisms into one removes the question "which logger does this module use?" and the taxonomy makes the answer to "which namespace?" mechanical. Routing app→test surfacing through a single opt-in forwarder means a dev-mode E2E run is quiet unless explicitly asked for a given area, instead of being shaped by whatever `console.on` handlers happen to be installed. Adopting the `debug` library rather than maintaining a wrapper deletes code we owned that did the same job less well. + +## Alternatives considered + +- **Keep the homegrown wrapper, add per-feature flags:** re-grows the exact selection and wildcard machinery `debug` ships, as maintained code, for no gain. +- **Structured logger (pino/winston) on the app side:** built for server log pipelines and shipping; overweight for browser diagnostics whose only consumer is a developer with devtools open. `debug` is the browser-native idiom. + +## Consequences + +- `debug` is a runtime dependency, not a dev-only one. +- New modules pick a namespace from the taxonomy above; a genuinely new domain extends the taxonomy in the same PR. +- Reviewers reject new bare `console.log` for app narration in `src/`, and new ungated success-path `console.log` in `tests/`. +- Enabling diagnostics is `localStorage.debug=` (browser) or `DEBUG=` (node); nothing is enabled by default. +- The console-error guard is unchanged and complementary: every `console.error` must still toast or throw (ADR 0019/0013 — errors are persisted and visible). This ADR gates narration, not error signalling, and does not supersede that rule. diff --git a/docs/adr/0032-prefer-libraries-over-homegrown.md b/docs/adr/0032-prefer-libraries-over-homegrown.md new file mode 100644 index 000000000..c06c1443c --- /dev/null +++ b/docs/adr/0032-prefer-libraries-over-homegrown.md @@ -0,0 +1,36 @@ +# 0032 — Less code is better: prefer libraries over homegrown implementations + +For any capability a well-maintained library provides, we install the library. Writing our own implementation instead is a deliberate, documented exception — never the default. + +## Status + +Accepted + +## Context + +The code we own is the code we pay for — forever. Every homegrown utility is a line we test, secure, document, and carry through every future change; a dependency that does the same job is code someone else maintains on our behalf. A homegrown implementation also tends to be a worse version of a library that already exists: it reimplements a subset, misses edge cases, has no docs, and drifts as the person who wrote it moves on. Left unchecked, these accrete into a private standard library that duplicates, badly, things the ecosystem already solved. + +The principle is not "add every dependency." A dependency is also a liability — supply-chain surface, transitive weight, an upstream that can break or vanish. The rule is about *ownership*, not byte count: for a real capability, not-owning the code beats owning it; for something trivial, neither write much nor pull a heavyweight dependency to avoid a few lines. + +## Decision + +Reach for a well-maintained library first. Writing custom code for something a library provides requires an **explicit, deliberate, recorded** justification for rejecting the library — a line in the PR description for ordinary cases, a new ADR for a significant or repeated surface. "We wrote our own" carries the burden of proof; "we added a dependency" is the default. + +Legitimate, stated reasons to go custom: + +- No library covers the need, or the closest ones are unmaintained / red-flagged (abandoned, insecure, incompatible license). +- The need is small enough that a dependency's cost (supply chain, transitive deps, bundle) outweighs the handful of lines it would save. +- The library would demand more glue and adaptation than it removes. + +Absent such a reason, replacing owned code with a library — or deleting owned code a library makes redundant — is always a welcome change, done atomically with every call site migrated in the same PR (ADR 0017). + +## Why + +Minimising the code we own is the highest-leverage way to keep the system maintainable: unwritten code has no bugs, needs no tests, and never rots. Preferring libraries makes that concrete for the large class of problems the ecosystem has already solved well. Forcing the *justification* to be explicit stops homegrown reimplementation from happening by default — the usual path isn't a decision to reinvent, it's the absence of a decision to check for a library first. + +## Consequences + +- Reviewers challenge any new homegrown implementation of a solved problem and ask which library was considered and why it was rejected; an unrecorded reinvention is a review finding. +- Deleting a homegrown utility in favour of a library needs no special justification — it is the direction of travel. +- New dependencies are still weighed (maintenance, license, transitive cost); this ADR raises the bar for writing code, it does not lower the bar for adding deps. +- ADR 0031 (replacing the homegrown `debugLog` wrapper with the `debug` library) is the first application of this principle; expect more as owned utilities are retired. diff --git a/docs/adr/README.md b/docs/adr/README.md index eee08d602..927f4cb71 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,3 +40,5 @@ See [`_template.md`](_template.md). Copy, renumber, fill in. | 0028 | Type annotations are data contracts | | 0029 | Servers run the production branch | | 0030 | First-class People and Company links | +| 0031 | One logging gate: the debug library with namespaces | +| 0032 | Less code is better: prefer libraries over homegrown implementations | diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 9efe79acb..732e5fec9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -63,6 +63,7 @@ The backend (Django) is in the repo root. The frontend and backend are in the sa ### Error Handling 30. Every `console.error` must toast or throw — never silent failures +31. Debug logging goes through `debug` only: `import debug from 'debug'; const log = debug('<domain>:<feature>')` — no bare `console.log` for app narration, no bespoke log wrappers. Enable with `localStorage.debug='job:*'` (ADR 0031) --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d4a447294..33ab94c38 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,7 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "dayjs": "^1.11.20", + "debug": "^4.4.3", "dompurify": "^3.4.11", "event-source-polyfill": "^1.0.31", "js-cookie": "^3.0.8", @@ -58,6 +59,7 @@ "@playwright/test": "^1.60.0", "@tsconfig/node22": "^22.0.1", "@types/adm-zip": "^0.5.7", + "@types/debug": "^4.1.13", "@types/dompurify": "^3.2.0", "@types/js-cookie": "^3.0.6", "@types/node": "^25.9.3", @@ -3947,7 +3949,9 @@ "peer": true }, "node_modules/@types/debug": { - "version": "4.1.12", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { "@types/ms": "*" @@ -7290,6 +7294,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" diff --git a/frontend/package.json b/frontend/package.json index dde57f201..96fb5d2f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,7 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "dayjs": "^1.11.20", + "debug": "^4.4.3", "dompurify": "^3.4.11", "event-source-polyfill": "^1.0.31", "js-cookie": "^3.0.8", @@ -78,6 +79,7 @@ "@playwright/test": "^1.60.0", "@tsconfig/node22": "^22.0.1", "@types/adm-zip": "^0.5.7", + "@types/debug": "^4.1.13", "@types/dompurify": "^3.2.0", "@types/js-cookie": "^3.0.6", "@types/node": "^25.9.3", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 581428429..d7c089387 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { onMounted, onUnmounted, watch } from 'vue' import { useAuthStore } from '@/stores/auth' @@ -23,9 +23,11 @@ import { stopSessionReplay, } from '@/services/sessionReplayService' +const log = debug('app:root') + const authStore = useAuthStore() -debugLog(useFeatureFlags().isCostingApiEnabled) +log('costing API enabled: %o', useFeatureFlags().isCostingApiEnabled) function refreshDataIfVisible(): void { if (document.visibilityState !== 'visible') return @@ -34,14 +36,14 @@ function refreshDataIfVisible(): void { // AppError per ADR 0019. if (!authStore.isAuthenticated) return dataFreshness.checkFreshness().catch((err) => { - debugLog('[App] data-freshness check failed:', err) + log('data-freshness check failed:', err) }) } function flushReplayIfHidden(): void { if (document.visibilityState !== 'hidden') return flushSessionReplay().catch((err) => { - debugLog('[App] session replay visibility flush failed:', err) + log('session replay visibility flush failed:', err) }) } @@ -51,18 +53,18 @@ function flushReplayBeforeUnload(): void { function captureFrontendError(event: ErrorEvent | PromiseRejectionEvent): void { reportFrontendError(event).catch((err) => { - debugLog('[App] frontend error replay report failed:', err) + log('frontend error replay report failed:', err) }) } function syncSessionReplayWithAuth(isAuthenticated: boolean): void { if (isAuthenticated) { startSessionReplay().catch((err) => { - debugLog('[App] session replay start failed:', err) + log('session replay start failed:', err) }) } else { stopSessionReplay().catch((err) => { - debugLog('[App] session replay stop failed:', err) + log('session replay stop failed:', err) }) } } @@ -80,19 +82,19 @@ onMounted(async () => { const isAuthenticated = await authStore.initializeAuth() if (isAuthenticated) { const companyDefaultsStore = useCompanyDefaultsStore() - debugLog('[App] Before loading company defaults:', companyDefaultsStore.companyDefaults) + log('Before loading company defaults:', companyDefaultsStore.companyDefaults) await companyDefaultsStore.loadCompanyDefaults() - debugLog('[App] After loading company defaults:', companyDefaultsStore.companyDefaults) + log('After loading company defaults:', companyDefaultsStore.companyDefaults) const notebookLmLinksStore = useNotebookLmLinksStore() await notebookLmLinksStore.loadLinks() // Establish baseline dataset versions; subscribers don't fire on first // observation, only on subsequent changes. dataFreshness.checkFreshness().catch((err) => { - debugLog('[App] initial data-freshness check failed:', err) + log('initial data-freshness check failed:', err) }) } } catch (error) { - debugLog('Failed to initialize auth or company defaults on app start:', error) + log('Failed to initialize auth or company defaults on app start:', error) } document.addEventListener('visibilitychange', refreshDataIfVisible) document.addEventListener('visibilitychange', flushReplayIfHidden) @@ -109,7 +111,7 @@ onUnmounted(() => { window.removeEventListener('error', captureFrontendError) window.removeEventListener('unhandledrejection', captureFrontendError) stopSessionReplay().catch((err) => { - debugLog('[App] session replay stop failed:', err) + log('session replay stop failed:', err) }) }) </script> diff --git a/frontend/src/__tests__/App.test.ts b/frontend/src/__tests__/App.test.ts index 6d76e42b1..6c8a6fc2b 100644 --- a/frontend/src/__tests__/App.test.ts +++ b/frontend/src/__tests__/App.test.ts @@ -20,10 +20,6 @@ vi.mock('@/services/sessionReplayService', () => ({ stopSessionReplay: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - const user = { id: '11111111-1111-4111-8111-111111111111', username: 'cindy@example.com', diff --git a/frontend/src/__tests__/debug-forwarder.test.ts b/frontend/src/__tests__/debug-forwarder.test.ts new file mode 100644 index 000000000..c81dea5cf --- /dev/null +++ b/frontend/src/__tests__/debug-forwarder.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +// The forwarder lives under tests/ (Playwright fixtures) which vitest excludes +// as a test-file root, but its pure bridge functions are imported here so the +// area->app-namespace mapping the whole forwarder depends on is guarded by unit +// tests. Relative import because `@/` only maps into src/. +import { browserDebugGlob, enabledAreas } from '../../tests/fixtures/debug-forwarder' + +describe('debug-forwarder bridge', () => { + const originalDebug = process.env.DEBUG + + beforeEach(() => { + delete process.env.DEBUG + }) + + afterEach(() => { + if (originalDebug === undefined) { + delete process.env.DEBUG + } else { + process.env.DEBUG = originalDebug + } + }) + + it('enables nothing when DEBUG is unset', () => { + expect(enabledAreas()).toEqual([]) + expect(browserDebugGlob()).toBeNull() + }) + + it('ignores non-e2e debug namespaces', () => { + process.env.DEBUG = 'job:autosave' + expect(enabledAreas()).toEqual([]) + expect(browserDebugGlob()).toBeNull() + }) + + it('maps a single area to its app glob', () => { + process.env.DEBUG = 'e2e:autosave' + expect(enabledAreas()).toEqual(['e2e:autosave']) + expect(browserDebugGlob()).toBe('job:autosave') + }) + + it('joins multiple areas into a comma-separated glob', () => { + process.env.DEBUG = 'e2e:kanban,e2e:job' + expect(browserDebugGlob()).toBe('kanban:*,job:*') + }) + + it('drops an e2e area that has no bridge entry', () => { + process.env.DEBUG = 'e2e:autosave,e2e:unknown' + expect(enabledAreas()).toEqual(['e2e:autosave', 'e2e:unknown']) + expect(browserDebugGlob()).toBe('job:autosave') + }) +}) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1f2555be2..6d7ff16e7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import { Zodios } from '@zodios/core' import axios from 'axios' import { endpoints } from './generated/api' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { trimStringsDeep } from '../utils/sanitize' import { isJobEndpoint, @@ -17,6 +17,8 @@ import { emitConcurrencyRetry } from '../composables/useConcurrencyEvents' import { emitPoConcurrencyRetry } from '../composables/usePoConcurrencyEvents' import { getSessionReplayId } from '@/services/sessionReplayState' +const log = debug('api:client') + // Global registry for ETag management to avoid circular imports let etagManager: { getETag: (jobId: string) => string | null @@ -100,7 +102,7 @@ axios.interceptors.request.use( const etag = etagManager.getETag(jobId) if (etag) { config.headers['If-Match'] = etag - debugLog(`[ETags] Added If-Match header for ${url}:`, etag) + log(`Added If-Match header for ${url}:`, etag) } } } @@ -114,9 +116,9 @@ axios.interceptors.request.use( try { const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data poId = body?.purchase_order_id || null - debugLog(`[PO ETags] Extracted PO ID from delivery receipt body:`, poId) + log(`Extracted PO ID from delivery receipt body:`, poId) } catch (e) { - debugLog(`[PO ETags] Failed to parse delivery receipt body:`, e) + log(`Failed to parse delivery receipt body:`, e) } } else { // For other PO endpoints, extract from URL @@ -127,7 +129,7 @@ axios.interceptors.request.use( const etag = poEtagManager.getETag(poId) if (etag) { config.headers['If-Match'] = etag - debugLog(`[PO ETags] Added If-Match header for ${url}:`, etag) + log(`Added If-Match header for ${url}:`, etag) } } } @@ -143,7 +145,7 @@ axios.interceptors.request.use( const jobId = extractJobId(url) if (jobId && isJobMutationEndpoint(url)) { - debugLog(`[ETags] Validation error for job ${jobId} - letting JobDelta handle it`) + log(`Validation error for job ${jobId} - letting JobDelta handle it`) // JobDelta service will surface the error to user, no silent reload } } @@ -162,7 +164,7 @@ axios.interceptors.response.use( const jobId = extractJobId(url) if (jobId && etagManager) { etagManager.setETag(jobId, etag) - debugLog(`[ETags] Captured ETag for ${url}:`, etag) + log(`Captured ETag for ${url}:`, etag) } } @@ -171,7 +173,7 @@ axios.interceptors.response.use( const poId = extractPoId(url) if (poId && poEtagManager) { poEtagManager.setETag(poId, etag) - debugLog(`[PO ETags] Captured ETag for ${url}:`, etag) + log(`Captured ETag for ${url}:`, etag) } } @@ -185,14 +187,14 @@ axios.interceptors.response.use( const poId = extractPoId(url) if (jobId && jobReloadManager) { - debugLog(`[ETags] Concurrency conflict detected for job ${jobId}, reloading data`) + log(`Concurrency conflict detected for job ${jobId}, reloading data`) // Reload job data to get fresh ETag try { await jobReloadManager.reloadJobOnConflict(jobId) - debugLog(`[ETags] Successfully reloaded job ${jobId} after concurrency conflict`) + log(`Successfully reloaded job ${jobId} after concurrency conflict`) } catch (reloadError) { - debugLog(`[ETags] Failed to reload job ${jobId}:`, reloadError) + log(`Failed to reload job ${jobId}:`, reloadError) } // Show persistent user notification with retry option @@ -216,14 +218,14 @@ axios.interceptors.response.use( } if (poId && poReloadManager) { - debugLog(`[PO ETags] Concurrency conflict detected for PO ${poId}, reloading data`) + log(`Concurrency conflict detected for PO ${poId}, reloading data`) // Reload PO data to get fresh ETag try { await poReloadManager.reloadPoOnConflict(poId) - debugLog(`[PO ETags] Successfully reloaded PO ${poId} after concurrency conflict`) + log(`Successfully reloaded PO ${poId} after concurrency conflict`) } catch (reloadError) { - debugLog(`[PO ETags] Failed to reload PO ${poId}:`, reloadError) + log(`Failed to reload PO ${poId}:`, reloadError) } // Show persistent user notification with retry option @@ -254,14 +256,14 @@ axios.interceptors.response.use( const poId = extractPoId(url) if (jobId && jobReloadManager) { - debugLog(`[ETags] Missing ETag for job ${jobId}, reloading data`) + log(`Missing ETag for job ${jobId}, reloading data`) // Reload job data to get ETag try { await jobReloadManager.reloadJobOnConflict(jobId) - debugLog(`[ETags] Successfully reloaded job ${jobId} to get ETag`) + log(`Successfully reloaded job ${jobId} to get ETag`) } catch (reloadError) { - debugLog(`[ETags] Failed to reload job ${jobId}:`, reloadError) + log(`Failed to reload job ${jobId}:`, reloadError) } // Show persistent user notification with retry option @@ -285,14 +287,14 @@ axios.interceptors.response.use( } if (poId && poReloadManager) { - debugLog(`[PO ETags] Missing ETag for PO ${poId}, reloading data`) + log(`Missing ETag for PO ${poId}, reloading data`) // Reload PO data to get ETag try { await poReloadManager.reloadPoOnConflict(poId) - debugLog(`[PO ETags] Successfully reloaded PO ${poId} to get ETag`) + log(`Successfully reloaded PO ${poId} to get ETag`) } catch (reloadError) { - debugLog(`[PO ETags] Failed to reload PO ${poId}:`, reloadError) + log(`Failed to reload PO ${poId}:`, reloadError) } // Show persistent user notification with retry option diff --git a/frontend/src/components/AIProvidersDialog.vue b/frontend/src/components/AIProvidersDialog.vue index b519fbd45..d27d37513 100644 --- a/frontend/src/components/AIProvidersDialog.vue +++ b/frontend/src/components/AIProvidersDialog.vue @@ -99,7 +99,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog' import Button from './ui/button/Button.vue' @@ -108,6 +108,8 @@ import { ArrowLeft } from 'lucide-vue-next' import { schemas } from '@/api/generated/api' import { z } from 'zod' +const log = debug('ai:providers') + type AIProvider = z.infer<typeof schemas.AIProvider> type ProviderForm = z.infer<typeof schemas.AIProviderCreateUpdateRequest> & { id?: AIProvider['id'] @@ -132,12 +134,12 @@ const toLocalProviders = (providers?: AIProvider[]) => const localProviders = ref<ProviderForm[]>(toLocalProviders(props.providers)) -debugLog('[AIProvidersDialog] props.providers:', props.providers) -debugLog('[AIProvidersDialog] local providers:', localProviders.value) -debugLog('[AIProvidersDialog] localProviders.value.length:', localProviders.value.length) +log('props.providers:', props.providers) +log('local providers:', localProviders.value) +log('localProviders.value.length:', localProviders.value.length) if (props.providers && props.providers.length > 0) { - debugLog('[AIProvidersDialog] First provider:', props.providers[0]) + log('First provider:', props.providers[0]) } watch( @@ -168,8 +170,8 @@ const onDefaultChange = (idx: number, event: Event) => { } function handleClose() { - debugLog( - '[AIProvidersDialog] handleClose - final localProviders:', + log( + 'handleClose - final localProviders:', localProviders.value.map((p) => ({ name: p.name, default: p.default, @@ -183,42 +185,42 @@ function handleClose() { function addProvider() { localProviders.value.push(createLocalProvider()) - debugLog('[AIProvidersDialog] addProvider - new provider added') + log('addProvider - new provider added') emitProviders() } function removeProvider(idx: number) { if (!localProviders.value[idx]) return localProviders.value.splice(idx, 1) - debugLog('[AIProvidersDialog] removeProvider - provider removed at index:', idx) + log('removeProvider - provider removed at index:', idx) emitProviders() } function setDefault(idx: number, checked: boolean) { const provider = localProviders.value[idx] if (!provider) return - debugLog(`[AIProvidersDialog] setDefault called for idx: ${idx}, checked: ${checked}`) - debugLog(`[AIProvidersDialog] Provider BEFORE setDefault:`, { + log(`setDefault called for idx: ${idx}, checked: ${checked}`) + log(`Provider BEFORE setDefault:`, { name: provider.name, default: provider.default, }) if (checked) { localProviders.value.forEach((p, i) => { - debugLog(`[AIProvidersDialog] Setting provider ${i} default to ${i === idx}`) + log(`Setting provider ${i} default to ${i === idx}`) p.default = i === idx }) } else { - debugLog(`[AIProvidersDialog] Unchecking provider ${idx}`) + log(`Unchecking provider ${idx}`) provider.default = false } - debugLog(`[AIProvidersDialog] Provider AFTER setDefault:`, { + log(`Provider AFTER setDefault:`, { name: provider.name, default: provider.default, }) - debugLog( - '[AIProvidersDialog] All providers after setDefault:', + log( + 'All providers after setDefault:', localProviders.value.map((p) => ({ name: p.name, default: p.default })), ) @@ -253,9 +255,9 @@ function emitProviders() { return payload }) - debugLog('[AIProvidersDialog] emitProviders, sending providers count:', providersToSend.length) - debugLog( - '[AIProvidersDialog] emitProviders, default values:', + log('emitProviders, sending providers count:', providersToSend.length) + log( + 'emitProviders, default values:', providersToSend.map((p) => ({ name: p.name, default: p.default, diff --git a/frontend/src/components/CompanyLookup.vue b/frontend/src/components/CompanyLookup.vue index 4523f80e4..53dd257f5 100644 --- a/frontend/src/components/CompanyLookup.vue +++ b/frontend/src/components/CompanyLookup.vue @@ -114,7 +114,9 @@ import CreateCompanyModal from '@/components/CreateCompanyModal.vue' import type { Company } from '@/composables/useCompanyLookup' import { api } from '@/api/client' import { toast } from 'vue-sonner' -import { debugLog } from '../utils/debug' +import debug from 'debug' + +const log = debug('company:lookup') const props = withDefaults( defineProps<{ @@ -318,7 +320,7 @@ watch(selectedCompany, (newCompany, oldCompany) => { // Preserve company selection when component mounts onMounted(() => { - debugLog('Props value: ', props) + log('Props value: ', props) preserveSelectedCompany(props.modelValue || '') }) </script> diff --git a/frontend/src/components/KanbanColumn.vue b/frontend/src/components/KanbanColumn.vue index 34b03670b..1d5bf0627 100644 --- a/frontend/src/components/KanbanColumn.vue +++ b/frontend/src/components/KanbanColumn.vue @@ -140,13 +140,15 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue' import JobCard from '@/components/JobCard.vue' import { schemas } from '../api/generated/api' import { z } from 'zod' +const log = debug('kanban:column') + type KanbanJob = z.infer<typeof schemas.KanbanJob> type StatusChoice = z.infer<typeof schemas.JobStatusEnum> @@ -223,7 +225,7 @@ const emit = defineEmits<KanbanColumnEmits>() const jobListRef = ref<HTMLElement>() const handleArchivedJobDrop = (event: CustomEvent) => { - debugLog('KanbanColumn received archived job drop:', event.detail) + log('KanbanColumn received archived job drop:', event.detail) const dropEvent = new CustomEvent('archived-job-drop', { detail: event.detail, @@ -236,7 +238,7 @@ onMounted(async () => { await nextTick() if (jobListRef.value) { - debugLog(`Column ${normalizedStatus.value.key} ready, emitting sortable-ready`) + log(`Column ${normalizedStatus.value.key} ready, emitting sortable-ready`) emit('sortable-ready', jobListRef.value, normalizedStatus.value.key) jobListRef.value.addEventListener('archived-job-drop', handleArchivedJobDrop as EventListener) diff --git a/frontend/src/components/PersonSelector.vue b/frontend/src/components/PersonSelector.vue index b43237048..497252d6f 100644 --- a/frontend/src/components/PersonSelector.vue +++ b/frontend/src/components/PersonSelector.vue @@ -69,7 +69,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '../utils/debug' +import debug from 'debug' import { toast } from 'vue-sonner' import { ref, watch } from 'vue' @@ -79,6 +79,8 @@ import PersonSelectionModal from './PersonSelectionModal.vue' import { schemas } from '../api/generated/api' import { z } from 'zod' +const log = debug('person:selector') + type CompanyPerson = z.infer<typeof schemas.CompanyPerson> type PhonePersonMatch = z.infer<typeof schemas.PhonePersonMatch> @@ -140,7 +142,7 @@ const isHydrating = ref(true) let loadToken = 0 const handleOpenModal = async () => { - debugLog('PersonSelector - handleOpenModal called:', { + log('handleOpenModal called:', { companyId: props.companyId, companyName: props.companyName, initialPersonId: props.initialPersonId, @@ -150,12 +152,12 @@ const handleOpenModal = async () => { }) if (!props.companyId) { - debugLog('Cannot open person modal without company') + log('Cannot open person modal without company') return } await openModal(props.companyId, props.companyName) - debugLog('PersonSelector - after openModal:', { + log('after openModal:', { isModalOpen: isModalOpen.value, people: people.value, selectedPerson: selectedPerson.value, @@ -164,13 +166,13 @@ const handleOpenModal = async () => { } const selectExistingPerson = (person: CompanyPerson) => { - debugLog('PersonSelector - selectExistingPerson:', person) + log('selectExistingPerson:', person) selectFromComposable(person) } const handleSavePerson = async () => { beginCreatePerson() - debugLog('PersonSelector - handleSavePerson: before save', { + log('handleSavePerson: before save', { personForm: personForm.value, selectedPerson: selectedPerson.value, }) @@ -203,7 +205,7 @@ const handleSavePerson = async () => { toast.dismiss('save-person') - debugLog('PersonSelector - handleSavePerson: after save', { + log('handleSavePerson: after save', { success, personForm: personForm.value, selectedPerson: selectedPerson.value, @@ -220,7 +222,7 @@ const handleSavePerson = async () => { } const handleEditPerson = (person: CompanyPerson) => { - debugLog('PersonSelector - handleEditPerson:', person) + log('handleEditPerson:', person) startEditPerson(person) } @@ -257,7 +259,7 @@ const isValidEmail = (email: string): boolean => { } const clearSelection = () => { - debugLog('PersonSelector - clearSelection') + log('clearSelection') clearFromComposable() } @@ -266,13 +268,13 @@ const updatePersonForm = (updatedPersonForm: PersonFormData) => { } const selectPrimaryPerson = async () => { - debugLog('PersonSelector - selectPrimaryPerson called', { + log('selectPrimaryPerson called', { companyId: props.companyId, peopleLength: people.value.length, }) if (!props.companyId) { - debugLog('Cannot select primary person without company', { + log('Cannot select primary person without company', { companyId: props.companyId, propsCompanyId: props.companyId, }) @@ -281,26 +283,26 @@ const selectPrimaryPerson = async () => { // Load people if not already loaded if (people.value.length === 0) { - debugLog('Loading people for company:', props.companyId) + log('Loading people for company:', props.companyId) await loadPeopleOnly(props.companyId) } // Find and select the primary person (without closing modal) const primaryPerson = findPrimaryPerson() - debugLog('selectPrimaryPerson:', { + log('selectPrimaryPerson:', { peopleLength: people.value.length, primaryPerson, currentSelectedPerson: selectedPerson.value, sameReference: primaryPerson === selectedPerson.value, }) if (primaryPerson) { - debugLog('Found primary person:', primaryPerson) + log('Found primary person:', primaryPerson) setSelectedPerson(primaryPerson) // Explicitly emit for JobCreateView - decideAndSelect uses suppressEmit but // selectPrimaryPerson is called by parent components that need the emit emitUpdates() } else { - debugLog('No primary person found', { + log('No primary person found', { totalPeople: people.value.length, people: people.value, }) @@ -318,7 +320,7 @@ defineExpose({ const emitUpdates = () => { if (suppressEmit.value) return - debugLog('PersonSelector - emitUpdates', { + log('emitUpdates', { displayValue: displayValue.value, selectedPerson: selectedPerson.value, }) @@ -329,7 +331,7 @@ const emitUpdates = () => { watch( selectedPerson, () => { - debugLog('PersonSelector - selectedPerson changed:', selectedPerson.value) + log('selectedPerson changed:', selectedPerson.value) emitUpdates() isHydrating.value = false }, @@ -339,7 +341,7 @@ watch( watch( () => [props.companyId, props.initialPersonId], async ([companyId, initialId]) => { - debugLog('PersonSelector - unified watch:', { companyId, initialId }) + log('unified watch:', { companyId, initialId }) // Clear local selection without emitting when the company changes. suppressEmit.value = true @@ -349,7 +351,7 @@ watch( suppressEmit.value = false if (!companyId) { - debugLog('Company ID vazio; nada a carregar.') + log('Company ID vazio; nada a carregar.') return } diff --git a/frontend/src/components/StaffPanel.vue b/frontend/src/components/StaffPanel.vue index 28dd38901..4736bcece 100644 --- a/frontend/src/components/StaffPanel.vue +++ b/frontend/src/components/StaffPanel.vue @@ -58,7 +58,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, onMounted, watch, nextTick } from 'vue' import StaffAvatar from './StaffAvatar.vue' @@ -66,6 +66,8 @@ import { useStaffApi } from '@/composables/useStaffApi' import { schemas } from '@/api/generated/api' import { z } from 'zod' +const log = debug('staff:panel') + // Use generated types from Zodios API type KanbanStaff = z.infer<typeof schemas.KanbanStaff> @@ -111,7 +113,7 @@ const loadStaffMembers = async (): Promise<void> => { })) } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load staff members' - debugLog('Error loading staff members:', err) + log('Error loading staff members:', err) } finally { isLoading.value = false } @@ -120,7 +122,7 @@ const loadStaffMembers = async (): Promise<void> => { const toggleStaffFilter = (staffId: string): void => { const index = activeFilters.value.indexOf(staffId) - debugLog('StaffPanel - Toggle staff filter:', { + log('Toggle staff filter:', { staffId, currentFilters: activeFilters.value, index, @@ -133,7 +135,7 @@ const toggleStaffFilter = (staffId: string): void => { activeFilters.value.push(staffId) } - debugLog('StaffPanel - After toggle:', { + log('After toggle:', { newFilters: activeFilters.value, }) @@ -141,7 +143,7 @@ const toggleStaffFilter = (staffId: string): void => { } const handleDragStart = (staffId: string, event: DragEvent): void => { - console.log('🎯 Staff drag start:', staffId) + log('drag start:', staffId) if (event.dataTransfer) { event.dataTransfer.setData('text/plain', staffId) event.dataTransfer.setData('application/x-drag-type', 'staff') @@ -158,7 +160,7 @@ const handleDragStart = (staffId: string, event: DragEvent): void => { } const handleDragEnd = (): void => { - console.log('🏁 Staff drag end') + log('drag end') // Remove visual feedback document.querySelectorAll('.job-card').forEach((card) => { ;(card as HTMLElement).style.outline = '' diff --git a/frontend/src/components/board/WorkshopModeView.vue b/frontend/src/components/board/WorkshopModeView.vue index a74235101..384d7d10a 100644 --- a/frontend/src/components/board/WorkshopModeView.vue +++ b/frontend/src/components/board/WorkshopModeView.vue @@ -20,9 +20,11 @@ import { DrawerTitle, } from '@/components/ui/drawer' import { toast } from 'vue-sonner' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import type { z } from 'zod' +const log = debug('workshop:board') + type WorkshopJob = z.infer<typeof schemas.WorkshopJob> const router = useRouter() @@ -64,7 +66,7 @@ const loadJobs = async () => { } } } catch (error) { - debugLog('Error loading workshop jobs:', error) + log('Error loading workshop jobs:', error) toast.error('Failed to load jobs. Please try again.') } finally { loading.value = false diff --git a/frontend/src/components/job/CameraModal.vue b/frontend/src/components/job/CameraModal.vue index 6776025c2..c355ce312 100644 --- a/frontend/src/components/job/CameraModal.vue +++ b/frontend/src/components/job/CameraModal.vue @@ -106,7 +106,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:camera') import { ref, watch, nextTick, onUnmounted } from 'vue' import { @@ -168,7 +170,7 @@ const initializeCamera = async () => { const stream = await startCamera() videoElement.value.srcObject = stream } catch (err) { - debugLog('Error initialising camera:', err) + log('Error initialising camera:', err) } finally { isInitializing.value = false } @@ -194,7 +196,7 @@ const handleCapture = async () => { emit('photo-captured', compressedPhoto) handleCancel() } catch (err) { - debugLog('Error capturing photo:', err) + log('Error capturing photo:', err) error.value = err instanceof Error ? err.message : 'Error capturing photo' } } diff --git a/frontend/src/components/job/JobActualTab.vue b/frontend/src/components/job/JobActualTab.vue index 4cd115560..18bcb768e 100644 --- a/frontend/src/components/job/JobActualTab.vue +++ b/frontend/src/components/job/JobActualTab.vue @@ -440,7 +440,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '../../utils/debug' +import debug from 'debug' + +const log = debug('job:actual') import { toLocalDateString } from '../../utils/dateUtils' import { formatCurrency, formatDate } from '@/utils/string-formatting' import { normalizeOptionalDecimal } from '@/utils/number' @@ -651,7 +653,7 @@ async function executeCreateInvoice(mode: string) { await loadInvoices() } catch (err: unknown) { let msg = 'Unexpected error while trying to create invoice.' - debugLog('Error creating invoice:', err) + log('Error creating invoice:', err) if ((err as AxiosError).isAxiosError) { const axiosErr = err as AxiosError<{ message: string }> const errorData = axiosErr.response?.data @@ -738,7 +740,7 @@ async function loadStaff() { {} as Record<string, KanbanStaff>, ) } catch (error) { - debugLog('Failed to load staff data:', error) + log('Failed to load staff data:', error) } } @@ -754,7 +756,7 @@ async function loadActualCosts() { // Ensure stock is loaded so UI has stock library available await checkAndUpdateNegativeStocks() } catch (error) { - debugLog('Failed to load actual cost lines:', error) + log('Failed to load actual cost lines:', error) } finally { isLoading.value = false } @@ -769,7 +771,7 @@ async function loadCostsSummary() { estimateTotal.value = response.estimate?.rev || 0 quoteTotal.value = response.quote?.rev || 0 } catch (error) { - debugLog('Failed to load costs summary:', error) + log('Failed to load costs summary:', error) } finally { costsSummaryLoading.value = false } @@ -783,7 +785,7 @@ async function loadInvoices() { // Zodios returns data directly, not wrapped in {success, data} invoices.value = response.invoices || [] } catch (error) { - debugLog('Failed to load invoices:', error) + log('Failed to load invoices:', error) } } @@ -840,7 +842,7 @@ async function consumeStockForNewLine(payload: { costLines.value.push(response.line) } - debugLog('[CONSUME-STOCK] New array: ', costLines.value, ' Received line: ', response.line) + log('[CONSUME-STOCK] New array: ', costLines.value, ' Received line: ', response.line) jobActualSaveFeedback.saved() emit('cost-line-changed') @@ -906,7 +908,7 @@ const { simpleSummary: actualSummary } = useCostSummary({ }) function navigateToDeliveryReceipt(purchaseOrderId: string) { - console.log('Received po id: ', purchaseOrderId) + log('Received po id: ', purchaseOrderId) router.push({ name: '/purchasing/po/[id]', params: { id: purchaseOrderId }, diff --git a/frontend/src/components/job/JobAttachmentsTab.vue b/frontend/src/components/job/JobAttachmentsTab.vue index f09a0a0a9..fa1bcb3c7 100644 --- a/frontend/src/components/job/JobAttachmentsTab.vue +++ b/frontend/src/components/job/JobAttachmentsTab.vue @@ -270,7 +270,9 @@ import { jobService } from '@/services/job.service' import { schemas } from '@/api/generated/api' import { formatFileSize, formatDateTime } from '@/utils/string-formatting' import type { z } from 'zod' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:attachments') import axios from '@/plugins/axios' import { useSaveFeedback } from '@/composables/useSaveFeedback' @@ -341,7 +343,7 @@ async function loadFiles() { localOnlyFiles.every((localFile) => localFile.id !== serverFile.id), ), ] - debugLog('Files loaded successfully:', files.value.length, 'files') + log('Files loaded successfully:', files.value.length, 'files') } catch (error) { console.error('❌ Failed to load files:', error) toast.error('Failed to load attachments') @@ -405,7 +407,7 @@ const handleFiles = async (fileList: File[]) => { const validFiles = fileList.filter((file) => { if (file.size === 0) { - debugLog(`File ${file.name} has 0 bytes and will be ignored`) + log(`File ${file.name} has 0 bytes and will be ignored`) return false } if (file.size > MAX_ATTACHMENT_SIZE_BYTES) { @@ -433,11 +435,11 @@ const processAndUploadFile = async (file: File, pendingId: string) => { if (isImageFile(file)) { updatePendingUpload(pendingId, { uploadStatus: 'preparing', uploadProgress: 0 }) - debugLog(`Compressing image before upload: ${file.name}`) + log(`Compressing image before upload: ${file.name}`) try { fileToUpload = await compressImage(file) } catch (error) { - debugLog(`Error compressing image ${file.name}:`, error) + log(`Error compressing image ${file.name}:`, error) markUploadFailed(pendingId, error) toast.error(`Failed to prepare ${file.name}`) return @@ -447,7 +449,7 @@ const processAndUploadFile = async (file: File, pendingId: string) => { try { await uploadFile(fileToUpload, pendingId) } catch (error) { - debugLog(`Error processing file ${file.name}:`, error) + log(`Error processing file ${file.name}:`, error) toast.error(`Failed to upload ${file.name}`) } } @@ -506,7 +508,7 @@ const compressImage = ( lastModified: Date.now(), }) - debugLog(`Image compressed: ${file.name} + log(`Image compressed: ${file.name} Original: ${formatFileSize(file.size)} Compressed: ${formatFileSize(compressedFile.size)}`) @@ -601,7 +603,7 @@ const uploadFile = async (file: File, pendingId: string) => { }) try { - debugLog('Uploading file:', file.name) + log('Uploading file:', file.name) const response = await jobService.uploadJobFiles(props.jobId, [file], (progressEvent) => { if (!progressEvent.total) { @@ -614,7 +616,7 @@ const uploadFile = async (file: File, pendingId: string) => { }) }) - debugLog('File uploaded successfully:', response) + log('File uploaded successfully:', response) toast.success(`File "${file.name}" uploaded successfully`) if (response.uploaded.length > 0) { @@ -625,7 +627,7 @@ const uploadFile = async (file: File, pendingId: string) => { throw new Error('Upload response did not include the saved file') } } catch (error) { - debugLog('Error uploading file:', error) + log('Error uploading file:', error) markUploadFailed(pendingId, error) throw error } @@ -673,7 +675,7 @@ async function downloadFile(file: AttachmentRow) { window.URL.revokeObjectURL(url) }, 1000) - debugLog('File opened for printing and download initiated:', file.filename) + log('File opened for printing and download initiated:', file.filename) } catch (error) { console.error('❌ Error downloading file:', error) toast.error('Failed to download file') @@ -693,7 +695,7 @@ async function deleteFile(id: string) { files.value = files.value.filter((f) => f.id !== id) try { - debugLog('Deleting file:', file.filename) + log('Deleting file:', file.filename) const result = await jobService.deleteJobFile(props.jobId, id) @@ -718,7 +720,7 @@ async function updatePrintSetting(file: AttachmentRow) { try { printSettingFeedback.saving() - debugLog('Updating print setting for file:', { + log('Updating print setting for file:', { filename: file.filename, print_on_jobsheet: file.print_on_jobsheet, job_id: props.jobId, @@ -754,7 +756,7 @@ const closeCameraModal = () => { const handlePhotoCaptured = async (photo: File) => { try { - debugLog('Photo captured:', { + log('Photo captured:', { name: photo.name, size: formatFileSize(photo.size), type: photo.type, @@ -764,7 +766,7 @@ const handlePhotoCaptured = async (photo: File) => { await processAndUploadFile(photo, pendingUpload.id) toast.success('Photo uploaded successfully!') } catch (error) { - debugLog('Error uploading captured photo:', error) + log('Error uploading captured photo:', error) toast.error('Failed to upload photo') } } @@ -788,7 +790,7 @@ const onImageError = (file: AttachmentRow, type: 'thumbnail' | 'download') => { } else { file.downloadError = true } - debugLog(`Failed to load image ${type} for file:`, file.filename) + log(`Failed to load image ${type} for file:`, file.filename) } // Helper functions diff --git a/frontend/src/components/job/JobEstimateTab.vue b/frontend/src/components/job/JobEstimateTab.vue index 66585dc8d..1585636be 100644 --- a/frontend/src/components/job/JobEstimateTab.vue +++ b/frontend/src/components/job/JobEstimateTab.vue @@ -91,7 +91,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '../../utils/debug' +import debug from 'debug' + +const log = debug('job:estimate') import { computed, onMounted, ref } from 'vue' import { toast } from 'vue-sonner' @@ -153,7 +155,7 @@ async function loadEstimate() { })) revision.value = costSet.rev || 0 } catch (error) { - debugLog('Failed to load estimate cost lines:', error) + log('Failed to load estimate cost lines:', error) } finally { isLoading.value = false } diff --git a/frontend/src/components/job/JobPdfDialog.vue b/frontend/src/components/job/JobPdfDialog.vue index a83308a1e..74ccd6bad 100644 --- a/frontend/src/components/job/JobPdfDialog.vue +++ b/frontend/src/components/job/JobPdfDialog.vue @@ -27,7 +27,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:pdf') import { Dialog, @@ -65,7 +67,7 @@ watch( const blob = await jobService.getWorkshopPdf(props.jobId) blobUrl.value = URL.createObjectURL(blob) } catch (err) { - debugLog('Error generating blobUrl from PDF:', err) + log('Error generating blobUrl from PDF:', err) throw err } } @@ -84,7 +86,7 @@ async function attachPdf() { // Note: attachWorkshopPdf functionality not available in clean API attached.value = true } catch (err) { - debugLog('Error attaching PDF:', err) + log('Error attaching PDF:', err) throw err } finally { attaching.value = false diff --git a/frontend/src/components/job/JobPricingGrids.vue b/frontend/src/components/job/JobPricingGrids.vue index 956683a20..1ed55522a 100644 --- a/frontend/src/components/job/JobPricingGrids.vue +++ b/frontend/src/components/job/JobPricingGrids.vue @@ -86,7 +86,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:pricing') import { formatCurrency } from '@/utils/string-formatting' import { ref, watch } from 'vue' @@ -197,7 +199,7 @@ watch( reality.value = extractSectionTotals(pricingData.reality) } } catch (error) { - debugLog('Error parsing pricing data:', error) + log('Error parsing pricing data:', error) estimates.value = { time: 100, materials: 200, adjustments: 50, total: 350 } quotes.value = { time: 120, materials: 220, adjustments: 60, total: 400 } diff --git a/frontend/src/components/job/JobQuoteTab.vue b/frontend/src/components/job/JobQuoteTab.vue index 2b6041e65..73f963aff 100644 --- a/frontend/src/components/job/JobQuoteTab.vue +++ b/frontend/src/components/job/JobQuoteTab.vue @@ -487,7 +487,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '../../utils/debug' +import debug from 'debug' + +const log = debug('job:quote') import { toLocalDateString } from '../../utils/dateUtils' import { ref, computed, watch, onMounted } from 'vue' @@ -702,9 +704,9 @@ async function refreshQuoteData(showLoading = true) { if (showLoading) isLoading.value = true // DEBUG: Log before refresh - debugLog('REFRESH QUOTE - BEFORE:') - debugLog(' - Current quote rev:', currentQuote.value?.quote?.rev) - debugLog(' - Current cost lines count:', costLines.value.length) + log('REFRESH QUOTE - BEFORE:') + log(' - Current quote rev:', currentQuote.value?.quote?.rev) + log(' - Current cost lines count:', costLines.value.length) try { const response = await fetchCostSet(props.jobId, 'quote') @@ -765,7 +767,7 @@ async function fetchQuoteRevisions() { async function onCreateNewRevision() { if (!props.jobId) return - console.log('onCreateNewRevision jobId:', props.jobId) + log('onCreateNewRevision jobId:', props.jobId) isCreatingRevision.value = true toast.info('Creating new quote revision...', { id: 'create-revision' }) try { @@ -844,7 +846,7 @@ const executeCreateQuote = async (breakdown: boolean) => { await refreshQuoteData() emit('cost-line-changed') } catch (err: unknown) { - debugLog('Error creating quote:', err) + log('Error creating quote:', err) let msg = 'Unexpected error while trying to create quote.' if ((err as AxiosError).isAxiosError) { const axiosErr = err as AxiosError<{ message: string }> @@ -910,7 +912,7 @@ const deleteQuoteOnXero = async () => { await refreshQuoteData() emit('cost-line-changed') } catch (err: unknown) { - debugLog('Error deleting quote:', err) + log('Error deleting quote:', err) let msg = 'Unexpected error while trying to delete quote.' if ((err as AxiosError).isAxiosError) { const axiosErr = err as AxiosError<{ message: string }> @@ -998,7 +1000,7 @@ async function onCopyFromEstimate() { } onMounted(() => { - console.log('[QUOTE-TAB]: Props ', { + log('Props ', { jobId: props.jobId, jobNumber: props.jobNumber, pricingMethodology: props.pricingMethodology, diff --git a/frontend/src/components/job/JobQuotingChatTab.vue b/frontend/src/components/job/JobQuotingChatTab.vue index 3005a51a1..e6bb9a7ee 100644 --- a/frontend/src/components/job/JobQuotingChatTab.vue +++ b/frontend/src/components/job/JobQuotingChatTab.vue @@ -138,7 +138,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, onMounted } from 'vue' import { Send, Paperclip, RotateCcw } from 'lucide-vue-next' import McpToolDetails from '@/components/chat/McpToolDetails.vue' @@ -147,6 +147,8 @@ import type { VueChatMessage } from '@/constants/vue-chat-message' import { schemas } from '@/api/generated/api' import { toast } from 'vue-sonner' +const log = debug('quote:chat') + interface Props { jobId: string jobName?: string @@ -180,7 +182,7 @@ const handleSendMessage = async () => { if (!currentInput.value.trim() || isLoading.value) return const messageContent = currentInput.value.trim() - debugLog('messageContent:', messageContent) + log('messageContent:', messageContent) currentInput.value = '' isLoading.value = true @@ -193,7 +195,7 @@ const handleSendMessage = async () => { timestamp: new Date().toISOString(), system: false, } - debugLog('userMessage:', userMessage) + log('userMessage:', userMessage) messages.value.push(userMessage) await saveMessage(userMessage, 'user') @@ -207,7 +209,7 @@ const handleSendMessage = async () => { const assistantMessage = quoteChatService.convertToVueMessage(backendAssistantMessage) messages.value.push(assistantMessage) } catch (error) { - debugLog('Chat processing failed:', error) + log('Chat processing failed:', error) const lastMessage = messages.value[messages.value.length - 1] if (lastMessage && lastMessage.senderId === 'assistant-1') { lastMessage.content = 'Sorry, I had trouble processing that. Please try again.' @@ -230,7 +232,7 @@ const formatTime = (timestamp: string): string => { hour12: true, }) } catch (error) { - debugLog('Error formatting time:', error) + log('Error formatting time:', error) return 'Invalid time' } } @@ -278,7 +280,7 @@ const loadChatHistory = async () => { const saveMessage = async (message: VueChatMessage, role: 'user' | 'assistant') => { try { const backendMessage = quoteChatService.convertFromVueMessage(message, role) - debugLog('backendMessage:', backendMessage) + log('backendMessage:', backendMessage) await quoteChatService.saveMessage(props.jobId, backendMessage) } catch (error) { console.error('Failed to save chat message:', error) @@ -326,7 +328,7 @@ const handleFileUpload = async (event: Event) => { if (!files || files.length === 0) return - debugLog( + log( 'Files selected:', Array.from(files).map((f) => f.name), ) @@ -340,8 +342,8 @@ const handleFileUpload = async (event: Event) => { } onMounted(async () => { - debugLog('JobQuotingChatTab mounted for job:', props.jobId) + log('JobQuotingChatTab mounted for job:', props.jobId) await loadChatHistory() - debugLog('Chat history loaded') + log('Chat history loaded') }) </script> diff --git a/frontend/src/components/job/JobSettingsTab.vue b/frontend/src/components/job/JobSettingsTab.vue index 6baa03bcd..54a044431 100644 --- a/frontend/src/components/job/JobSettingsTab.vue +++ b/frontend/src/components/job/JobSettingsTab.vue @@ -376,7 +376,9 @@ import CompanyLookup from '../CompanyLookup.vue' import PersonSelector from '../PersonSelector.vue' import CreateCompanyModal from '../CreateCompanyModal.vue' import type { Company } from '../../composables/useCompanyLookup' -import { debugLog } from '../../utils/debug' +import debug from 'debug' + +const log = debug('job:settings') import { toast } from 'vue-sonner' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card' import { api } from '../../api/client' @@ -539,7 +541,7 @@ async function loadBasicInfo() { }) } } catch (e) { - debugLog('Failed to load basic job information: ', e) + log('Failed to load basic job information: ', e) } finally { isHydratingBasicInfo.value = false basicInfoLoading.value = false @@ -667,7 +669,7 @@ const resetCompanyChangeState = () => { // Handle field input changes const handleFieldInput = (field: string, value: string) => { - debugLog('[handleFieldInput] called', { field, value, isInitializing: isInitializing.value }) + log('[handleFieldInput] called', { field, value, isInitializing: isInitializing.value }) if (!localJobData.value) return const newValue = value || '' @@ -1058,7 +1060,7 @@ const handleCompanyLookupSelected = (company: Company | null) => { const confirmCompanyChange = () => { if (!newCompanyId.value || !selectedNewCompany.value) { - debugLog('No new company selected') + log('No new company selected') return } @@ -1089,7 +1091,7 @@ const confirmCompanyChange = () => { const editCurrentCompany = async () => { if (!jobData.value?.company_id) { - debugLog('No current company to edit') + log('No current company to edit') return } @@ -1297,7 +1299,7 @@ const autosave = createJobAutosave({ const serverJobDetail = result.data?.data?.job if (serverJobDetail?.id && serverJobDetail.id !== props.jobId) { - debugLog('Ignoring stale response for different job', { + log('Ignoring stale response for different job', { expected: props.jobId, received: serverJobDetail.id, }) @@ -1638,7 +1640,6 @@ const autosave = createJobAutosave({ return { success: false, error: msg, conflict: isConcurrencyError } } }, - devLogging: true, }) /** Life-cycle bindings */ @@ -1700,7 +1701,7 @@ onMounted(() => { void autosave.flush('retry-click') } } catch (error) { - debugLog('Failed to reload job data for retry:', error) + log('Failed to reload job data for retry:', error) toast.error('Failed to reload job data. Please refresh the page.') } }) diff --git a/frontend/src/components/job/JobViewTabs.vue b/frontend/src/components/job/JobViewTabs.vue index 0c165ab4a..203954c77 100644 --- a/frontend/src/components/job/JobViewTabs.vue +++ b/frontend/src/components/job/JobViewTabs.vue @@ -113,7 +113,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:view') import JobEstimateTab from './JobEstimateTab.vue' import JobQuoteTab from './JobQuoteTab.vue' import JobActualTab from './JobActualTab.vue' @@ -197,13 +199,13 @@ const fullyInvoicedBoolean = computed(() => props.fullyInvoiced || false) watch( () => props.companyDefaults, (val) => { - debugLog('[JobViewTabs] companyDefaults prop changed:', val) + log('companyDefaults prop changed:', val) }, ) watch( () => props.activeTab, (val) => { - debugLog('[JobViewTabs] activeTab changed:', val) + log('activeTab changed:', val) }, ) </script> diff --git a/frontend/src/components/job/WorkshopPdfViewer.vue b/frontend/src/components/job/WorkshopPdfViewer.vue index cc776bba1..2a5bfb721 100644 --- a/frontend/src/components/job/WorkshopPdfViewer.vue +++ b/frontend/src/components/job/WorkshopPdfViewer.vue @@ -19,12 +19,14 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, onMounted } from 'vue' import PDF from 'pdf-vue3' import { jobService } from '@/services/job.service' +const log = debug('workshop:pdf') + const props = defineProps<{ jobId: string }>() const pdfData = ref<Uint8Array | null>(null) const hasError = ref(false) @@ -37,7 +39,7 @@ async function loadPdf() { pdfData.value = new Uint8Array(buffer) } catch (err) { hasError.value = true - debugLog('Error loading PDF:', err) + log('Error loading PDF:', err) } } diff --git a/frontend/src/components/job/__tests__/JobSettingsTab.companyChange.test.ts b/frontend/src/components/job/__tests__/JobSettingsTab.companyChange.test.ts index 3ca3f98a6..375972f50 100644 --- a/frontend/src/components/job/__tests__/JobSettingsTab.companyChange.test.ts +++ b/frontend/src/components/job/__tests__/JobSettingsTab.companyChange.test.ts @@ -110,10 +110,6 @@ vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - import JobSettingsTab from '../JobSettingsTab.vue' const passthrough = defineComponent({ diff --git a/frontend/src/components/job/__tests__/JobSettingsTab.labourRate.test.ts b/frontend/src/components/job/__tests__/JobSettingsTab.labourRate.test.ts index c36477793..3c647742b 100644 --- a/frontend/src/components/job/__tests__/JobSettingsTab.labourRate.test.ts +++ b/frontend/src/components/job/__tests__/JobSettingsTab.labourRate.test.ts @@ -110,10 +110,6 @@ vi.mock('vue-sonner', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('@/api/generated/api', async (importOriginal) => { const actual = await importOriginal<typeof import('@/api/generated/api')>() return actual diff --git a/frontend/src/components/job/__tests__/JobSettingsTab.urgent.test.ts b/frontend/src/components/job/__tests__/JobSettingsTab.urgent.test.ts index 489d43122..a1ae53cae 100644 --- a/frontend/src/components/job/__tests__/JobSettingsTab.urgent.test.ts +++ b/frontend/src/components/job/__tests__/JobSettingsTab.urgent.test.ts @@ -107,10 +107,6 @@ vi.mock('vue-sonner', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('@/api/generated/api', async (importOriginal) => { const actual = await importOriginal<typeof import('@/api/generated/api')>() return actual diff --git a/frontend/src/components/purchasing/AddressAutocompleteInput.vue b/frontend/src/components/purchasing/AddressAutocompleteInput.vue index 775051a39..21080e2ff 100644 --- a/frontend/src/components/purchasing/AddressAutocompleteInput.vue +++ b/frontend/src/components/purchasing/AddressAutocompleteInput.vue @@ -62,9 +62,11 @@ <script setup lang="ts"> import { ref, watch, onMounted } from 'vue' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import type { AddressCandidate } from '@/composables/usePickupAddressManagement' +const log = debug('po:address-autocomplete') + const props = withDefaults( defineProps<{ modelValue: string @@ -141,10 +143,10 @@ const searchAddress = async (query: string) => { if (suggestions.value.length > 0) { showSuggestions.value = true } - debugLog('Address suggestions:', suggestions.value) + log('Address suggestions:', suggestions.value) } catch (error) { if (requestId === currentRequestId) { - debugLog('Error fetching address suggestions:', error) + log('Error fetching address suggestions:', error) suggestions.value = [] } } finally { diff --git a/frontend/src/components/purchasing/AllocationCellEditor.vue b/frontend/src/components/purchasing/AllocationCellEditor.vue index 133491a60..7e0923a25 100644 --- a/frontend/src/components/purchasing/AllocationCellEditor.vue +++ b/frontend/src/components/purchasing/AllocationCellEditor.vue @@ -7,9 +7,11 @@ import { History, Plus, Trash2, Zap, Package } from 'lucide-vue-next' import JobSelect from '@/components/purchasing/JobSelect.vue' import type { z } from 'zod' import { schemas } from '@/api/generated/api' -import { debugLog } from '../../utils/debug' +import debug from 'debug' import { api } from '../../api/client' +const log = debug('po:allocation') + type PurchaseOrderLine = z.infer<typeof schemas.PurchaseOrderLine> type JobForPurchasing = z.infer<typeof schemas.JobForPurchasing> type AllocationItem = z.infer<typeof schemas.AllocationItem> @@ -197,7 +199,7 @@ async function deleteAllocation(allocationId: string, allocationType: 'job' | 's throw new Error(response.message || 'Failed to delete allocation') } } catch (error) { - debugLog('Error deleting allocation:', error) + log('Error deleting allocation:', error) } } diff --git a/frontend/src/components/purchasing/PickupAddressSelector.vue b/frontend/src/components/purchasing/PickupAddressSelector.vue index 59915f112..075b65b98 100644 --- a/frontend/src/components/purchasing/PickupAddressSelector.vue +++ b/frontend/src/components/purchasing/PickupAddressSelector.vue @@ -68,7 +68,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toast } from 'vue-sonner' import { ref, watch } from 'vue' @@ -80,6 +80,8 @@ import { z } from 'zod' type SupplierPickupAddress = z.infer<typeof schemas.SupplierPickupAddress> +const log = debug('po:pickup-address') + const props = withDefaults( defineProps<{ id: string @@ -194,7 +196,7 @@ watch(selectedAddress, (newAddress) => { const handleOpenModal = () => { if (!props.supplierId || props.disabled) { - debugLog('Cannot open modal without supplier ID or when disabled') + log('Cannot open modal without supplier ID or when disabled') return } openModal(props.supplierId, props.supplierName) @@ -249,7 +251,7 @@ const selectPrimaryAddress = async () => { const primary = findPrimaryAddress() if (primary) { setSelectedAddress(primary) - debugLog('Auto-selected primary address:', primary.name) + log('Auto-selected primary address:', primary.name) } } diff --git a/frontend/src/components/purchasing/PoLinesTable.vue b/frontend/src/components/purchasing/PoLinesTable.vue index 375c025a8..6d679daf6 100644 --- a/frontend/src/components/purchasing/PoLinesTable.vue +++ b/frontend/src/components/purchasing/PoLinesTable.vue @@ -12,7 +12,7 @@ import { schemas } from '@/api/generated/api' import type { DataTableRowContext } from '@/utils/data-table-types' import type { ColumnDef } from '@tanstack/vue-table' import { z } from 'zod' -import { debugLog } from '../../utils/debug' +import debug from 'debug' import { gridCellAttrs, handleGridCellKeydown, @@ -87,6 +87,8 @@ function isSelectableStockItem(value: unknown): value is { return 'description' in value && 'unit_cost' in value } +const log = debug('po:lines-table') + const props = defineProps<Props>() const emit = defineEmits<Emits>() @@ -231,7 +233,7 @@ const columns = computed<ColumnDef<PurchaseOrderLine>[]>(() => { onSelectedItem: isColumnDisabled.value ? undefined : (selected) => { - debugLog('PoLinesTable: Received selected item:', selected) + log('Received selected item:', selected) if (!isSelectableStockItem(selected)) { openItemSelectIndex.value = -1 return @@ -460,7 +462,7 @@ const columns = computed<ColumnDef<PurchaseOrderLine>[]>(() => { }) onMounted(() => { - debugLog('Props ', props) + log('Props ', props) }) </script> diff --git a/frontend/src/components/shared/SmartCostLinesTable.vue b/frontend/src/components/shared/SmartCostLinesTable.vue index f89c9b006..2b8d05df8 100644 --- a/frontend/src/components/shared/SmartCostLinesTable.vue +++ b/frontend/src/components/shared/SmartCostLinesTable.vue @@ -24,7 +24,7 @@ import { Textarea } from '../ui/textarea' import ItemSelect from '../../views/purchasing/ItemSelect.vue' import type { DataTableRowContext } from '../../utils/data-table-types' import { toast } from 'vue-sonner' -import { debugLog } from '../../utils/debug' +import debug from 'debug' import { formatCurrency } from '../../utils/string-formatting' import { roundToDecimalPlaces } from '@/utils/number' import { requiredNumber } from '@/utils/requiredNumber' @@ -66,6 +66,8 @@ import { import { schemas } from '../../api/generated/api' import type { z } from 'zod' +const log = debug('cost:lines-table') + // Types from generated schemas // Extend CostLine type to include timestamp fields (to be added to backend schema) type CostLine = z.infer<typeof schemas.CostLine> & { @@ -179,7 +181,7 @@ function selectEmptyLine(): void { } function resetEmptyLine(kind: KindOption = 'material') { - debugLog('resetEmptyLine called with kind:', kind) + log('resetEmptyLine called with kind:', kind) resetPhantom(makeEmptyLine(kind)) } @@ -284,7 +286,7 @@ function updateLineKind(line: CostLine, newKind: KindOption) { // Save if line has real ID and meets baseline if (line.id && isLineReadyForSave(line)) { - debugLog('Saving kind change:', line.id, newKind) + log('Saving kind change:', line.id, newKind) const patch: PatchedCostLineCreateUpdate = { kind: newKind, labour_subtype: line.labour_subtype ?? null, @@ -485,8 +487,8 @@ function isUnapproved(line: CostLine): boolean { function isNegativeStock(line: CostLine): boolean { if (!line?.id || !isStockLine(line)) return false const stockId = (line.ext_refs as Record<string, unknown>)?.stock_id - console.log( - 'DEBUG: isNegativeStock - stockId:', + log( + 'isNegativeStock - stockId:', stockId, 'type:', typeof stockId, @@ -579,18 +581,18 @@ const { onKeydown } = useGridKeyboardNav({ const line = displayLines.value[i] // Only duplicate actual lines, not auto-generated empty ones if (line.id || props.lines.includes(line)) { - debugLog('SmartCostLinesTable emitting event: duplicate-line', line) + log('SmartCostLinesTable emitting event: duplicate-line', line) emit('duplicate-line', line) } } }, deleteSelected: () => { const i = selectedRowIndex.value - debugLog('Keyboard delete triggered for selectedRowIndex:', i) + log('Keyboard delete triggered for selectedRowIndex:', i) if (i >= 0 && i < displayLines.value.length) { const line = displayLines.value[i] - debugLog('Keyboard delete for line:', { + log('Keyboard delete for line:', { lineId: line.id, selectedIndex: i, lineDesc: line.desc, @@ -598,23 +600,23 @@ const { onKeydown } = useGridKeyboardNav({ }) if (line.id) { - debugLog('Keyboard emitting delete-line with line.id:', line.id) + log('Keyboard emitting delete-line with line.id:', line.id) autosave.cancel(line) emit('delete-line', line.id as string) } else { // Find the actual index in the original props.lines array const actualIndex = props.lines.findIndex((l) => l === line) - debugLog('Keyboard looking for local line in props.lines:', { + log('Keyboard looking for local line in props.lines:', { actualIndex, foundLine: actualIndex >= 0 ? props.lines[actualIndex] : null, }) if (actualIndex >= 0) { - debugLog('Keyboard emitting delete-line with actualIndex:', actualIndex) + log('Keyboard emitting delete-line with actualIndex:', actualIndex) autosave.cancel(line) emit('delete-line', actualIndex) } else { - debugLog('Keyboard: Auto-generated empty line - cannot delete, ignoring') + log('Keyboard: Auto-generated empty line - cannot delete, ignoring') } } } @@ -886,13 +888,13 @@ const columns = computed(() => { } if (val) { - debugLog('Storing item selection:', { val, lineId: line.id }) + log('Storing item selection:', { val, lineId: line.id }) // For regular items, we'll fetch the data below and update selectedItemMap.set(line, { id: val, description: '', item_code: '' }) - debugLog('Stored placeholder for stock item in selectedItemMap') + log('Stored placeholder for stock item in selectedItemMap') } else { selectedItemMap.set(line, null) - debugLog('Cleared selectedItemMap for line') + log('Cleared selectedItemMap for line') } // Infer kind based on selection @@ -949,7 +951,7 @@ const columns = computed(() => { // Look up stock item from store (already loaded for the dropdown) const found = val ? store.items.find((item) => item.id === val) : null if (found) { - debugLog('Found stock item in store:', { + log('Found stock item in store:', { id: found.id, item_code: found.item_code, description: found.description, @@ -974,7 +976,7 @@ const columns = computed(() => { description: found.description || '', item_code: found.item_code || '', }) - debugLog('Updated line with stock item data:', { + log('Updated line with stock item data:', { id: val, description: found.description || '', item_code: found.item_code || '', @@ -997,7 +999,7 @@ const columns = computed(() => { maybePersistNewLine(line) } } else if (val) { - debugLog('Stock item not found in store for id:', val) + log('Stock item not found in store for id:', val) updateLine(line, { desc: '', unit_cost: 0 }) selectedItemMap.set(line, null) } @@ -1252,7 +1254,7 @@ const columns = computed(() => { } if (!line.id || !isLineReadyForSave(line)) { - debugLog('Skipping unit_cost save:', { + log('Skipping unit_cost save:', { editable, id: line.id, ready: isLineReadyForSave(line), @@ -1260,7 +1262,7 @@ const columns = computed(() => { return } - debugLog('Saving unit_cost change:', line.id, line.unit_cost) + log('Saving unit_cost change:', line.id, line.unit_cost) // For material/adjust, unit_rev may be auto recalculated unless overridden const derived = apply(line).derived const patch: PatchedCostLineCreateUpdate = { @@ -1343,7 +1345,7 @@ const columns = computed(() => { } if (!line.id || !isLineReadyForSave(line)) { - debugLog('Skipping unit_rev save:', { + log('Skipping unit_rev save:', { editable, id: line.id, ready: isLineReadyForSave(line), @@ -1351,7 +1353,7 @@ const columns = computed(() => { return } - debugLog('Saving unit_rev change:', line.id, line.unit_rev) + log('Saving unit_rev change:', line.id, line.unit_rev) const patch: PatchedCostLineCreateUpdate = { unit_rev: requiredNumber(line.unit_rev, 'cost line unit_rev'), } @@ -1601,7 +1603,7 @@ const columns = computed(() => { e.stopPropagation() if (disabled) return - debugLog('Delete button clicked for line:', { + log('Delete button clicked for line:', { lineId: line.id, rowIndex: row.index, lineDesc: line.desc, @@ -1619,19 +1621,19 @@ const columns = computed(() => { } // Find the actual index in the original props.lines array const actualIndex = props.lines.findIndex((l) => l === line) - debugLog('Looking for local line in props.lines:', { + log('Looking for local line in props.lines:', { actualIndex, foundLine: actualIndex >= 0 ? props.lines[actualIndex] : null, searchedLine: line, }) if (actualIndex >= 0) { - debugLog('Emitting delete-line with actualIndex:', actualIndex) + log('Emitting delete-line with actualIndex:', actualIndex) autosave.cancel(line) emit('delete-line', actualIndex) } else { // This is the auto-generated empty line - don't delete it, just clear it - debugLog('Auto-generated empty line - cannot delete, ignoring') + log('Auto-generated empty line - cannot delete, ignoring') return } return @@ -1640,7 +1642,7 @@ const columns = computed(() => { // For saved lines, ask for confirmation const confirmed = window.confirm('Delete this line? This action cannot be undone.') if (!confirmed) return - debugLog('Emitting delete-line with line.id:', line.id) + log('Emitting delete-line with line.id:', line.id) autosave.cancel(line) emit('delete-line', line.id as string) }, diff --git a/frontend/src/components/shared/__tests__/SmartCostLinesTable.kindInference.test.ts b/frontend/src/components/shared/__tests__/SmartCostLinesTable.kindInference.test.ts index 68c0fe095..7c9e0a3a9 100644 --- a/frontend/src/components/shared/__tests__/SmartCostLinesTable.kindInference.test.ts +++ b/frontend/src/components/shared/__tests__/SmartCostLinesTable.kindInference.test.ts @@ -81,10 +81,6 @@ vi.mock('@/components/DataTable.vue', () => ({ }), })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), diff --git a/frontend/src/components/shared/__tests__/SmartCostLinesTable.test.ts b/frontend/src/components/shared/__tests__/SmartCostLinesTable.test.ts index 29b6929f9..37c35d826 100644 --- a/frontend/src/components/shared/__tests__/SmartCostLinesTable.test.ts +++ b/frontend/src/components/shared/__tests__/SmartCostLinesTable.test.ts @@ -72,10 +72,6 @@ vi.mock('@/components/DataTable.vue', () => ({ }), })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), diff --git a/frontend/src/components/shared/__tests__/SmartCostLinesTable.timeGuard.test.ts b/frontend/src/components/shared/__tests__/SmartCostLinesTable.timeGuard.test.ts index 751c3698a..d946d6c21 100644 --- a/frontend/src/components/shared/__tests__/SmartCostLinesTable.timeGuard.test.ts +++ b/frontend/src/components/shared/__tests__/SmartCostLinesTable.timeGuard.test.ts @@ -106,10 +106,6 @@ vi.mock('@/views/purchasing/ItemSelect.vue', () => ({ }), })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: toastErrorMock, diff --git a/frontend/src/components/timesheet/SmartTimesheetTable.vue b/frontend/src/components/timesheet/SmartTimesheetTable.vue index 82601a12a..95424ca95 100644 --- a/frontend/src/components/timesheet/SmartTimesheetTable.vue +++ b/frontend/src/components/timesheet/SmartTimesheetTable.vue @@ -37,7 +37,7 @@ import { usePhantomRow } from '@/composables/usePhantomRow' import { costlineService } from '@/services/costline.service' import { formatCurrency } from '@/utils/string-formatting' import { logError } from '@/utils/error-handler' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { requiredNumber } from '@/utils/requiredNumber' import { getRateMultiplier, @@ -54,6 +54,8 @@ import { rateForSubtype } from '@/utils/labourRates' import { schemas } from '@/api/generated/api' import type { z } from 'zod' +const log = debug('timesheet:table') + type TimesheetCostLine = z.infer<typeof schemas.TimesheetCostLine> type Job = z.infer<typeof schemas.ModernTimesheetJob> type CostLine = z.infer<typeof schemas.CostLine> @@ -221,7 +223,7 @@ const autosave = useCostLineAutosave({ // The actual error message is shown by useCostLineAutosave's own toast. // We log details via the saveFn wrapper above; keep this rollback notice // simple so users aren't toast-spammed. - debugLog('SmartTimesheetTable: row rolled back after save failure', { lineId: line.id }) + log('SmartTimesheetTable: row rolled back after save failure', { lineId: line.id }) }, onSaved: (line, response, patch) => { // The backend reprices unit_rev when labour_subtype changes; refresh the @@ -356,7 +358,7 @@ function setRate(entry: TimesheetCostLine, rateType: string): void { // Non-Ord: use the dedicated Xero pay item registered for that multiplier. const payItem = props.payItemsByMultiplier?.[String(mult)] if (payItem) { - debugLog('SmartTimesheetTable: resolved Xero pay item for pay rate', { + log('SmartTimesheetTable: resolved Xero pay item for pay rate', { multiplier: mult, payItemId: payItem.id, payItemName: payItem.name, diff --git a/frontend/src/components/timesheet/SummaryDrawer.vue b/frontend/src/components/timesheet/SummaryDrawer.vue index 0c6c659e9..93cdf8281 100644 --- a/frontend/src/components/timesheet/SummaryDrawer.vue +++ b/frontend/src/components/timesheet/SummaryDrawer.vue @@ -205,10 +205,12 @@ import { import { useTimesheetSummary } from '@/composables/useTimesheetSummary' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '../../utils/debug' +import debug from 'debug' import { z } from 'zod' import { formatCurrency, formatHoursDisplay } from '@/utils/string-formatting' +const log = debug('timesheet:summary') + type ModernTimesheetJob = z.infer<typeof schemas.ModernTimesheetJob> type FullJob = z.infer<typeof schemas.Job> | z.infer<typeof schemas.JobSummary> type TimesheetCostLine = z.infer<typeof schemas.TimesheetCostLine> @@ -268,16 +270,16 @@ const loadJobDetails = async () => { if (jobIdsWithEntries.value.length === 0) return loadingJobDetails.value = true - debugLog('Loading job details for jobs with entries:', jobIdsWithEntries.value) + log('Loading job details for jobs with entries:', jobIdsWithEntries.value) try { const jobPromises = jobIdsWithEntries.value.map(async (jobId) => { try { const jobDetail = await api.getJobSummary({ params: { job_id: jobId } }) - debugLog('Job detail: ', jobDetail) + log('Job detail: ', jobDetail) return { jobId, job: jobDetail.data.job } } catch (err) { - debugLog('Failed to load job details for:', jobId, err) + log('Failed to load job details for:', jobId, err) return null } }) @@ -292,9 +294,9 @@ const loadJobDetails = async () => { }) enhancedJobs.value = newEnhancedJobs - debugLog('Loaded enhanced job details:', enhancedJobs.value.size) + log('Loaded enhanced job details:', enhancedJobs.value.size) } catch (err) { - debugLog('Error loading job details:', err) + log('Error loading job details:', err) } finally { loadingJobDetails.value = false } @@ -323,14 +325,14 @@ watch( // Computed properties const activeJobs = computed(() => { - debugLog('SummaryDrawer - Computing active jobs:', { + log('Computing active jobs:', { totalJobs: props.jobs.length, jobs: props.jobs .slice(0, 3) .map((j) => ({ id: j.id, job_number: j.job_number, status: j.status })), }) const active = getActiveJobs(props.jobs) - debugLog('SummaryDrawer - Active jobs result:', { + log('Active jobs result:', { activeJobsCount: active.length, activeJobs: active .slice(0, 3) @@ -348,7 +350,7 @@ const consolidatedSummary = computed(() => ({ })) const activeJobsWithData = computed(() => { - debugLog('SummaryDrawer - Computing jobs with data:', { + log('Computing jobs with data:', { activeJobsCount: activeJobs.value.length, timeEntriesCount: props.timeEntries.length, enhancedJobsCount: enhancedJobs.value.size, @@ -372,7 +374,7 @@ const activeJobsWithData = computed(() => { const completionPercentage = getCompletionPercentage(actualHours, estimatedHours) const isOverBudget = isJobOverBudget(actualHours, estimatedHours) - debugLog(`Job ${job.job_number} data:`, { + log(`Job ${job.job_number} data:`, { jobId: job.id, actualHours, estimatedHours, @@ -393,7 +395,7 @@ const activeJobsWithData = computed(() => { .filter((jobData) => jobData !== null) // Remove null entries .sort((a, b) => b.actualHours - a.actualHours) // Sort by hours worked (descending) - debugLog('SummaryDrawer - Final jobs with data:', { + log('Final jobs with data:', { filteredJobsCount: jobsWithData.length, jobs: jobsWithData.map((jd) => ({ job_number: jd.job.job_number, diff --git a/frontend/src/composables/__tests__/useOptimizedDragAndDrop.test.ts b/frontend/src/composables/__tests__/useOptimizedDragAndDrop.test.ts index 542da624e..b12d7090a 100644 --- a/frontend/src/composables/__tests__/useOptimizedDragAndDrop.test.ts +++ b/frontend/src/composables/__tests__/useOptimizedDragAndDrop.test.ts @@ -19,14 +19,6 @@ vi.mock('sortablejs', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - -vi.mock('../utils/debug', () => ({ - debugLog: vi.fn(), -})) - import { useOptimizedDragAndDrop, type OptimizedDragEventHandler } from '../useOptimizedDragAndDrop' function installSortableMock() { diff --git a/frontend/src/composables/__tests__/useOptimizedKanban.test.ts b/frontend/src/composables/__tests__/useOptimizedKanban.test.ts index 15e9d3a2d..cbfceef28 100644 --- a/frontend/src/composables/__tests__/useOptimizedKanban.test.ts +++ b/frontend/src/composables/__tests__/useOptimizedKanban.test.ts @@ -165,16 +165,11 @@ vi.mock('@/api/generated/api', () => ({ schemas: {}, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('@/composables/useSaveFeedback', () => ({ useSaveFeedback: () => saveFeedback, })) import { useOptimizedKanban } from '../useOptimizedKanban' -import { debugLog } from '@/utils/debug' type HarnessState = ReturnType<typeof useOptimizedKanban> @@ -384,22 +379,6 @@ describe('useOptimizedKanban search reconciliation', () => { 'job-96477', 'job-96990', ]) - expect(debugLog).toHaveBeenCalledWith( - 'kanban.search.reconciled-order', - expect.objectContaining({ - query: 'Mayer', - rawOrder: expect.arrayContaining([ - expect.objectContaining({ jobNumber: 96990, priority: 5600 }), - expect.objectContaining({ jobNumber: 96477, priority: 5800 }), - ]), - renderedColumnOrder: expect.objectContaining({ - approved: [ - expect.objectContaining({ jobNumber: 96477, priority: 5800 }), - expect.objectContaining({ jobNumber: 96990, priority: 5600 }), - ], - }), - }), - ) }) it('logs a search-result click before navigating to the job', async () => { @@ -648,11 +627,6 @@ describe('useOptimizedKanban search reconciliation', () => { await kanban.reorderJob('job-1', undefined, undefined, 'draft', 'drag-456') expect(getJobsByColumn).toHaveBeenCalledTimes(3) - expect(debugLog).toHaveBeenCalledWith('kanban.drag.persist.success', { - dragId: 'drag-456', - jobId: 'job-1', - revalidated: false, - }) }) it('rolls back local drag state and revalidates affected columns when reorder persistence fails', async () => { @@ -666,14 +640,6 @@ describe('useOptimizedKanban search reconciliation', () => { expect(kanban.getJobsByStatus.value('draft').map((job) => job.id)).toEqual([]) expect(saveFeedback.error).toHaveBeenCalledWith('Job move failed. Change reverted.') expect(getJobsByColumn).toHaveBeenCalledTimes(5) - expect(debugLog).toHaveBeenCalledWith( - 'kanban.drag.rollback.revalidate', - expect.objectContaining({ - dragId: 'drag-789', - jobId: 'job-1', - columnIds: ['in_progress', 'draft'], - }), - ) }) it('keeps a search-only job visible after a drag status update', async () => { diff --git a/frontend/src/composables/__tests__/usePersonManagement.test.ts b/frontend/src/composables/__tests__/usePersonManagement.test.ts index 6a0a097cc..700511975 100644 --- a/frontend/src/composables/__tests__/usePersonManagement.test.ts +++ b/frontend/src/composables/__tests__/usePersonManagement.test.ts @@ -10,10 +10,6 @@ vi.mock('@/api/client', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), diff --git a/frontend/src/composables/useAppLayout.ts b/frontend/src/composables/useAppLayout.ts index 44e36acff..d7402fa8a 100644 --- a/frontend/src/composables/useAppLayout.ts +++ b/frontend/src/composables/useAppLayout.ts @@ -1,11 +1,13 @@ import { computed } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' -import { debugLog } from '../utils/debug' +import debug from 'debug' import type { NavigationItem } from '@/constants/navigation-item' import { z } from 'zod' import { schemas } from '../api/generated/api' +const log = debug('app:layout') + type Staff = z.infer<typeof schemas.Staff> export function useAppLayout() { @@ -61,7 +63,7 @@ export function useAppLayout() { await authStore.logout() await router.push('/login') } catch (error) { - debugLog('Logout failed:', error) + log('Logout failed:', error) await router.push('/login') } diff --git a/frontend/src/composables/useCamera.ts b/frontend/src/composables/useCamera.ts index b64420570..415b037dc 100644 --- a/frontend/src/composables/useCamera.ts +++ b/frontend/src/composables/useCamera.ts @@ -1,5 +1,7 @@ import { ref } from 'vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:camera') interface CameraOptions { facingMode?: 'user' | 'environment' @@ -39,7 +41,7 @@ export function useCamera(options: CameraOptions = {}) { } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error accessing camera' error.value = errorMessage - debugLog('Error starting camera:', err) + log('Error starting camera:', err) throw new Error(`Could not access camera: ${errorMessage}`) } } @@ -157,7 +159,7 @@ export function useCamera(options: CameraOptions = {}) { lastModified: Date.now(), }) - debugLog(`Image compressed: ${file.name} + log(`Image compressed: ${file.name} Original: ${(file.size / 1024 / 1024).toFixed(2)}MB Compressed: ${(compressedFile.size / 1024 / 1024).toFixed(2)}MB`) diff --git a/frontend/src/composables/useCompanyLookup.ts b/frontend/src/composables/useCompanyLookup.ts index 7f9b38f8c..5d39067b2 100644 --- a/frontend/src/composables/useCompanyLookup.ts +++ b/frontend/src/composables/useCompanyLookup.ts @@ -3,9 +3,11 @@ import { z } from 'zod' import { toast } from 'vue-sonner' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { logSearchResultClick } from '@/services/searchTelemetry.service' +const log = debug('company:lookup') + // Use generated schemas export type Company = z.infer<typeof schemas.CompanySearchResult> export type CompanyPerson = z.infer<typeof schemas.CompanyPerson> @@ -35,7 +37,7 @@ export async function logCompanySearchClick( source, }) } catch (error) { - debugLog('Failed to log company search click:', error) + log('Failed to log company search click:', error) } } @@ -48,7 +50,7 @@ export function useCompanyLookup(options: UseCompanyLookupOptions = {}) { const people = ref<CompanyPerson[]>([]) const hasValidXeroId = computed(() => { - debugLog('Selected company value: ', selectedCompany.value) + log('Selected company value: ', selectedCompany.value) return ( selectedCompany.value?.xero_contact_id != null && selectedCompany.value.xero_contact_id !== '' ) @@ -188,7 +190,7 @@ export function useCompanyLookup(options: UseCompanyLookupOptions = {}) { } const preserveSelectedCompany = (modelValue?: string) => { - debugLog('Preserving selected company from modelValue:', modelValue) + log('Preserving selected company from modelValue:', modelValue) // Preserve the selected company when dialog reopens if (selectedCompany.value && !searchQuery.value) { searchQuery.value = selectedCompany.value.name diff --git a/frontend/src/composables/useCreateCostLineFromEmpty.ts b/frontend/src/composables/useCreateCostLineFromEmpty.ts index 207947a8a..d1698eb1f 100644 --- a/frontend/src/composables/useCreateCostLineFromEmpty.ts +++ b/frontend/src/composables/useCreateCostLineFromEmpty.ts @@ -2,12 +2,14 @@ import { toast } from 'vue-sonner' import { costlineService } from '../services/costline.service' import { schemas } from '../api/generated/api' import type { z } from 'zod' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { toLocalDateString } from '../utils/dateUtils' import { useJobsStore } from '../stores/jobs' import { useSaveFeedback } from '@/composables/useSaveFeedback' import { requiredNumber } from '@/utils/requiredNumber' +const log = debug('cost:create-line') + type CostLine = z.infer<typeof schemas.CostLine> type CostLineCreateUpdate = z.infer<typeof schemas.CostLineCreateUpdateRequest> type CostSetKind = 'estimate' | 'quote' | 'actual' @@ -39,7 +41,7 @@ export function useCreateCostLineFromEmpty(options: UseCreateCostLineFromEmptyOp return } - debugLog(`Creating cost line from empty line (${costSetKind}):`, line) + log(`Creating cost line from empty line (${costSetKind}):`, line) try { saveFeedback.saving() @@ -63,7 +65,7 @@ export function useCreateCostLineFromEmpty(options: UseCreateCostLineFromEmptyOp const created = await costlineService.createCostLine(jobId, costSetKind, createPayload) saveFeedback.saved() - debugLog('Successfully created cost line:', created) + log('Successfully created cost line:', created) // Call success callback if provided if (onSuccess) { diff --git a/frontend/src/composables/useDeviceDetection.ts b/frontend/src/composables/useDeviceDetection.ts index 1eea06267..0c5868f2a 100644 --- a/frontend/src/composables/useDeviceDetection.ts +++ b/frontend/src/composables/useDeviceDetection.ts @@ -1,5 +1,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('app:device') export function useDeviceDetection() { const windowWidth = ref(window.innerWidth) @@ -28,7 +30,7 @@ export function useDeviceDetection() { windowHeight.value <= 1366 && !(windowWidth.value <= 430 && windowHeight.value <= 932) - debugLog('Device detection:', { + log('Device detection:', { windowWidth: windowWidth.value, windowHeight: windowHeight.value, isTabletDimensions, diff --git a/frontend/src/composables/useDragAndDrop.ts b/frontend/src/composables/useDragAndDrop.ts index d321f0df6..cf0c79bcd 100644 --- a/frontend/src/composables/useDragAndDrop.ts +++ b/frontend/src/composables/useDragAndDrop.ts @@ -1,6 +1,8 @@ import { ref, type Ref } from 'vue' import Sortable from 'sortablejs' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('kanban:drag') export interface DragEventPayload { jobId: string @@ -58,7 +60,7 @@ export function useDragAndDrop(onDragEvent?: DragEventHandler) { } if (!element || !element.isConnected) return - debugLog(`Creating Sortable for ${status}:`, { + log(`Creating Sortable for ${status}:`, { element, dataStatus: element.dataset.status, children: element.children.length, @@ -81,25 +83,25 @@ export function useDragAndDrop(onDragEvent?: DragEventHandler) { fallbackOnBody: true, swapThreshold: 0.65, onStart: () => { - debugLog(`Drag started from: ${status}`) + log(`Drag started from: ${status}`) isDragging.value = true document.body.classList.add('is-dragging') }, onMove: (evt) => { const toColumn = (evt.to.closest('[data-status]') as HTMLElement)?.dataset.status - debugLog(`Drag moving to: ${toColumn}`) + log(`Drag moving to: ${toColumn}`) return true }, onAdd: (evt) => { const toStatus = evt.to.dataset.status - debugLog(`Item added to column: ${toStatus}`) + log(`Item added to column: ${toStatus}`) }, onChange: (evt) => { const status = evt.to.dataset.status || evt.from.dataset.status - debugLog(`Change detected in column: ${status}`) + log(`Change detected in column: ${status}`) }, onEnd: (evt) => { - debugLog(`Drag ended:`, { + log(`Drag ended:`, { from: evt.from.dataset.status, to: evt.to.dataset.status, item: evt.item.dataset.jobId, diff --git a/frontend/src/composables/useJobAutoSync.ts b/frontend/src/composables/useJobAutoSync.ts index ce0524c83..99b94391f 100644 --- a/frontend/src/composables/useJobAutoSync.ts +++ b/frontend/src/composables/useJobAutoSync.ts @@ -1,5 +1,7 @@ import { ref, onMounted, onUnmounted } from 'vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:sync') export function useJobAutoSync( jobId: string, @@ -28,16 +30,16 @@ export function useJobAutoSync( isSyncing.value = true syncError.value = null - debugLog(`Auto-sync: Reloading job ${jobId} data...`) + log(`Auto-sync: Reloading job ${jobId} data...`) await reloadFunction() lastSyncTime.value = new Date() - debugLog(`Auto-sync: Job ${jobId} data reloaded successfully`) + log(`Auto-sync: Job ${jobId} data reloaded successfully`) } catch (error) { const syncErr = error instanceof Error ? error : new Error('Unknown sync error') syncError.value = syncErr - debugLog(`Auto-sync error for job ${jobId}:`, syncErr) + log(`Auto-sync error for job ${jobId}:`, syncErr) if (onError) { onError(syncErr) @@ -53,14 +55,14 @@ export function useJobAutoSync( } if (isAutoSyncEnabled.value && interval > 0) { - debugLog(`Auto-sync: Starting for job ${jobId} (interval: ${interval}ms)`) + log(`Auto-sync: Starting for job ${jobId} (interval: ${interval}ms)`) intervalId = setInterval(performSync, interval) } } const stopAutoSync = () => { if (intervalId) { - debugLog(`Auto-sync: Stopping for job ${jobId}`) + log(`Auto-sync: Stopping for job ${jobId}`) clearInterval(intervalId) intervalId = null } diff --git a/frontend/src/composables/useJobAutosave.ts b/frontend/src/composables/useJobAutosave.ts index fef225d98..d063657a6 100644 --- a/frontend/src/composables/useJobAutosave.ts +++ b/frontend/src/composables/useJobAutosave.ts @@ -1,8 +1,10 @@ import { ref, type Ref } from 'vue' import type { Router } from 'vue-router' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { useSaveFeedback } from '@/composables/useSaveFeedback' +const log = debug('job:autosave') + export type SaveResult = { success: boolean serverData?: unknown @@ -35,7 +37,6 @@ export type JobAutosaveOptions = { debounceMs?: number retryPolicy?: RetryPolicy - devLogging?: boolean statusSource?: string } @@ -86,7 +87,6 @@ export function createJobAutosave(opts: JobAutosaveOptions): JobAutosaveApi { factor: 2, jitter: true, } - const dev = !!opts.devLogging const normalize: NormalizeFn = opts.normalize ?? @@ -143,11 +143,6 @@ export function createJobAutosave(opts: JobAutosaveOptions): JobAutosaveApi { let debounceTimer: number | null = null let pendingAfterFlight = false - function log(...args: unknown[]) { - if (!dev) return - debugLog('[JobAutosave]', ...args) - } - function getPendingPatch(): Record<string, unknown> { const rawPatch: Record<string, unknown> = {} for (const [k, v] of changeBuffer.entries()) rawPatch[k] = v diff --git a/frontend/src/composables/useJobCache.ts b/frontend/src/composables/useJobCache.ts index 50ff8c568..2ef7735e4 100644 --- a/frontend/src/composables/useJobCache.ts +++ b/frontend/src/composables/useJobCache.ts @@ -1,9 +1,11 @@ import { ref, computed } from 'vue' import { z } from 'zod' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import type { JobCacheEntry } from '@/constants/job-cache' +const log = debug('job:cache') + type JobDetailResponse = z.infer<typeof schemas.JobDetailResponse> export function useJobCache() { @@ -25,17 +27,17 @@ export function useJobCache() { const entry = cache.value.get(jobId) if (!entry) { - debugLog(`Cache miss for job ${jobId}`) + log(`Cache miss for job ${jobId}`) return null } if (!isCacheValid(entry, ttl)) { - debugLog(`Cache expired for job ${jobId}`) + log(`Cache expired for job ${jobId}`) cache.value.delete(jobId) return null } - debugLog(`Cache hit for job ${jobId}`) + log(`Cache hit for job ${jobId}`) return entry.data } @@ -47,23 +49,23 @@ export function useJobCache() { } cache.value.set(jobId, entry) - debugLog(`Job ${jobId} cached`) + log(`Job ${jobId} cached`) } const removeCachedJob = (jobId: string): void => { if (cache.value.delete(jobId)) { - debugLog(`Job ${jobId} removed from cache`) + log(`Job ${jobId} removed from cache`) } } const invalidateAll = (): void => { currentVersion.value++ - debugLog(`Cache invalidated - new version: ${currentVersion.value}`) + log(`Cache invalidated - new version: ${currentVersion.value}`) } const clearCache = (): void => { cache.value.clear() - debugLog('Cache cleared') + log('Cache cleared') } const updateCachedJob = (jobId: string, updates: Partial<JobDetailResponse>): void => { @@ -72,7 +74,7 @@ export function useJobCache() { if (entry && isCacheValid(entry)) { const updatedData = { ...entry.data, ...updates } setCachedJob(jobId, updatedData) - debugLog(`Job ${jobId} updated in cache`) + log(`Job ${jobId} updated in cache`) } } @@ -112,7 +114,7 @@ export function useJobCache() { return cached as T } - debugLog(`Loading job ${jobId} from API...`) + log(`Loading job ${jobId} from API...`) const freshData = await loadFunction() setCachedJob(jobId, freshData) diff --git a/frontend/src/composables/useJobETags.ts b/frontend/src/composables/useJobETags.ts index 87f9327c1..a110ac342 100644 --- a/frontend/src/composables/useJobETags.ts +++ b/frontend/src/composables/useJobETags.ts @@ -6,7 +6,9 @@ */ import { ref } from 'vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:etags') const etagByJob = ref(new Map<string, string>()) @@ -33,7 +35,7 @@ export function useJobETags() { const setETag = (jobId: string, etag: string): void => { if (etag && typeof etag === 'string') { etagByJob.value.set(jobId, etag) - debugLog('ETag stored:', { jobId, etag }) + log('ETag stored:', { jobId, etag }) } } @@ -45,7 +47,7 @@ export function useJobETags() { const hadETag = etagByJob.value.has(jobId) etagByJob.value.delete(jobId) if (hadETag) { - debugLog('ETag cleared:', { jobId }) + log('ETag cleared:', { jobId }) } } @@ -57,7 +59,7 @@ export function useJobETags() { const count = etagByJob.value.size etagByJob.value.clear() if (count > 0) { - debugLog('All ETags cleared:', { count }) + log('All ETags cleared:', { count }) } } @@ -99,5 +101,3 @@ export function useJobETags() { clearAllETags, } } - -// Debug logging is handled by the imported debugLog function diff --git a/frontend/src/composables/useJobHeaderAutosave.ts b/frontend/src/composables/useJobHeaderAutosave.ts index d3c14404f..bf3e0a25d 100644 --- a/frontend/src/composables/useJobHeaderAutosave.ts +++ b/frontend/src/composables/useJobHeaderAutosave.ts @@ -262,7 +262,6 @@ export function useJobHeaderAutosave(headerRef: Ref<JobHeaderResponse | null>) { return { success: false, error: msg, conflict } } }, - devLogging: true, statusSource: `job-header:${header.job_id}`, }) diff --git a/frontend/src/composables/useJobNotifications.ts b/frontend/src/composables/useJobNotifications.ts index 11608102f..47506af1d 100644 --- a/frontend/src/composables/useJobNotifications.ts +++ b/frontend/src/composables/useJobNotifications.ts @@ -1,5 +1,7 @@ import { toast } from 'vue-sonner' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:notifications') export function useJobNotifications() { const notifyJobUpdated = (jobName: string) => { @@ -9,7 +11,7 @@ export function useJobNotifications() { } const notifyJobLoaded = (jobName: string) => { - debugLog(`Job ${jobName} loaded successfully`) + log(`Job ${jobName} loaded successfully`) } const notifyJobError = (jobId: string, error: string) => { @@ -50,7 +52,7 @@ export function useJobNotifications() { const notifyDataChanged = (dataType: string) => { // Reduced verbosity - only debug log for auto-save - debugLog(`${dataType} changed - auto-saving`) + log(`${dataType} changed - auto-saving`) } const notifyEventAdded = (eventType: string) => { @@ -61,7 +63,7 @@ export function useJobNotifications() { const notifyPricingUpdated = () => { // Reduced verbosity - only debug log for pricing updates - debugLog('Pricing data updated automatically') + log('Pricing data updated automatically') } const notifyQuoteLinkStart = () => { diff --git a/frontend/src/composables/useOptimizedDragAndDrop.ts b/frontend/src/composables/useOptimizedDragAndDrop.ts index 83c77e29e..d2a3e26fd 100644 --- a/frontend/src/composables/useOptimizedDragAndDrop.ts +++ b/frontend/src/composables/useOptimizedDragAndDrop.ts @@ -1,7 +1,9 @@ import { ref, type Ref } from 'vue' import Sortable from 'sortablejs' import type { SortableEvent } from 'sortablejs' -import { debugLog } from '../utils/debug' +import debug from 'debug' + +const log = debug('kanban:drag-opt') export interface OptimizedDragEventPayload { jobId: string @@ -94,7 +96,7 @@ export function useOptimizedDragAndDrop(onDragEvent?: OptimizedDragEventHandler) const ghostEls = document.querySelectorAll('.sortable-ghost') const chosenEls = document.querySelectorAll('.sortable-chosen') const dragEls = document.querySelectorAll('.sortable-drag') - debugLog('kanban.drag.stuck-warning', { + log('kanban.drag.stuck-warning', { dragId, isDraggingRef: isDragging.value, sortableGhosts: ghostEls.length, @@ -113,7 +115,7 @@ export function useOptimizedDragAndDrop(onDragEvent?: OptimizedDragEventHandler) } if (!element || !element.isConnected) return - debugLog(`Creating Sortable for ${status}:`, { + log(`Creating Sortable for ${status}:`, { children: element.children.length, }) @@ -139,7 +141,7 @@ export function useOptimizedDragAndDrop(onDragEvent?: OptimizedDragEventHandler) const jobElement = evt.item as HTMLElement | undefined const sourceStatus = (evt.from?.closest('[data-status]') as HTMLElement | null)?.dataset .status - debugLog('kanban.drag.start', { + log('kanban.drag.start', { dragId: activeDragId, jobId: jobElement?.dataset.jobId, sourceStatus, @@ -176,7 +178,7 @@ export function useOptimizedDragAndDrop(onDragEvent?: OptimizedDragEventHandler) placement = anchor.placement targetColumnJobs = anchor.targetColumnJobs - debugLog('kanban.drag.anchor', { + log('kanban.drag.anchor', { dragId, jobId, fromStatus, @@ -189,7 +191,7 @@ export function useOptimizedDragAndDrop(onDragEvent?: OptimizedDragEventHandler) draggedJobInArray: targetColumnJobs[newIndex], }) } else { - debugLog('kanban.drag.skip', { + log('kanban.drag.skip', { dragId, reason: 'missing jobId/from/to status', jobId, @@ -221,7 +223,7 @@ export function useOptimizedDragAndDrop(onDragEvent?: OptimizedDragEventHandler) } if (jobId) { - debugLog('kanban.drag.dom.after-handler', { + log('kanban.drag.dom.after-handler', { dragId, jobId, draggedElementConnected: jobElement.isConnected, diff --git a/frontend/src/composables/useOptimizedKanban.ts b/frontend/src/composables/useOptimizedKanban.ts index 2c3f82611..6bb0e0f9b 100644 --- a/frontend/src/composables/useOptimizedKanban.ts +++ b/frontend/src/composables/useOptimizedKanban.ts @@ -9,11 +9,13 @@ import { schemas } from '../api/generated/api' import type { AdvancedFilters } from '../constants/advanced-filters' import { DEFAULT_ADVANCED_FILTERS } from '../constants/advanced-filters' import type { StatusChoice } from '../constants/job-status' -import { debugLog } from '../utils/debug' +import debug from 'debug' import type { z } from 'zod' import { useSaveFeedback } from '@/composables/useSaveFeedback' import { logSearchResultClick } from '@/services/searchTelemetry.service' +const log = debug('kanban:core') + // Type aliases for better readability type KanbanJob = z.infer<typeof schemas.KanbanJob> type KanbanJobPerson = z.infer<typeof schemas.KanbanJobPerson> @@ -173,7 +175,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { const checkFreshnessInBackground = (): void => { dataFreshness.checkFreshness().catch((err) => { - debugLog('Kanban freshness check failed:', err) + log('Kanban freshness check failed:', err) }) } @@ -266,7 +268,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { columnState.loading = true columnState.error = null - debugLog(`Loading jobs for column: ${columnId}`) + log(`Loading jobs for column: ${columnId}`) const data: KanbanColumnCacheEntry = await jobsStore.loadKanbanColumnWithCache( columnId, @@ -275,10 +277,10 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { ) applyColumnData(columnId, data) - debugLog(`Loaded ${data.jobIds.length} jobs for column: ${columnId}`) + log(`Loaded ${data.jobIds.length} jobs for column: ${columnId}`) } catch (err) { columnState.error = err instanceof Error ? err.message : `Failed to load jobs for ${columnId}` - debugLog(`Error loading jobs for column ${columnId}:`, err) + log(`Error loading jobs for column ${columnId}:`, err) } finally { columnState.loading = false } @@ -303,7 +305,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { notifyJobsLoaded() } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load kanban columns' - debugLog('Error loading kanban columns:', err) + log('Error loading kanban columns:', err) } finally { isLoading.value = false jobsStore.setLoadingKanban(false) @@ -312,7 +314,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { // Revalidate specific columns (for optimistic updates) const revalidateColumns = async (columnIds: string[]): Promise<void> => { - debugLog(`Revalidating columns: ${columnIds.join(', ')}`) + log(`Revalidating columns: ${columnIds.join(', ')}`) await Promise.all(columnIds.map((columnId) => loadColumnJobs(columnId, { force: true }))) } @@ -655,7 +657,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { targetColumnId?: string } = {}, ): Promise<boolean> => { - debugLog(`Starting status update: Job ${jobId} -> ${newStatus}`) + log(`Starting status update: Job ${jobId} -> ${newStatus}`) error.value = null // Find the job in current columns @@ -664,7 +666,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { const job = localJob?.job ?? null if (!job || !sourceColumnId) { - debugLog(`Job ${jobId} not found for status update`) + log(`Job ${jobId} not found for status update`) error.value = 'Job not found for status update' return false } @@ -672,7 +674,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { // Determine target column const targetColumnId = options.targetColumnId ?? KanbanCategorizationService.getColumnForStatus(newStatus) - debugLog(`Moving from column ${sourceColumnId} to ${targetColumnId}`) + log(`Moving from column ${sourceColumnId} to ${targetColumnId}`) applyLocalJobStatusMove( jobId, @@ -686,20 +688,20 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { try { // Make API call first - debugLog(`Calling API to update job status`) + log(`Calling API to update job status`) await jobService.updateJobStatus(jobId, newStatus) - debugLog(`Job ${jobId} status updated successfully`) + log(`Job ${jobId} status updated successfully`) // Revalidate affected columns to get fresh data from backend const columnsToRevalidate = [sourceColumnId, targetColumnId].filter( (id, index, arr) => arr.indexOf(id) === index, // Remove duplicates ) - debugLog(`Revalidating columns: ${columnsToRevalidate.join(', ')}`) + log(`Revalidating columns: ${columnsToRevalidate.join(', ')}`) await revalidateColumns(columnsToRevalidate) - debugLog(`Status update and revalidation completed`) + log(`Status update and revalidation completed`) return true } catch (err) { - debugLog(`Failed to update job ${jobId} status:`, err) + log(`Failed to update job ${jobId} status:`, err) error.value = err instanceof Error ? err.message : 'Failed to update job status' // On error, revalidate both columns to ensure consistency @@ -708,9 +710,9 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { (id, index, arr) => arr.indexOf(id) === index, ) await revalidateColumns(columnsToRevalidate) - debugLog(`Emergency revalidation completed after error`) + log(`Emergency revalidation completed after error`) } catch (revalidateErr) { - debugLog(`Emergency revalidation also failed:`, revalidateErr) + log(`Emergency revalidation also failed:`, revalidateErr) } return false } @@ -724,7 +726,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { status?: string, dragId?: string, ): Promise<void> => { - debugLog('kanban.drag.local.request', { + log('kanban.drag.local.request', { dragId, jobId, anchorJobId, @@ -745,7 +747,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { const snapshot = captureMoveSnapshot(columnIds, localJob?.job ?? null) if (targetStatus && targetColumnId && sourceColumnId && localJob) { - debugLog('kanban.drag.local.before', { + log('kanban.drag.local.before', { dragId, jobId, sourceColumnId, @@ -767,7 +769,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { placement, ) - debugLog('kanban.drag.local.after', { + log('kanban.drag.local.after', { dragId, jobId, sourceColumnId, @@ -778,7 +780,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { movedJobStatus: jobsStore.getKanbanJobById(jobId)?.status ?? targetStatus, }) } else { - debugLog('kanban.drag.local.skip', { + log('kanban.drag.local.skip', { dragId, jobId, reason: 'missing local job, source column, target column, or target status', @@ -790,7 +792,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { } try { - debugLog('kanban.drag.persist.request', { + log('kanban.drag.persist.request', { dragId, jobId, payload: { @@ -803,13 +805,13 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { kanbanMoveFeedback.saving() await jobService.reorderJob(jobId, anchorJobId, placement, status) kanbanMoveFeedback.saved() - debugLog('kanban.drag.persist.success', { + log('kanban.drag.persist.success', { dragId, jobId, revalidated: false, }) } catch (err) { - debugLog('kanban.drag.persist.error', { + log('kanban.drag.persist.error', { dragId, jobId, error: err instanceof Error ? err.message : String(err), @@ -822,7 +824,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { error.value = err instanceof Error ? err.message : 'Failed to reorder job' kanbanMoveFeedback.error('Job move failed. Change reverted.') restoreMoveSnapshot(snapshot) - debugLog('kanban.drag.rollback.after', { + log('kanban.drag.rollback.after', { dragId, jobId, sourceColumnId, @@ -833,7 +835,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { }) if (columnIds.length > 0) { - debugLog('kanban.drag.rollback.revalidate', { + log('kanban.drag.rollback.revalidate', { dragId, jobId, columnIds, @@ -898,7 +900,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { selectedMobileStatus.value = firstStatus?.key || statusChoices.value[0].key } } catch (err) { - debugLog('Error loading status choices:', err) + log('Error loading status choices:', err) const columns = KanbanCategorizationService.getAllColumns() statusChoices.value = columns.map((col) => ({ @@ -939,8 +941,8 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { } const rawJobs = response.jobs filteredJobs.value = sortJobsForKanbanDisplay(rawJobs) - debugLog(`Search reconciled from backend: ${filteredJobs.value.length} jobs for "${query}"`) - debugLog('kanban.search.reconciled-order', { + log(`Search reconciled from backend: ${filteredJobs.value.length} jobs for "${query}"`) + log('kanban.search.reconciled-order', { query, rawOrder: summarizeJobsForDebug(rawJobs), renderedColumnOrder: summarizeFilteredColumnOrder(filteredJobs.value), @@ -949,7 +951,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { if (requestId !== latestSearchRequestId || searchQuery.value.trim() !== query) { return } - debugLog('Error performing search:', err) + log('Error performing search:', err) filteredJobs.value = searchJobsLocally(getAllLoadedJobs(), query) } finally { if (requestId === latestSearchRequestId) { @@ -980,7 +982,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { // Every non-empty query shows immediate local substring matches first, then // reconciles against backend token-order search after the debounce settles. - debugLog( + log( `Search started locally: ${filteredJobs.value.length} jobs found for "${searchQuery.value}"`, ) } @@ -1009,7 +1011,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { filteredJobs.value = response.jobs } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to perform advanced search' - debugLog('Error performing advanced search:', err) + log('Error performing advanced search:', err) filteredJobs.value = [] throw err } finally { @@ -1032,7 +1034,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { } const loadMoreJobs = (columnId: string): void => { - debugLog('Load more jobs for column:', columnId) + log('Load more jobs for column:', columnId) // TODO: Implement pagination } @@ -1091,7 +1093,7 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { initialLoadPromise = (async () => { try { - debugLog('Initializing Kanban...') + log('Initializing Kanban...') initializeColumnStates() jobsStore.setCurrentContext('kanban') @@ -1112,9 +1114,9 @@ export function useOptimizedKanban(onJobsLoaded?: () => void) { await handleSearch() } - debugLog('Kanban initialization complete') + log('Kanban initialization complete') } catch (err) { - debugLog('Error during Kanban initialization:', err) + log('Error during Kanban initialization:', err) error.value = err instanceof Error ? err.message : 'Failed to initialize kanban' } })() diff --git a/frontend/src/composables/usePersonManagement.ts b/frontend/src/composables/usePersonManagement.ts index 4cb4316b6..789ad9de6 100644 --- a/frontend/src/composables/usePersonManagement.ts +++ b/frontend/src/composables/usePersonManagement.ts @@ -3,9 +3,11 @@ import { isAxiosError } from 'axios' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' import { z } from 'zod' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toast } from 'vue-sonner' +const log = debug('person:manage') + // Schema-derived types (no custom interfaces) type CompanyPerson = z.infer<typeof schemas.CompanyPerson> type PersonCreateRequest = z.input<typeof schemas.CompanyPersonCreateRequest> @@ -105,7 +107,7 @@ export function usePersonManagement() { */ const openModal = async (companyId: string, companyName: string) => { if (!companyId) { - debugLog('Cannot open person modal without company ID') + log('Cannot open person modal without company ID') return } @@ -147,7 +149,7 @@ export function usePersonManagement() { people.value = response || [] personForm.value.is_primary = people.value.length === 0 } catch (error) { - debugLog('Error loading people:', error) + log('Error loading people:', error) toast.error('Failed to load people for this company') people.value = [] personForm.value.is_primary = true @@ -196,12 +198,12 @@ export function usePersonManagement() { */ const createNewPerson = async (createSeparate = false): Promise<boolean> => { if (!currentCompanyId.value) { - debugLog('Cannot create person without company ID') + log('Cannot create person without company ID') return false } if (!personForm.value.name.trim()) { - debugLog('Person name is required') + log('Person name is required') return false } @@ -241,7 +243,7 @@ export function usePersonManagement() { ...(trimmedPhone ? { phone: trimmedPhone } : {}), } - debugLog('Creating new person:', personData) + log('Creating new person:', personData) const response = await api.companies_people_create(personData, { params: { company_id: currentCompanyId.value }, @@ -253,7 +255,7 @@ export function usePersonManagement() { const newPerson = response - debugLog('Person created successfully:', newPerson) + log('Person created successfully:', newPerson) // Reload people first to get the updated list await loadPeople(currentCompanyId.value) @@ -262,10 +264,10 @@ export function usePersonManagement() { const createdPerson = people.value.find((person) => person.person_id === newPerson.person_id) if (createdPerson) { selectedPerson.value = createdPerson - debugLog('New person selected:', createdPerson) + log('New person selected:', createdPerson) } else { selectedPerson.value = newPerson - debugLog('Using response person:', newPerson) + log('Using response person:', newPerson) } closeModal() @@ -278,7 +280,7 @@ export function usePersonManagement() { return false } } - debugLog('Error creating person:', error) + log('Error creating person:', error) return false } finally { isLoading.value = false @@ -315,7 +317,7 @@ export function usePersonManagement() { closeModal() return true } catch (error) { - debugLog('Error linking existing person:', error) + log('Error linking existing person:', error) toast.error('Failed to link the existing person') return false } finally { @@ -354,15 +356,15 @@ export function usePersonManagement() { const updatePerson = async (): Promise<boolean> => { const original = editingPerson.value if (!original) { - debugLog('Cannot update person without an editing target') + log('Cannot update person without an editing target') return false } if (!personForm.value.name.trim()) { - debugLog('Person name is required') + log('Person name is required') return false } if (!currentCompanyId.value) { - debugLog('Cannot update person without company ID') + log('Cannot update person without company ID') return false } @@ -434,7 +436,7 @@ export function usePersonManagement() { closeModal() return true } catch (error) { - debugLog('Error updating person:', error) + log('Error updating person:', error) return false } finally { isLoading.value = false @@ -452,7 +454,7 @@ export function usePersonManagement() { */ const deletePerson = async (person: CompanyPerson): Promise<boolean> => { if (!currentCompanyId.value) { - debugLog('Cannot remove person without company ID') + log('Cannot remove person without company ID') return false } @@ -486,7 +488,7 @@ export function usePersonManagement() { deleteErrorDetail.value = null } } - debugLog('Error removing person:', error) + log('Error removing person:', error) return false } finally { isLoading.value = false @@ -527,7 +529,7 @@ export function usePersonManagement() { return true default: - debugLog('Please select an existing person or create a new one') + log('Please select an existing person or create a new one') return false } } diff --git a/frontend/src/composables/usePickupAddressManagement.ts b/frontend/src/composables/usePickupAddressManagement.ts index 92f77dd23..ce10d2eed 100644 --- a/frontend/src/composables/usePickupAddressManagement.ts +++ b/frontend/src/composables/usePickupAddressManagement.ts @@ -2,7 +2,9 @@ import { ref, computed } from 'vue' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' import { z } from 'zod' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('po:pickup-address') // Schema-derived types (no custom interfaces) type SupplierPickupAddress = z.infer<typeof schemas.SupplierPickupAddress> @@ -95,7 +97,7 @@ export function usePickupAddressManagement() { */ const openModal = async (supplierId: string, supplierName: string) => { if (!supplierId) { - debugLog('Cannot open address modal without supplier ID') + log('Cannot open address modal without supplier ID') return } @@ -137,7 +139,7 @@ export function usePickupAddressManagement() { addresses.value = response || [] newAddressForm.value.is_primary = addresses.value.length === 0 } catch (error) { - debugLog('Error loading addresses:', error) + log('Error loading addresses:', error) addresses.value = [] newAddressForm.value.is_primary = true } finally { @@ -192,7 +194,7 @@ export function usePickupAddressManagement() { }) return response.candidates || [] } catch (error) { - debugLog('Error validating address:', error) + log('Error validating address:', error) return [] } } @@ -227,22 +229,22 @@ export function usePickupAddressManagement() { */ const createNewAddress = async (): Promise<boolean> => { if (!currentSupplierId.value) { - debugLog('Cannot create address without supplier ID') + log('Cannot create address without supplier ID') return false } if (!newAddressForm.value.name.trim()) { - debugLog('Address name is required') + log('Address name is required') return false } if (!newAddressForm.value.street.trim()) { - debugLog('Street address is required') + log('Street address is required') return false } if (!newAddressForm.value.city.trim()) { - debugLog('City is required') + log('City is required') return false } @@ -268,7 +270,7 @@ export function usePickupAddressManagement() { notes: newAddressForm.value.notes?.trim() || undefined, } - debugLog('Creating new address:', addressData) + log('Creating new address:', addressData) const response = await api.companies_pickup_addresses_create(addressData) @@ -278,7 +280,7 @@ export function usePickupAddressManagement() { const newAddress = response as SupplierPickupAddress - debugLog('Address created successfully:', newAddress) + log('Address created successfully:', newAddress) // Reload addresses first to get the updated list await loadAddresses(currentSupplierId.value) @@ -287,16 +289,16 @@ export function usePickupAddressManagement() { const createdAddress = addresses.value.find((address) => address.id === newAddress.id) if (createdAddress) { selectedAddress.value = createdAddress - debugLog('New address selected:', createdAddress) + log('New address selected:', createdAddress) } else { selectedAddress.value = newAddress - debugLog('Using response address:', newAddress) + log('Using response address:', newAddress) } closeModal() return true } catch (error) { - debugLog('Error creating address:', error) + log('Error creating address:', error) return false } finally { isLoading.value = false @@ -349,22 +351,22 @@ export function usePickupAddressManagement() { */ const updateAddress = async (): Promise<boolean> => { if (!editingAddress.value?.id) { - debugLog('Cannot update address without ID') + log('Cannot update address without ID') return false } if (!newAddressForm.value.name.trim()) { - debugLog('Address name is required') + log('Address name is required') return false } if (!newAddressForm.value.street.trim()) { - debugLog('Street address is required') + log('Street address is required') return false } if (!newAddressForm.value.city.trim()) { - debugLog('City is required') + log('City is required') return false } @@ -387,13 +389,13 @@ export function usePickupAddressManagement() { notes: newAddressForm.value.notes?.trim() || null, } - debugLog('Updating address:', editingAddress.value.id, addressData) + log('Updating address:', editingAddress.value.id, addressData) await api.companies_pickup_addresses_partial_update(addressData, { params: { id: editingAddress.value.id }, }) - debugLog('Address updated successfully') + log('Address updated successfully') // Reload addresses to get updated list await loadAddresses(currentSupplierId.value) @@ -410,7 +412,7 @@ export function usePickupAddressManagement() { closeModal() return true } catch (error) { - debugLog('Error updating address:', error) + log('Error updating address:', error) return false } finally { isLoading.value = false @@ -425,20 +427,20 @@ export function usePickupAddressManagement() { */ const deleteAddress = async (addressId: string): Promise<boolean> => { if (!addressId) { - debugLog('Cannot delete address without ID') + log('Cannot delete address without ID') return false } isLoading.value = true try { - debugLog('Deleting address:', addressId) + log('Deleting address:', addressId) await api.companies_pickup_addresses_destroy(undefined, { params: { id: addressId }, }) - debugLog('Address deleted successfully') + log('Address deleted successfully') // Reload addresses to get updated list (inactive addresses filtered out) await loadAddresses(currentSupplierId.value) @@ -455,7 +457,7 @@ export function usePickupAddressManagement() { return true } catch (error) { - debugLog('Error deleting address:', error) + log('Error deleting address:', error) return false } finally { isLoading.value = false @@ -487,7 +489,7 @@ export function usePickupAddressManagement() { return true default: - debugLog('Please select an existing address or create a new one') + log('Please select an existing address or create a new one') return false } } diff --git a/frontend/src/composables/usePoETags.ts b/frontend/src/composables/usePoETags.ts index 4ca0310dc..383b7a2a9 100644 --- a/frontend/src/composables/usePoETags.ts +++ b/frontend/src/composables/usePoETags.ts @@ -6,7 +6,9 @@ */ import { ref } from 'vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('po:etags') const etagByPo = ref(new Map<string, string>()) @@ -33,7 +35,7 @@ export function usePoETags() { const setETag = (poId: string, etag: string): void => { if (etag && typeof etag === 'string') { etagByPo.value.set(poId, etag) - debugLog('PO ETag stored:', { poId, etag }) + log('stored:', { poId, etag }) } } @@ -45,7 +47,7 @@ export function usePoETags() { const hadETag = etagByPo.value.has(poId) etagByPo.value.delete(poId) if (hadETag) { - debugLog('PO ETag cleared:', { poId }) + log('cleared:', { poId }) } } @@ -57,7 +59,7 @@ export function usePoETags() { const count = etagByPo.value.size etagByPo.value.clear() if (count > 0) { - debugLog('All PO ETags cleared:', { count }) + log('all cleared:', { count }) } } diff --git a/frontend/src/composables/useQuoteImport.ts b/frontend/src/composables/useQuoteImport.ts index 5d933bd96..b5ea3d666 100644 --- a/frontend/src/composables/useQuoteImport.ts +++ b/frontend/src/composables/useQuoteImport.ts @@ -1,9 +1,11 @@ import { ref } from 'vue' import { quoteService } from '@/services/quote.service' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { z } from 'zod' import { schemas } from '@/api/generated/api' +const log = debug('quote:import') + type QuoteImportStatusResponse = z.infer<typeof schemas.QuoteImportStatusResponse> export function useQuoteImport() { @@ -16,12 +18,12 @@ export function useQuoteImport() { error.value = null try { - debugLog('[useQuoteImport] Loading quote status for jobId:', jobId) + log('Loading quote status for jobId:', jobId) currentQuote.value = await quoteService.getQuoteStatus(jobId) - debugLog('[useQuoteImport] Quote status loaded:', currentQuote.value) + log('Quote status loaded:', currentQuote.value) } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to load quote status' - debugLog('[useQuoteImport] Failed to load quote status:', err) + log('Failed to load quote status:', err) } finally { isLoading.value = false } diff --git a/frontend/src/composables/useSettingsSchema.ts b/frontend/src/composables/useSettingsSchema.ts index 25bc750c2..7028ef753 100644 --- a/frontend/src/composables/useSettingsSchema.ts +++ b/frontend/src/composables/useSettingsSchema.ts @@ -1,7 +1,9 @@ import { ref, computed } from 'vue' import { SettingsSchemaService } from '@/services/settings-schema.service' import type { ResolvedSettingsSection, ResolvedSettingsField } from '@/types/settings-schema.types' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('settings:schema') /** * CompanyDefaults setting keys removed from the backend (migration @@ -41,12 +43,12 @@ const isLoaded = ref(false) export function useSettingsSchema() { async function loadSchema(): Promise<void> { if (isLoaded.value) { - debugLog('[useSettingsSchema] Schema already loaded, skipping fetch') + log('Schema already loaded, skipping fetch') return } if (isLoading.value) { - debugLog('[useSettingsSchema] Schema already loading, skipping duplicate fetch') + log('Schema already loading, skipping duplicate fetch') return } @@ -56,10 +58,10 @@ export function useSettingsSchema() { try { sections.value = await SettingsSchemaService.getResolvedSchema() isLoaded.value = true - debugLog('[useSettingsSchema] Schema loaded:', sections.value.length, 'sections') + log('Schema loaded:', sections.value.length, 'sections') } catch (e) { error.value = (e as Error)?.message || 'Failed to load settings schema' - debugLog('[useSettingsSchema] Error loading schema:', error.value) + log('Error loading schema:', error.value) } finally { isLoading.value = false } diff --git a/frontend/src/composables/useSmartCostLineDelete.ts b/frontend/src/composables/useSmartCostLineDelete.ts index 240a3c46f..d44b01ab6 100644 --- a/frontend/src/composables/useSmartCostLineDelete.ts +++ b/frontend/src/composables/useSmartCostLineDelete.ts @@ -1,10 +1,12 @@ import { toast } from 'vue-sonner' import { costlineService } from '../services/costline.service' -import { debugLog } from '../utils/debug' +import debug from 'debug' import type { Ref } from 'vue' import { schemas } from '../api/generated/api' import type { z } from 'zod' +const log = debug('cost:delete-line') + type CostLine = z.infer<typeof schemas.CostLine> export interface UseSmartCostLineDeleteOptions { @@ -34,7 +36,7 @@ export function useSmartCostLineDelete(options: UseSmartCostLineDeleteOptions) { } } catch (error) { toast.error('Failed to delete cost line.') - debugLog('Failed to delete cost line:', error) + log('Failed to delete cost line:', error) } finally { if (isLoading) isLoading.value = false toast.dismiss('delete-cost-line') @@ -46,7 +48,7 @@ export function useSmartCostLineDelete(options: UseSmartCostLineDeleteOptions) { * Handles both saved lines (with ID) and local lines (without ID) */ async function handleSmartDelete(idOrIndex: string | number) { - console.log('useSmartCostLineDelete handleSmartDelete called with:', { + log('handleSmartDelete called with:', { idOrIndex, type: typeof idOrIndex, costLinesLength: costLines.value.length, @@ -57,17 +59,17 @@ export function useSmartCostLineDelete(options: UseSmartCostLineDeleteOptions) { ? (costLines.value.find((l) => l.id === idOrIndex) ?? null) : (costLines.value[idOrIndex] ?? null) - console.log('useSmartCostLineDelete found line:', { + log('found line:', { line: line ? { id: line.id, desc: line.desc } : null, hasId: !!line?.id, }) if (line) { if (line.id) { - console.log('useSmartCostLineDelete calling handleDeleteCostLine for saved line') + log('calling handleDeleteCostLine for saved line') await handleDeleteCostLine(line as CostLine) } else { - console.log('useSmartCostLineDelete removing local line from array') + log('removing local line from array') // For local lines without ID, just remove from array const index = typeof idOrIndex === 'number' ? idOrIndex : costLines.value.indexOf(line) if (index >= 0) { @@ -79,7 +81,7 @@ export function useSmartCostLineDelete(options: UseSmartCostLineDeleteOptions) { } } } else { - console.log('useSmartCostLineDelete: No line found for deletion') + log('No line found for deletion') } } diff --git a/frontend/src/composables/useTimesheetSummary.ts b/frontend/src/composables/useTimesheetSummary.ts index a8d6a9765..5d72b5160 100644 --- a/frontend/src/composables/useTimesheetSummary.ts +++ b/frontend/src/composables/useTimesheetSummary.ts @@ -4,12 +4,14 @@ import { schemas } from '../api/generated/api' import { z } from 'zod' type TimesheetCostLine = z.infer<typeof schemas.TimesheetCostLine> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { getJobActualHours, getJobEstimatedHours } from '@/utils/costLineMeta' type ModernTimesheetJob = z.infer<typeof schemas.ModernTimesheetJob> type FullJob = z.infer<typeof schemas.Job> | z.infer<typeof schemas.JobSummary> +const log = debug('timesheet:summary') + export function useTimesheetSummary() { const router = useRouter() const loading = ref(false) @@ -24,7 +26,7 @@ export function useTimesheetSummary() { const getJobHours = (jobId: string, timeEntries: TimesheetCostLine[]) => { const jobEntries = timeEntries.filter((entry) => entry.job_id === jobId) - debugLog(`getJobHours for jobId ${jobId}:`, { + log(`getJobHours for jobId ${jobId}:`, { jobId, totalEntries: timeEntries.length, matchingEntries: jobEntries.length, @@ -115,7 +117,7 @@ export function useTimesheetSummary() { } const getEstimatedHours = (job: FullJob) => { - debugLog('Received job:', job) + log('Received job:', job) return getJobEstimatedHours(job) } diff --git a/frontend/src/composables/useXeroApps.ts b/frontend/src/composables/useXeroApps.ts index bf35f7abe..2ed75645b 100644 --- a/frontend/src/composables/useXeroApps.ts +++ b/frontend/src/composables/useXeroApps.ts @@ -2,7 +2,9 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import type { z } from 'zod' import { api } from '@/api/client' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('xero:apps') export type XeroApp = z.infer<typeof schemas.XeroApp> @@ -42,7 +44,7 @@ export function useXeroApps(autoPoll: boolean = true) { } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load Xero apps.' error.value = message - debugLog('[useXeroApps] refresh failed:', err) + log('refresh failed:', err) } finally { loading.value = false } @@ -59,7 +61,7 @@ export function useXeroApps(autoPoll: boolean = true) { dayFloor.value = response.day_floor }) .catch((err) => { - debugLog('[useXeroApps] config fetch failed:', err) + log('config fetch failed:', err) }) .finally(() => { configFetchInflight = null diff --git a/frontend/src/composables/useXeroAuth.ts b/frontend/src/composables/useXeroAuth.ts index c5e103e4b..d4294f06c 100644 --- a/frontend/src/composables/useXeroAuth.ts +++ b/frontend/src/composables/useXeroAuth.ts @@ -3,9 +3,11 @@ import router from '@/router' import { getApiBaseUrl } from '@/plugins/axios' import { api } from '@/api/client' import { toast } from 'vue-sonner' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { xeroSyncStatusClass } from '@/utils/statusMappings' +const dbg = debug('xero:auth') + type XeroSseEvent = { datetime: string message: string @@ -141,13 +143,13 @@ export function useXeroAuth() { error.value = '' try { const pingRes = await api.xero_ping_retrieve() - console.log('[Xero Debug] Ping response:', pingRes) + dbg('Ping response:', pingRes) const shouldAuth = !!(pingRes && pingRes.connected) - console.log('[Xero Debug] Setting isAuthenticated to:', shouldAuth) + dbg('Setting isAuthenticated to:', shouldAuth) isAuthenticated.value = shouldAuth - console.log('[Xero Debug] isAuthenticated.value is now:', isAuthenticated.value) + dbg('isAuthenticated.value is now:', isAuthenticated.value) if (!isAuthenticated.value) { - console.log('[Xero Debug] Early return - not authenticated') + dbg('Early return - not authenticated') loading.value = false return } @@ -173,7 +175,7 @@ export function useXeroAuth() { syncing.value = false } } catch (err) { - console.log('[Xero Debug] Catch block triggered, error:', err) + dbg('Catch block triggered, error:', err) error.value = 'Failed to load Xero sync status.' isAuthenticated.value = false } finally { @@ -248,7 +250,7 @@ export function useXeroAuth() { `${formatEntityName(entity)}: ${data.message}` + (missingFields.length ? ` (missing: ${missingFields.join(', ')})` : ''), ) - debugLog('[Xero SSE Error]', { + dbg('SSE error', { entity, message: data.message, missingFields, diff --git a/frontend/src/pages/jobs/[id]/(index).vue b/frontend/src/pages/jobs/[id]/(index).vue index 1d4580d71..97f0806ab 100644 --- a/frontend/src/pages/jobs/[id]/(index).vue +++ b/frontend/src/pages/jobs/[id]/(index).vue @@ -299,7 +299,9 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:view') import { ref, computed, onMounted, watch } from 'vue' import AppLayout from '@/components/AppLayout.vue' import JobViewTabs from '@/components/job/JobViewTabs.vue' @@ -341,7 +343,7 @@ onMounted(async () => { }) jobsStore.setHeader(headerResponse) // #2: After setHeader - debugLog('[JobView] header set', { + log('header set', { store: jobsStore.headersById[jobId.value], computed: jobHeader.value, }) @@ -349,7 +351,7 @@ onMounted(async () => { (async () => { await companyDefaultsStore.loadCompanyDefaults() // #1: After loadCompanyDefaults - debugLog('[JobView] defaults loaded', { + log('defaults loaded', { store: companyDefaultsStore.companyDefaults, computed: companyDefaults.value, }) @@ -361,7 +363,7 @@ onMounted(async () => { } finally { loadingJob.value = false // #3: In finally - render check - debugLog('[JobView] render check', { + log('render check', { jobHeader: jobHeader.value, companyDefaults: companyDefaults.value, activeTab: activeTab.value, @@ -374,7 +376,7 @@ const isNewJob = computed(() => route.query.new === 'true') const defaultTab: JobTabKey = isNewJob.value ? 'quote' : 'actual' const { activeTab, setTab } = useJobTabs(defaultTab) // #4: After useJobTabs -debugLog('[JobView] tabs init', { activeTab: activeTab.value, defaultTab }) +log('tabs init', { activeTab: activeTab.value, defaultTab }) const localJobName = ref('') const localCompanyName = ref('') @@ -617,7 +619,7 @@ async function printJob() { } } catch (error) { toast.error('Error generating PDF for printing') - debugLog('Error printing job:', error) + log('Error printing job:', error) } } @@ -639,14 +641,14 @@ async function printDeliveryDocket() { } } catch (error) { toast.error('Error generating delivery docket for printing') - debugLog('Error printing delivery docket:', error) + log('Error printing delivery docket:', error) } } -debugLog('JobView - jobId:', jobId.value, 'jobHeader:', jobHeader.value) +log('jobId:', jobId.value, 'jobHeader:', jobHeader.value) onMounted(() => { - debugLog('Quote accepted?: ', shouldShowQuoteWarning.value) + log('Quote accepted?: ', shouldShowQuoteWarning.value) }) </script> diff --git a/frontend/src/pages/jobs/create.vue b/frontend/src/pages/jobs/create.vue index 979234835..41fab5a34 100644 --- a/frontend/src/pages/jobs/create.vue +++ b/frontend/src/pages/jobs/create.vue @@ -248,7 +248,9 @@ import RichTextEditor from '@/components/RichTextEditor.vue' import { jobService, type JobCreateData } from '@/services/job.service' import { schemas } from '@/api/generated/api' import { z } from 'zod' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('job:create') import { extractErrorMessage, createErrorToast, logError } from '@/utils/error-handler' type CompanySearchResult = z.infer<typeof schemas.CompanySearchResult> @@ -319,7 +321,7 @@ const isSubmitting = ref(false) const jobCreated = ref(false) const handleCompanySelection = async (company: CompanySearchResult | null) => { - debugLog('JobCreateView - handleCompanySelection:', { + log('handleCompanySelection:', { company, previousCompanyId: formData.value.company_id, previousPersonId: formData.value.person_id, @@ -341,7 +343,7 @@ const handleCompanySelection = async (company: CompanySearchResult | null) => { companyDisplayName.value = company.name formData.value.company_id = company.id - debugLog('JobCreateView - Company selected, waiting for PersonSelector to update') + log('Company selected, waiting for PersonSelector to update') // Wait for the next DOM update cycle to ensure the ref is ready // and the new company ID has propagated to the PersonSelector. @@ -351,7 +353,7 @@ const handleCompanySelection = async (company: CompanySearchResult | null) => { await new Promise((resolve) => setTimeout(resolve, 100)) if (personSelectorRef.value) { - debugLog('JobCreateView - Calling selectPrimaryPerson') + log('Calling selectPrimaryPerson') // The `selectPrimaryPerson` method within the composable // will handle loading people and finding the primary. await personSelectorRef.value.selectPrimaryPerson() @@ -360,7 +362,7 @@ const handleCompanySelection = async (company: CompanySearchResult | null) => { // Clear company fields if company is deselected companyDisplayName.value = '' formData.value.company_id = '' - debugLog('JobCreateView - Company cleared') + log('Company cleared') } } @@ -399,7 +401,7 @@ const canSubmit = computed(() => { const timeCheck = hasValidTimeEstimate.value const materialsCheck = hasValidMaterialsEstimate.value - debugLog('canSubmit validation:', { + log('canSubmit validation:', { nameCheck, xeroCheck, timeCheck, @@ -452,13 +454,13 @@ const validateForm = (): boolean => { const handleSubmit = async () => { if (!validateForm()) { - debugLog('Validation errors:', errors.value) + log('Validation errors:', errors.value) return } isSubmitting.value = true toast.info('Creating job…', { id: 'create-job' }) - debugLog('FormData: ', formData.value) + log('FormData: ', formData.value) // Step 1: create the job. A failure here means no job exists — surface it and let the user retry. let result: Awaited<ReturnType<typeof jobService.createJob>> @@ -510,7 +512,7 @@ const handleSubmit = async () => { } watch(formData.value, () => { - debugLog('FormData changed:', formData.value) + log('FormData changed:', formData.value) }) onMounted(() => { diff --git a/frontend/src/pages/purchasing/po/(index).vue b/frontend/src/pages/purchasing/po/(index).vue index eea3eeac0..711a5db35 100644 --- a/frontend/src/pages/purchasing/po/(index).vue +++ b/frontend/src/pages/purchasing/po/(index).vue @@ -116,7 +116,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import AppLayout from '@/components/AppLayout.vue' import { Button } from '@/components/ui/button' @@ -133,6 +133,8 @@ import { formatDate } from '@/utils/string-formatting' const statusOptions = schemas.PurchaseOrderDetailStatusEnum.options type PurchaseOrderStatus = (typeof statusOptions)[number] +const log = debug('po:list') + const router = useRouter() const store = usePurchaseOrderStore() const orders = computed(() => store.orders) @@ -223,7 +225,7 @@ const deletePo = async (id: string) => { toast.success('Purchase order deleted successfully') } catch (error) { - debugLog('Error deleting purchase order:', error) + log('Error deleting purchase order:', error) toast.error('Failed to delete the purchase order. Please try again.') } } diff --git a/frontend/src/pages/purchasing/po/[id].vue b/frontend/src/pages/purchasing/po/[id].vue index dbd7fa0dd..4cddb9d87 100644 --- a/frontend/src/pages/purchasing/po/[id].vue +++ b/frontend/src/pages/purchasing/po/[id].vue @@ -136,7 +136,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, watch, onMounted, onUnmounted, computed } from 'vue' import AppLayout from '@/components/AppLayout.vue' @@ -170,6 +170,8 @@ type CompanyPerson = z.infer<typeof schemas.CompanyPerson> type PurchaseOrderEmailResponseWithLegacy = PurchaseOrderEmailResponse & { email?: string } type PurchaseOrderStatus = z.infer<typeof schemas.PurchaseOrderDetailStatusEnum> +const log = debug('po:edit') + const route = useRoute() const router = useRouter() const orderId = route.params.id as string @@ -295,26 +297,26 @@ async function fetchJobs() { error.value = null try { - debugLog('Loading jobs for purchase order...') + log('Loading jobs for purchase order...') // Use the all-jobs endpoint which returns the expected format with jobs array const response = await api.purchasing_all_jobs_retrieve() - debugLog('Zodios response:', response) + log('Zodios response:', response) if (!response.success) { throw new Error('Failed to fetch jobs from server') } jobs.value = response.jobs || [] - debugLog(`Loaded ${jobs.value.length} jobs for purchase order`) + log(`Loaded ${jobs.value.length} jobs for purchase order`) if (jobs.value.length === 0) { toast.warning('No jobs available for purchase order creation') } else { - debugLog('First job sample:', jobs.value[0]) + log('First job sample:', jobs.value[0]) } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load jobs' - debugLog('Error loading jobs for purchasing:', err) + log('Error loading jobs for purchasing:', err) toast.error( `Failed to load jobs: ${errorMessage}. Job selection is required for purchase orders.`, ) @@ -334,7 +336,7 @@ async function loadExistingAllocations() { .catch(() => ({ allocations: {} })) existingAllocations.value = response.allocations || {} } catch (err) { - debugLog('Error loading existing allocations:', err) + log('Error loading existing allocations:', err) existingAllocations.value = {} } } @@ -344,7 +346,7 @@ async function loadJobsForReceipt() { const { stockHolding } = await receiptStore.fetchJobs() stockHoldingJobId.value = stockHolding?.id || null } catch (err) { - debugLog('Error loading jobs for receipt:', err) + log('Error loading jobs for receipt:', err) stockHoldingJobId.value = null } } @@ -355,14 +357,14 @@ async function load() { return } - debugLog('Loading PO data...') + log('Loading PO data...') isLoading.value = true isReloading.value = true error.value = null if (debounceTimer) { clearPoDebounceTimer(false) - debugLog('Cancelled pending autosave during reload') + log('Cancelled pending autosave during reload') } try { @@ -370,7 +372,7 @@ async function load() { po.value = data originalLines.value = JSON.parse(JSON.stringify(po.value.lines)) - debugLog('PO loaded successfully. Lines:', po.value.lines.length) + log('PO loaded successfully. Lines:', po.value.lines.length) if (po.value.status === 'deleted') { toast.warning('This purchase order has been deleted and cannot be edited', { @@ -381,7 +383,7 @@ async function load() { } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load purchase order' error.value = errorMessage - debugLog('Error loading purchase order:', err) + log('Error loading purchase order:', err) if (errorMessage.includes('not found') || errorMessage.includes('404')) { toast.error('Purchase order not found') @@ -395,7 +397,7 @@ async function load() { isLoading.value = false setTimeout(() => { isReloading.value = false - debugLog('Reload complete, watchers re-enabled') + log('Reload complete, watchers re-enabled') }, 200) } } @@ -429,7 +431,7 @@ async function saveSummary() { await loadExistingAllocations() } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) - debugLog('Error saving summary:', err) + log('Error saving summary:', err) // Listen for retry events if this was a concurrency conflict if ( @@ -450,7 +452,7 @@ async function saveSummary() { await loadExistingAllocations() } catch (retryErr) { toast.error('Retry failed. Please try again.') - debugLog('Retry failed:', retryErr) + log('Retry failed:', retryErr) } }) } @@ -490,11 +492,11 @@ function deleteLine(idOrIdx: string | number) { return } - debugLog('Deleting line:', idOrIdx) + log('Deleting line:', idOrIdx) if (debounceTimer) { clearPoDebounceTimer(false) - debugLog('Cancelled pending autosave timer') + log('Cancelled pending autosave timer') } isDeletingLine.value = true @@ -505,7 +507,7 @@ function deleteLine(idOrIdx: string | number) { if (lineToDelete && hasAnyContent(lineToDelete)) { linesToDelete.value.push(idOrIdx) - debugLog('Added line to delete list:', idOrIdx) + log('Added line to delete list:', idOrIdx) } po.value.lines = po.value.lines.filter((l: PurchaseOrderLine) => l.id !== idOrIdx) @@ -513,15 +515,15 @@ function deleteLine(idOrIdx: string | number) { po.value.lines = po.value.lines.filter((_: PurchaseOrderLine, idx: number) => idx !== idOrIdx) } - debugLog('Line deleted. Remaining lines:', po.value.lines.length) + log('Line deleted. Remaining lines:', po.value.lines.length) } finally { setTimeout(() => { isDeletingLine.value = false - debugLog('Deletion flag cleared, autosave re-enabled') + log('Deletion flag cleared, autosave re-enabled') // Trigger autosave after deletion if there are lines to delete if (linesToDelete.value.length > 0) { - debugLog('Triggering save for deleted lines') + log('Triggering save for deleted lines') saveLines() } }, 500) @@ -582,13 +584,13 @@ function isValidLine(line: PurchaseOrderLine) { async function saveLines() { if (isPoDeleted.value) { - debugLog('Cannot save - PO is deleted') + log('Cannot save - PO is deleted') toast.error('Cannot save changes - this purchase order has been deleted') return } if (isReloading.value || isDeletingLine.value) { - debugLog('Skipping save - reloading:', isReloading.value, 'deleting:', isDeletingLine.value) + log('Skipping save - reloading:', isReloading.value, 'deleting:', isDeletingLine.value) return } @@ -631,7 +633,7 @@ async function saveLines() { } if (!validLines.length && !linesToDelete.value.length) { - debugLog('No valid lines or deletes to save') + log('No valid lines or deletes to save') syncPoSaveStatus(false) return } @@ -639,12 +641,12 @@ async function saveLines() { const changedLines = validLines.filter(lineChanged) if (!changedLines.length && !linesToDelete.value.length) { - debugLog('No changes detected - skipping save') + log('No changes detected - skipping save') syncPoSaveStatus(false) return } - debugLog('Saving lines...', { + log('Saving lines...', { isSubmitted: isPoSubmitted.value, validLines: validLines.length, changedLines: changedLines.length, @@ -664,7 +666,7 @@ async function saveLines() { job_id: line.job_id && line.job_id.trim() !== '' ? line.job_id : null, } - debugLog('Transformed line (submitted PO - job only):', { + log('Transformed line (submitted PO - job only):', { hasId: !!transformed.id, id: transformed.id, job_id: transformed.job_id, @@ -689,7 +691,7 @@ async function saveLines() { dimensions: line.dimensions || '', } - debugLog('Transformed line (full):', { + log('Transformed line (full):', { hasId: !!transformed.id, id: transformed.id, description: transformed.description?.substring(0, 20), @@ -720,14 +722,14 @@ async function saveLines() { const needsReload = changedLines.some((line) => !line.id) || linesToDeleteBackup.length > 0 if (needsReload) { - debugLog('Reloading PO after save to ensure consistency...') + log('Reloading PO after save to ensure consistency...') await load() } else { originalLines.value = JSON.parse(JSON.stringify(po.value.lines)) - debugLog('Updated original lines without reload') + log('Updated original lines without reload') } } catch (error) { - debugLog('Error saving lines:', error) + log('Error saving lines:', error) // Rollback on error po.value.lines = snapshot toast.error('Failed to save lines. Changes have been reverted: ' + extractErrorMessage(error)) @@ -758,11 +760,11 @@ async function syncWithXero() { if (data.messages?.length) { data.messages.forEach((msg) => toast.warning(msg)) } - debugLog('Xero sync successful:', data) + log('Xero sync successful:', data) } else { const msgs = data.messages?.length ? data.messages : ['Xero sync failed'] msgs.forEach((msg) => toast.error(msg, createErrorToast())) - debugLog('Xero sync failed:', data) + log('Xero sync failed:', data) } } catch (err: unknown) { toast.dismiss('po-sync-loading') @@ -783,7 +785,7 @@ function viewInXero() { window.open(po.value.online_url, '_blank', 'noopener,noreferrer') toast.success('Opened Purchase Order in Xero') } catch (error) { - debugLog('Failed to open Xero URL:', error) + log('Failed to open Xero URL:', error) toast.error('Failed to open Xero. Please check if pop-ups are blocked.') } } @@ -827,7 +829,7 @@ async function resolveSupplierEmail(): Promise<string | null> { return resolvedEmail } catch (err) { - debugLog('Failed to resolve supplier people for email:', err) + log('Failed to resolve supplier people for email:', err) supplierEmailCache.value = { ...supplierEmailCache.value, [supplierId]: null, @@ -874,13 +876,13 @@ async function emailPurchaseOrder() { toast.dismiss('po-email-loading') const errorMessage = extractErrorMessage(error) toast.error(`Email failed: ${errorMessage}`, createErrorToast()) - debugLog('Error preparing email:', error) + log('Error preparing email:', error) } } async function close() { if (isPoDeleted.value) { - debugLog('Closing without save - PO is deleted') + log('Closing without save - PO is deleted') router.push('/purchasing/po') return } @@ -888,25 +890,25 @@ async function close() { let hasAuthError = false try { - debugLog('Closing PO form - triggering autosave...') + log('Closing PO form - triggering autosave...') // Clear any pending debounce timers to force immediate save if (debounceTimer) { clearPoDebounceTimer(false) - debugLog('Cleared pending autosave timer for immediate save') + log('Cleared pending autosave timer for immediate save') } // Always try to save lines (function will check for actual changes internally) - debugLog('Saving line changes before close...') + log('Saving line changes before close...') await saveLines() // Always try to save summary to ensure consistency - debugLog('Saving summary changes before close...') + log('Saving summary changes before close...') await saveSummary() - debugLog('Autosave completed, navigating to PO list') + log('Autosave completed, navigating to PO list') } catch (error) { - debugLog('Error during autosave on close:', error) + log('Error during autosave on close:', error) // Check if it's an authentication error const possibleErrorObject = typeof error === 'object' && error !== null ? error : null @@ -925,7 +927,7 @@ async function close() { const isAuthError = responseStatus === 401 || messageText?.includes('auth') if (isAuthError) { - debugLog('Authentication error detected during save') + log('Authentication error detected during save') toast.error('Session expired. Please login again.') hasAuthError = true return @@ -936,7 +938,7 @@ async function close() { } finally { // Always navigate back to PO list unless there was an auth error if (!hasAuthError) { - debugLog('Navigating to purchase orders list') + log('Navigating to purchase orders list') router.push('/purchasing/po') } } @@ -986,7 +988,7 @@ const handleReceiptSave = async (payload: { const newRows = payload.editorState.rows if (!lineId || lineId === 'undefined') { - debugLog('Invalid lineId:', lineId) + log('Invalid lineId:', lineId) return } @@ -1006,7 +1008,7 @@ const handleReceiptSave = async (payload: { }) if (newApiAllocations.length === 0) { - debugLog('No valid allocations to save.') + log('No valid allocations to save.') return } @@ -1033,7 +1035,7 @@ const handleReceiptSave = async (payload: { const request = transformDeliveryReceiptForAPI(po.value.id, map) try { - debugLog('Saving receipt for line:', lineId, 'with NEW allocations:', consolidated) + log('Saving receipt for line:', lineId, 'with NEW allocations:', consolidated) saveFeedback.saving() await receiptStore.submitDeliveryReceipt(po.value.id, request.allocations) saveFeedback.saved() @@ -1042,7 +1044,7 @@ const handleReceiptSave = async (payload: { await updatePoStatusAfterReceipt() } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) - debugLog('Error saving receipt:', err) + log('Error saving receipt:', err) // Check if this is a concurrency conflict (handled by the store) const isConcurrencyError = @@ -1071,7 +1073,7 @@ const handleReceiptSave = async (payload: { await updatePoStatusAfterReceipt() } catch (retryErr) { saveFeedback.error('Retry failed. Please try again.') - debugLog('Retry failed:', retryErr) + log('Retry failed:', retryErr) } }) } else { @@ -1117,13 +1119,13 @@ const updatePoStatusAfterReceipt = async () => { toast.success(`Purchase order status updated to ${newStatus.replace('_', ' ')}`) } } catch (err) { - debugLog('Error updating PO status after receipt:', err) + log('Error updating PO status after receipt:', err) // Don't show error toast for this as the receipt was successful } } const handleAllocationDeleted = async (data: { allocationId: string; allocationType: string }) => { - debugLog('Allocation deleted:', data) + log('Allocation deleted:', data) // Reload allocations and PO data to reflect changes await Promise.all([load(), loadExistingAllocations()]) @@ -1136,7 +1138,7 @@ onMounted(async () => { try { await Promise.all([fetchJobs(), load(), loadJobsForReceipt(), loadExistingAllocations()]) } catch (err) { - debugLog('Error during component initialization:', err) + log('Error during component initialization:', err) } watch( @@ -1148,7 +1150,7 @@ onMounted(async () => { isEditingAdditionalFields.value || isSavingReceipt.value ) { - debugLog( + log( '⏸️ Skipping autosave - reloading:', isReloading.value, 'deleting:', @@ -1160,7 +1162,7 @@ onMounted(async () => { } if (!newLines || newLines.length === 0) { - debugLog('Skipping autosave - no lines to save') + log('Skipping autosave - no lines to save') return } @@ -1177,13 +1179,13 @@ onMounted(async () => { } } - debugLog('Lines changed, scheduling autosave in 500ms') + log('Lines changed, scheduling autosave in 500ms') if (debounceTimer) { clearPoDebounceTimer(false) - debugLog('Cleared previous timer') + log('Cleared previous timer') } schedulePoAutosave(() => { - debugLog('Executing scheduled autosave') + log('Executing scheduled autosave') saveLines() }, 500) }, diff --git a/frontend/src/pages/purchasing/pricing.vue b/frontend/src/pages/purchasing/pricing.vue index e81dfc78f..a98ba3f66 100644 --- a/frontend/src/pages/purchasing/pricing.vue +++ b/frontend/src/pages/purchasing/pricing.vue @@ -18,13 +18,16 @@ </AppLayout> </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import AppLayout from '@/components/AppLayout.vue' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { UploadCloud } from 'lucide-vue-next' import DragAndDropUploader from '@/components/purchasing/DragAndDropUploader.vue' + +const log = debug('po:pricing') + function upload(files: FileList) { - debugLog('upload', files) + log('upload', files) } </script> diff --git a/frontend/src/pages/purchasing/stock.vue b/frontend/src/pages/purchasing/stock.vue index f7d29f6d4..9e8ef0376 100644 --- a/frontend/src/pages/purchasing/stock.vue +++ b/frontend/src/pages/purchasing/stock.vue @@ -241,7 +241,7 @@ </AppLayout> </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import AppLayout from '@/components/AppLayout.vue' import { Button } from '@/components/ui/button' @@ -265,6 +265,8 @@ import { onMounted, ref, computed, watch } from 'vue' import { toast } from 'vue-sonner' import { formatCurrency } from '@/utils/string-formatting' +const log = debug('po:stock') + const stockStore = useStockStore() const jobsStore = useJobsStore() @@ -373,7 +375,7 @@ const allocateForm = ref({ watch( allocateForm, (newValue) => { - debugLog('allocateForm changed:', newValue) + log('allocateForm changed:', newValue) }, { deep: true }, ) @@ -458,7 +460,7 @@ async function submitAllocate() { return } - console.log('Stock allocation check:', { + log('Stock allocation check:', { qtyToUse: allocateForm.value.qtyToUse, availableQty: allocateForm.value.availableQty, qtyToUseType: typeof allocateForm.value.qtyToUse, @@ -468,7 +470,7 @@ async function submitAllocate() { if (allocateForm.value.qtyToUse > allocateForm.value.availableQty) { // Show confirmation dialog for insufficient stock - console.log('Should show insufficient stock dialog') + log('Should show insufficient stock dialog') showInsufficientStockConfirm.value = true return } @@ -488,7 +490,7 @@ async function performStockAllocation() { await refreshStock() } catch (error) { toast.error('Failed to allocate stock') - debugLog('Error allocating stock:', error) + log('Error allocating stock:', error) } } @@ -514,7 +516,7 @@ async function submitAdd() { await refreshStock() } catch (error) { toast.error('Failed to add stock') - debugLog('Error adding stock:', error) + log('Error adding stock:', error) } } @@ -526,7 +528,7 @@ async function submitDelete() { await refreshStock() } catch (error) { toast.error('Failed to delete stock') - debugLog('Error deleting stock:', error) + log('Error deleting stock:', error) } } @@ -542,7 +544,7 @@ async function loadJobs() { const archivedJobs = data.archived_jobs || [] jobsStore.setKanbanJobs([...activeJobs, ...archivedJobs]) } catch (error) { - debugLog('Error loading jobs:', error) + log('Error loading jobs:', error) toast.error('Failed to load jobs') } } diff --git a/frontend/src/pages/quoting/chat.vue b/frontend/src/pages/quoting/chat.vue index 66eb2fe42..f5dcdc442 100644 --- a/frontend/src/pages/quoting/chat.vue +++ b/frontend/src/pages/quoting/chat.vue @@ -158,7 +158,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -171,6 +171,8 @@ import { toast } from 'vue-sonner' import { schemas } from '@/api/generated/api' import type { z } from 'zod' +const log = debug('quote:chat') + type JobQuoteChat = z.infer<typeof schemas.JobQuoteChat> const route = useRoute() @@ -232,7 +234,7 @@ const handleSendMessage = async () => { const assistantMessage = quoteChatService.convertToVueMessage(backendAssistantMessage) messages.value.push(assistantMessage) } catch (error) { - debugLog('Chat processing failed:', error) + log('Chat processing failed:', error) const lastMessage = messages.value[messages.value.length - 1] if (lastMessage && lastMessage.senderId === 'assistant-1') { lastMessage.content = 'Sorry, I had trouble processing that. Please try again.' @@ -255,7 +257,7 @@ const formatTime = (timestamp: string): string => { hour12: true, }) } catch (error) { - debugLog('Error formatting time:', error) + log('Error formatting time:', error) return 'Invalid time' } } @@ -279,7 +281,7 @@ const extractChatMessages = (payload: unknown): JobQuoteChat[] => { const parsed = schemas.JobQuoteChat.array().safeParse(messages) if (!parsed.success) { - debugLog('Invalid chat history payload:', parsed.error) + log('Invalid chat history payload:', parsed.error) return [] } @@ -311,7 +313,7 @@ const loadChatHistory = async () => { } } } catch (error) { - debugLog('Failed to load chat history:', error) + log('Failed to load chat history:', error) toast.error('Failed to load chat history', { description: 'Could not retrieve previous conversation. Starting fresh.', }) @@ -325,7 +327,7 @@ const saveMessage = async (message: VueChatMessage, role: 'user' | 'assistant') const backendMessage = quoteChatService.convertFromVueMessage(message, role) await quoteChatService.saveMessage(jobContext.value.jobId, backendMessage) } catch (error) { - debugLog('Failed to save chat message:', error) + log('Failed to save chat message:', error) toast.error('Failed to save message', { description: 'Message was not saved to database', }) @@ -354,7 +356,7 @@ const clearChatHistory = async () => { description: 'All messages have been deleted', }) } catch (error) { - debugLog('Failed to clear chat history:', error) + log('Failed to clear chat history:', error) toast.error('Failed to clear chat history', { description: 'Could not delete messages from database', }) @@ -372,7 +374,7 @@ const handleFileUpload = async (event: Event) => { if (!files || files.length === 0) return - debugLog( + log( 'Files selected:', Array.from(files).map((f) => f.name), ) @@ -394,18 +396,18 @@ const navigateBack = () => { } onMounted(async () => { - debugLog('QuotingChatView mounted') - debugLog('Route query:', route.query) - debugLog('Job context:', jobContext.value) + log('QuotingChatView mounted') + log('Route query:', route.query) + log('Job context:', jobContext.value) if (!jobContext.value) { - debugLog('No job context provided') + log('No job context provided') return } - debugLog('Loading chat history...') + log('Loading chat history...') await loadChatHistory() - debugLog('Chat history loaded') + log('Chat history loaded') }) </script> diff --git a/frontend/src/pages/reports/kpi.vue b/frontend/src/pages/reports/kpi.vue index 36556b810..d00e3b2c3 100644 --- a/frontend/src/pages/reports/kpi.vue +++ b/frontend/src/pages/reports/kpi.vue @@ -21,14 +21,14 @@ :month="selectedMonth" @update:year=" (year) => { - debugLog('Year changed to:', year) + log('Year changed to:', year) selectedYear = year fetchKPIData() } " @update:month=" (month) => { - debugLog('Month changed to:', month) + log('Month changed to:', month) selectedMonth = month fetchKPIData() } @@ -320,7 +320,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' @@ -338,6 +338,8 @@ import type { KPICalendarResponse, DayKPI } from '@/services/kpi.service' import { BarChart3, Download, RefreshCw, Settings } from 'lucide-vue-next' import { formatCurrency, formatHoursDisplay } from '@/utils/string-formatting' +const log = debug('report:kpi') + const router = useRouter() const loading = ref(false) const showSettingsModal = ref(false) @@ -358,7 +360,7 @@ function goToToday() { } function exportData() { - debugLog('Exporting KPI data for overview') + log('Exporting KPI data for overview') } function refreshData() { @@ -370,7 +372,7 @@ async function fetchKPIData() { kpiLoading.value = true kpiError.value = null - debugLog('Fetching KPI data for:', { + log('Fetching KPI data for:', { year: selectedYear.value, month: selectedMonth.value, }) @@ -380,7 +382,7 @@ async function fetchKPIData() { month: selectedMonth.value, }) - debugLog('KPI data received:', { + log('KPI data received:', { year: response.year, month: response.month, daysCount: Object.keys(response.calendar_data).length, @@ -388,7 +390,7 @@ async function fetchKPIData() { kpiData.value = response } catch (error) { - debugLog('Error fetching KPI data:', error) + log('Error fetching KPI data:', error) kpiError.value = error instanceof Error ? error.message : 'Failed to load KPI data' } finally { kpiLoading.value = false diff --git a/frontend/src/pages/timesheets/daily.vue b/frontend/src/pages/timesheets/daily.vue index d2cf916f3..ede703cf5 100644 --- a/frontend/src/pages/timesheets/daily.vue +++ b/frontend/src/pages/timesheets/daily.vue @@ -184,7 +184,7 @@ </template> <script setup lang="ts"> -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { ref, onMounted, watch } from 'vue' import { useRouter, useRoute } from 'vue-router' @@ -211,6 +211,8 @@ import { } from '@/services/daily-timesheet.service' import { dateService, today, navigateDay } from '@/services/date.service' +const log = debug('timesheet:daily') + const router = useRouter() const route = useRoute() const loading = ref(false) @@ -224,8 +226,8 @@ const selectedStaff = ref<StaffDailyData | null>(null) const showStaffModal = ref(false) const showMetricsModal = ref(false) -debugLog('DailyTimesheetView URL params:', { date: route.query.date }) -debugLog('Using initial date:', initialDate) +log('DailyTimesheetView URL params:', { date: route.query.date }) +log('Using initial date:', initialDate) const formatDisplayDate = (date: string): string => { return dateService.formatDisplayDate(date, { @@ -242,13 +244,13 @@ const loadData = async (): Promise<void> => { loading.value = true error.value = null - debugLog('Loading daily timesheet data for:', selectedDate.value) + log('Loading daily timesheet data for:', selectedDate.value) summary.value = await getDailyTimesheetSummary(selectedDate.value) - debugLog('Loaded summary:', summary.value) + log('Loaded summary:', summary.value) } catch (err) { - debugLog('Error loading timesheet data:', err) + log('Error loading timesheet data:', err) error.value = 'Failed to load timesheet data. Please try again.' } finally { loading.value = false @@ -305,7 +307,7 @@ watch( () => route.query.date, (newDate) => { if (newDate && newDate !== selectedDate.value) { - debugLog('Updating date from URL:', newDate) + log('Updating date from URL:', newDate) selectedDate.value = newDate as string loadData() } @@ -315,7 +317,7 @@ watch( watch(selectedDate, (newDate) => { if (newDate && newDate !== route.query.date) { - debugLog('Updating URL from date change:', newDate) + log('Updating URL from date change:', newDate) updateRoute() } }) diff --git a/frontend/src/pages/timesheets/entry.vue b/frontend/src/pages/timesheets/entry.vue index 573293a04..36403d384 100644 --- a/frontend/src/pages/timesheets/entry.vue +++ b/frontend/src/pages/timesheets/entry.vue @@ -586,11 +586,13 @@ import { api } from '@/api/client' import { schemas } from '@/api/generated/api' import { z } from 'zod' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toLocalDateString } from '@/utils/dateUtils' import { extractErrorMessage, logError } from '@/utils/error-handler' import { useSaveFeedback } from '@/composables/useSaveFeedback' +const log = debug('timesheet:entry') + type ModernTimesheetJob = z.infer<typeof schemas.ModernTimesheetJob> type Staff = z.infer<typeof schemas.ModernStaff> type TimesheetCostLine = z.infer<typeof schemas.TimesheetCostLine> @@ -631,13 +633,13 @@ const focusPhantomToken = ref(0) const showHelpModal = ref(false) const todayDate = toLocalDateString() -debugLog('Today is:', todayDate, 'Day of week:', new Date().getDay()) +log('Today is:', todayDate, 'Day of week:', new Date().getDay()) const initialDate = (route.query.date as string) || todayDate const initialStaffId = (route.query.staffId as string) || '' -debugLog('URL params:', { date: route.query.date, staffId: route.query.staffId }) -debugLog('Using initial values:', { date: initialDate, staffId: initialStaffId }) +log('URL params:', { date: route.query.date, staffId: route.query.staffId }) +log('Using initial values:', { date: initialDate, staffId: initialStaffId }) const currentDate = ref<string>(initialDate) const selectedStaffId = ref<string>(initialStaffId) @@ -653,7 +655,7 @@ const adaptedTimeEntries = computed(() => { ...entry, })) - debugLog('adaptedTimeEntries:', { + log('adaptedTimeEntries:', { originalCount: timeEntries.value.length, adaptedCount: adapted.length, originalSample: timeEntries.value.slice(0, 2).map((e) => ({ @@ -674,7 +676,7 @@ const adaptedTimeEntries = computed(() => { // Computed property to ensure jobs are always available const availableJobs = computed(() => { - debugLog('availableJobs computed - jobs count:', timesheetStore.jobs.length) + log('availableJobs computed - jobs count:', timesheetStore.jobs.length) return timesheetStore.jobs || [] }) @@ -739,7 +741,7 @@ const getJobHours = (jobId: string, timeEntries: TimesheetCostLine[]) => { const hours = jobEntries.reduce((sum, entry) => sum + getEntryHours(entry), 0) - debugLog(`getJobHours (local) for jobId ${jobId}:`, { + log(`getJobHours (local) for jobId ${jobId}:`, { jobId, totalEntries: timeEntries.length, matchingEntries: jobEntries.length, @@ -766,15 +768,11 @@ const loadEnhancedJobData = async (jobIds: string[]) => { const jobsToLoad = jobIds.filter((id) => !enhancedJobs.value.has(id)) if (jobsToLoad.length === 0) { - debugLog('All enhanced job data already loaded') + log('All enhanced job data already loaded') return } - debugLog( - 'Loading enhanced job data for jobs with timesheet entries:', - jobsToLoad.length, - 'jobs', - ) + log('Loading enhanced job data for jobs with timesheet entries:', jobsToLoad.length, 'jobs') // Load all jobs in parallel instead of sequentially const results = await Promise.allSettled( @@ -789,18 +787,18 @@ const loadEnhancedJobData = async (jobIds: string[]) => { if (result.status === 'fulfilled') { const { jobId, job } = result.value enhancedJobs.value.set(jobId, job) - debugLog('Loaded enhanced job data:', { + log('Loaded enhanced job data:', { jobId, jobNumber: job.job_number, latest_estimate: job.latest_estimate?.summary, latest_quote: job.latest_quote?.summary, }) } else { - debugLog('Failed to load enhanced job data:', result.reason) + log('Failed to load enhanced job data:', result.reason) } } } catch (err) { - debugLog('Error loading enhanced job data:', err) + log('Error loading enhanced job data:', err) } } @@ -828,7 +826,7 @@ const activeJobsWithData = computed<ActiveJobWithData[]>(() => { ), ] - debugLog('activeJobsWithData:', { + log('activeJobsWithData:', { adaptedEntriesCount: adaptedTimeEntries.value.length, uniqueJobIdsFromEntries: uniqueJobIds, activeJobsCount: activeJobs.value.length, @@ -842,7 +840,7 @@ const activeJobsWithData = computed<ActiveJobWithData[]>(() => { // Skip jobs without timesheet entries (shouldn't happen since we're filtering by entries) if (actualHours === 0) { - debugLog(`Job ${jobId} has 0 hours despite being in entries`) + log(`Job ${jobId} has 0 hours despite being in entries`) return null } @@ -877,9 +875,9 @@ const activeJobsWithData = computed<ActiveJobWithData[]>(() => { leave_type: typeof metaLeaveType === 'string' ? metaLeaveType : 'time', } as ModernTimesheetJob - debugLog(`Created minimal job object for ${jobId}:`, job) + log(`Created minimal job object for ${jobId}:`, job) } else { - debugLog(`Could not find or create job data for ${jobId}`) + log(`Could not find or create job data for ${jobId}`) return null } } @@ -892,7 +890,7 @@ const activeJobsWithData = computed<ActiveJobWithData[]>(() => { const completionPercentage = getCompletionPercentage(actualHours, estimatedHours) const isOverBudget = isJobOverBudget(actualHours, estimatedHours) - debugLog(`Job ${job.job_number} (${jobId}):`, { + log(`Job ${job.job_number} (${jobId}):`, { actualHours, estimatedHours, totalBill, @@ -912,7 +910,7 @@ const activeJobsWithData = computed<ActiveJobWithData[]>(() => { .filter((jobData): jobData is ActiveJobWithData => jobData !== null) // Remove null entries .sort((a, b) => b.actualHours - a.actualHours) // Sort by hours worked (descending) - debugLog('Active jobs with data (FIXED):', jobsWithData.length, jobsWithData) + log('Active jobs with data (FIXED):', jobsWithData.length, jobsWithData) return jobsWithData }) @@ -921,7 +919,7 @@ watch( () => activeJobsWithData.value.map((jobData) => jobData.job.id), async (newJobIds) => { if (newJobIds.length > 0) { - debugLog( + log( 'Jobs with timesheet entries changed, loading enhanced data for:', newJobIds.length, 'jobs', @@ -985,7 +983,7 @@ async function handleCreateEntry(entry: TimesheetCostLine): Promise<void> { is_billable: billRateMultiplier > 0, }, } - debugLog('[handleCreateEntry] POST payload:', payload, 'jobId:', job.id) + log('[handleCreateEntry] POST payload:', payload, 'jobId:', job.id) let savedSuccessfully = false createPending.value = true createEntrySaveFeedback.saving() @@ -1031,9 +1029,9 @@ async function handleDeleteEntryById(id: string): Promise<void> { loading.value = true await costlineService.deleteCostLine(String(id)) timeEntries.value = timeEntries.value.filter((e) => String(e.id) !== String(id)) - debugLog('Entry deleted successfully:', id) + log('Entry deleted successfully:', id) } catch (err) { - debugLog('Error deleting entry:', err) + log('Error deleting entry:', err) error.value = 'Failed to delete entry' } finally { loading.value = false @@ -1084,7 +1082,7 @@ const navigateDate = (direction: number) => { const newDay = String(date.getDate()).padStart(2, '0') currentDate.value = `${newYear}-${newMonth}-${newDay}` - debugLog( + log( 'Navigated to:', currentDate.value, 'Day of week:', @@ -1112,7 +1110,7 @@ const goToToday = () => { const day = String(today.getDate()).padStart(2, '0') currentDate.value = `${year}-${month}-${day}` - debugLog('Going to today:', currentDate.value, 'Weekend enabled:', timesheetStore.weekendEnabled) + log('Going to today:', currentDate.value, 'Weekend enabled:', timesheetStore.weekendEnabled) updateRoute() } @@ -1186,16 +1184,16 @@ const refreshData = () => { const handleStaffChange = async (staffId: string | null) => { if (!staffId) { - debugLog('Skipping staff change - no staffId provided') + log('Skipping staff change - no staffId provided') return } if (staffId === selectedStaffId.value) { - debugLog('Skipping staff change - same staff selected') + log('Skipping staff change - same staff selected') return } - debugLog('Staff changed:', { from: selectedStaffId.value, to: staffId }) + log('Staff changed:', { from: selectedStaffId.value, to: staffId }) selectedStaffId.value = staffId updateRoute() @@ -1205,29 +1203,29 @@ const handleStaffChange = async (staffId: string | null) => { } const loadTimesheetData = async () => { - debugLog('function loadTimesheetData called with:', { + log('function loadTimesheetData called with:', { staffId: selectedStaffId.value, date: currentDate.value, isInitializing: isInitializing.value, isLoadingData: isLoadingData.value, }) - debugLog('Call-stack: ', new Error().stack) - debugLog('Timestamp:', new Date().toISOString()) + log('Call-stack: ', new Error().stack) + log('Timestamp:', new Date().toISOString()) // ✅ Prevent duplicate calls if (isLoadingData.value) { - debugLog('Skipping data load - already loading') + log('Skipping data load - already loading') return } if (!selectedStaffId.value) { - debugLog('Skipping data load - no staff selected') + log('Skipping data load - no staff selected') return } if (!currentDate.value) { - debugLog('Skipping data load - no date selected') + log('Skipping data load - no date selected') return } @@ -1236,7 +1234,7 @@ const loadTimesheetData = async () => { isLoadingData.value = true // ✅ Set loading flag error.value = null - debugLog('Loading timesheet data for:', { + log('Loading timesheet data for:', { staffId: selectedStaffId.value, date: currentDate.value, }) @@ -1246,9 +1244,9 @@ const loadTimesheetData = async () => { currentDate.value, ) - debugLog('API Response:', response) - debugLog('Cost lines from API:', response.cost_lines) - debugLog('Number of cost lines:', response.cost_lines?.length || 0) + log('API Response:', response) + log('Cost lines from API:', response.cost_lines) + log('Number of cost lines:', response.cost_lines?.length || 0) // Sort by canonical backend sequence so the visible order matches the // order enforced for this staff member and date. @@ -1265,9 +1263,9 @@ const loadTimesheetData = async () => { scheduledHours.value = response.summary.scheduled_hours as number - debugLog(`Loaded ${timeEntries.value.length} timesheet entries`) + log(`Loaded ${timeEntries.value.length} timesheet entries`) } catch (err) { - debugLog('Error loading timesheet data:', err) + log('Error loading timesheet data:', err) error.value = 'Failed to load timesheet data' } finally { loading.value = false @@ -1282,7 +1280,7 @@ onMounted(async () => { try { loading.value = true - debugLog('Initializing optimized timesheet...') + log('Initializing optimized timesheet...') // initialize() already calls loadStaff, loadJobs, loadCompanyDefaults in parallel // No need to call them again - that was causing duplicate API calls @@ -1292,7 +1290,7 @@ onMounted(async () => { timesheetStore.initialize(currentDate.value), companyDefaultsStore.loadCompanyDefaults(), ]) - debugLog('Company Defaults store value: ', companyDefaultsStore.companyDefaults) + log('Company Defaults store value: ', companyDefaultsStore.companyDefaults) let validStaffId = selectedStaffId.value let currentStaffData = validStaffId @@ -1312,26 +1310,26 @@ onMounted(async () => { if (!validStaffId && timesheetStore.staff.length > 0) { validStaffId = timesheetStore.staff[0].id currentStaffData = timesheetStore.staff[0] - debugLog('No staffId in URL, using first available:', validStaffId) + log('No staffId in URL, using first available:', validStaffId) } selectedStaffId.value = validStaffId - debugLog('Available staff:', timesheetStore.staff.length) - debugLog('Available jobs:', timesheetStore.jobs.length) - debugLog( + log('Available staff:', timesheetStore.staff.length) + log('Available jobs:', timesheetStore.jobs.length) + log( 'Current staff for calculations:', currentStaffData?.name, 'wage rate:', currentStaffData?.wageRate, ) - debugLog('Company defaults for calculations:', companyDefaultsStore.companyDefaults) + log('Company defaults for calculations:', companyDefaultsStore.companyDefaults) updateRoute() isInitializing.value = false - debugLog('Starting initial data load...') + log('Starting initial data load...') await loadTimesheetData() // Load enhanced job data for jobs with timesheet entries @@ -1343,9 +1341,9 @@ onMounted(async () => { await loadEnhancedJobData(jobsWithEntries.map((job) => job.id)) } - debugLog('Optimized timesheet initialized successfully') + log('Optimized timesheet initialized successfully') } catch (err) { - debugLog('Error initializing optimized timesheet:', err) + log('Error initializing optimized timesheet:', err) error.value = 'Failed to initialize timesheet' } }) @@ -1354,29 +1352,29 @@ watch( [selectedStaffId, currentDate], async ([newStaffId, newDate], [oldStaffId, oldDate]) => { if (!newStaffId || !newDate) { - debugLog('Skipping watcher - missing staffId or date') + log('Skipping watcher - missing staffId or date') return } if (newStaffId === oldStaffId && newDate === oldDate) { - debugLog('Skipping watcher - no actual change') + log('Skipping watcher - no actual change') return } if (isInitializing.value) { - debugLog('Skipping watcher - still initializing') + log('Skipping watcher - still initializing') return } if (!oldStaffId || !oldDate) { - debugLog('Skipping watcher - initial setup detected') + log('Skipping watcher - initial setup detected') return } selectedStaffId.value = newStaffId updateRoute() - debugLog('Loading data due to staff/date change:', { + log('Loading data due to staff/date change:', { newStaffId, newDate, oldStaffId, @@ -1392,16 +1390,16 @@ watch( () => route.query, (newQuery, oldQuery) => { if (isInitializing.value) { - debugLog('Skipping URL watcher - still initializing') + log('Skipping URL watcher - still initializing') return } - debugLog('URL query changed:', { old: oldQuery, new: newQuery }) + log('URL query changed:', { old: oldQuery, new: newQuery }) let hasChanges = false if (newQuery.date && newQuery.date !== currentDate.value) { - debugLog('Updating date from URL:', newQuery.date) + log('Updating date from URL:', newQuery.date) currentDate.value = newQuery.date as string hasChanges = true } @@ -1409,16 +1407,16 @@ watch( if (newQuery.staffId && newQuery.staffId !== selectedStaffId.value) { const staffExists = timesheetStore.staff.find((s: Staff) => s.id === newQuery.staffId) if (staffExists) { - debugLog('Updating staff from URL:', newQuery.staffId) + log('Updating staff from URL:', newQuery.staffId) selectedStaffId.value = newQuery.staffId as string hasChanges = true } else { - debugLog('Staff ID from URL not found:', newQuery.staffId) + log('Staff ID from URL not found:', newQuery.staffId) } } if (hasChanges) { - debugLog('Reloading data due to URL changes') + log('Reloading data due to URL changes') // ✅ Use debounced version to prevent rapid calls debouncedLoadTimesheetData() } diff --git a/frontend/src/pages/timesheets/weekly.vue b/frontend/src/pages/timesheets/weekly.vue index 6a3f0a7ef..e74c40d80 100644 --- a/frontend/src/pages/timesheets/weekly.vue +++ b/frontend/src/pages/timesheets/weekly.vue @@ -334,12 +334,14 @@ import { type PayRunListItem, } from '@/services/payroll.service' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { useTimesheetStore } from '@/stores/timesheet' import { useXeroConnection } from '@/composables/useXeroConnection' import { toast } from 'vue-sonner' import { z } from 'zod' +const log = debug('timesheet:weekly') + type WeeklyTimesheetData = z.infer<typeof schemas.WeeklyTimesheetData> type WeekDaySeed = { idx: number; dow: number; date: string } type DisplayDay = WeekDaySeed & { @@ -420,10 +422,10 @@ function loadPayRunForCurrentWeek() { payRunStatus.value = payRun.pay_run_status paymentDate.value = payRun.payment_date xeroUrl.value = payRun.xero_url - debugLog('Found pay run for current week:', payRun) + log('Found pay run for current week:', payRun) } else { resetPayRunState() - debugLog(`No pay run found for week starting ${currentWeekStart}`) + log(`No pay run found for week starting ${currentWeekStart}`) } } @@ -436,7 +438,7 @@ async function loadAllPayRuns(): Promise<PayRunListItem[]> { allPayRuns.value = result.pay_runs nextPostableWeekStart.value = result.next_postable_week_start_date nextPostableWeekEnd.value = result.next_postable_week_end_date - debugLog('Loaded all pay runs:', result.pay_runs.length, 'items') + log('Loaded all pay runs:', result.pay_runs.length, 'items') return result.pay_runs } @@ -490,7 +492,7 @@ const sortedStaffData = computed(() => { }) const displayDays = computed<DisplayDay[]>(() => { if (!weeklyData.value?.week_days || !Array.isArray(weeklyData.value.week_days)) { - debugLog('No valid weekly data available for displayDays computation') + log('No valid weekly data available for displayDays computation') return [] } @@ -604,7 +606,7 @@ function getTotalCost(): number { // Core data loader with enhanced error handling async function loadData(): Promise<void> { - console.log('[WeeklyTimesheet] loadData: starting') + log('loadData: starting') loading.value = true error.value = null postedAllToXero.value = false @@ -618,10 +620,7 @@ async function loadData(): Promise<void> { console.time('[WeeklyTimesheet] fetchWeeklyOverview API call') weeklyData.value = await fetchWeeklyOverview(startDate) console.timeEnd('[WeeklyTimesheet] fetchWeeklyOverview API call') - console.log( - '[WeeklyTimesheet] loadData: data received, staff count:', - weeklyData.value?.staff_data?.length, - ) + log('loadData: data received, staff count:', weeklyData.value?.staff_data?.length) // Validate response structure if (!weeklyData.value?.week_days || !Array.isArray(weeklyData.value.week_days)) { @@ -631,7 +630,7 @@ async function loadData(): Promise<void> { // Log successful load with weekend information const dayCount = weeklyData.value.week_days.length const expectedDays = weekendEnabled.value ? 7 : 5 - debugLog(`Weekly data loaded successfully: ${dayCount} days (expected: ${expectedDays})`) + log(`Weekly data loaded successfully: ${dayCount} days (expected: ${expectedDays})`) // Warn if day count doesn't match expectation if (dayCount !== expectedDays) { @@ -644,7 +643,7 @@ async function loadData(): Promise<void> { const errorMessage = humanizeErrorMessage(rawMessage) error.value = `Failed to load weekly timesheet data. Please try again. ${errorMessage}` - debugLog('Error while loading weekly timesheet data:', { + log('Error while loading weekly timesheet data:', { error: err, payrollMode: payrollMode.value, weekendEnabled: weekendEnabled.value, @@ -681,7 +680,7 @@ function goToPostableWeek() { } function togglePayrollMode(checked: boolean) { payrollMode.value = checked - debugLog(`Switched to ${payrollMode.value ? 'Loaded Wages' : 'Cash Wages'}`) + log(`Switched to ${payrollMode.value ? 'Loaded Wages' : 'Cash Wages'}`) } function goToDailyViewHeader(date: string) { router.push({ name: '/timesheets/daily', query: { date } }) @@ -714,7 +713,7 @@ async function handleRefreshPayRuns() { toast.success('Pay runs refreshed', { description: `Fetched ${result.fetched}, created ${result.created}, updated ${result.updated}`, }) - debugLog('Pay runs synced from Xero:', result) + log('Pay runs synced from Xero:', result) // Reload all pay runs and update current week status await loadAllPayRuns() loadPayRunForCurrentWeek() @@ -751,7 +750,7 @@ async function handlePostAllToXero() { toast.error('No staff data available', { description: 'Refresh the week before posting to Xero.', }) - debugLog('Post all aborted: no staff_data in weekly payload', weeklyData.value) + log('Post all aborted: no staff_data in weekly payload', weeklyData.value) return } @@ -762,7 +761,7 @@ async function handlePostAllToXero() { toast.error('Missing week start', { description: 'Unable to determine the selected week. Please refresh and try again.', }) - debugLog('Post all aborted: missing week start', { + log('Post all aborted: missing week start', { selectedWeekStart: selectedWeekStart.value, weeklyData: weeklyData.value, }) @@ -775,7 +774,7 @@ async function handlePostAllToXero() { if (staff.staff_id) { staffIds.push(staff.staff_id) } else { - debugLog('Skipping staff with missing identifier', staff) + log('Skipping staff with missing identifier', staff) } } @@ -784,7 +783,7 @@ async function handlePostAllToXero() { toast.error('No staff IDs available', { description: 'Unable to find staff identifiers for payroll posting. Please refresh.', }) - debugLog('Post all aborted: no staff IDs in payload', staffList) + log('Post all aborted: no staff IDs in payload', staffList) return } @@ -802,7 +801,7 @@ async function handlePostAllToXero() { currentStaffName: null, failedStaff: [], } - debugLog('Starting batch post:', event) + log('Starting batch post:', event) }, onProgress: (event) => { if (postingProgress.value) { @@ -812,7 +811,7 @@ async function handlePostAllToXero() { currentStaffName: event.staff_name, } } - debugLog('Progress:', event) + log('Progress:', event) }, onComplete: (event: PostStaffWeekCompleteEvent) => { if (!event.success) { @@ -835,17 +834,17 @@ async function handlePostAllToXero() { skippedInactiveWithEntries.push(event.staff_name) console.warn(`Skipped inactive staff with entries: ${event.staff_name}`) } else { - debugLog(`Skipped inactive staff (no entries): ${event.staff_name}`) + log(`Skipped inactive staff (no entries): ${event.staff_name}`) } } - debugLog('Complete:', event) + log('Complete:', event) }, onStreamError: (event) => { streamErrorMessage = event.message console.error('Payroll stream error:', event.message) }, onDone: (event) => { - debugLog('Batch post done:', event) + log('Batch post done:', event) }, }) diff --git a/frontend/src/plugins/axios.ts b/frontend/src/plugins/axios.ts index 5ca60e46e..292e2df3f 100644 --- a/frontend/src/plugins/axios.ts +++ b/frontend/src/plugins/axios.ts @@ -4,7 +4,9 @@ import axios from 'axios' import { loginXero } from '../composables/useXeroAuth' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('api:axios') // ETag / concurrency handling lives in api/client.ts (Zodios). This helper remains for auth (401/logout) and Xero only. @@ -33,9 +35,7 @@ axios.interceptors.response.use( } if (isAuthError) { - debugLog( - 'API request returned 401; route guard will confirm session state before redirecting', - ) + log('API request returned 401; route guard will confirm session state before redirecting') } return Promise.reject(error) diff --git a/frontend/src/router/__tests__/auth-guard.test.ts b/frontend/src/router/__tests__/auth-guard.test.ts index e9454ecce..7bd3c7e78 100644 --- a/frontend/src/router/__tests__/auth-guard.test.ts +++ b/frontend/src/router/__tests__/auth-guard.test.ts @@ -11,10 +11,6 @@ vi.mock('@/api/client', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), diff --git a/frontend/src/router/__tests__/not-found.test.ts b/frontend/src/router/__tests__/not-found.test.ts index b86a0e8ea..4419f1644 100644 --- a/frontend/src/router/__tests__/not-found.test.ts +++ b/frontend/src/router/__tests__/not-found.test.ts @@ -9,10 +9,6 @@ vi.mock('@/api/client', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), diff --git a/frontend/src/services/__tests__/sessionReplayService.test.ts b/frontend/src/services/__tests__/sessionReplayService.test.ts index ccf6cad70..9196a4d93 100644 --- a/frontend/src/services/__tests__/sessionReplayService.test.ts +++ b/frontend/src/services/__tests__/sessionReplayService.test.ts @@ -29,10 +29,6 @@ vi.mock('@rrweb/record', () => ({ record: recordMock, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - describe('sessionReplayService', () => { beforeEach(async () => { vi.clearAllMocks() diff --git a/frontend/src/services/aiProviderService.ts b/frontend/src/services/aiProviderService.ts index aefcafdaf..bf7bd8e71 100644 --- a/frontend/src/services/aiProviderService.ts +++ b/frontend/src/services/aiProviderService.ts @@ -1,8 +1,10 @@ import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { z } from 'zod' +const log = debug('ai:providers') + export type AIProvider = z.infer<typeof schemas.AIProvider> export type AIProviderCreateUpdate = z.infer<typeof schemas.AIProviderCreateUpdateRequest> @@ -22,7 +24,7 @@ export class AIProviderService { try { return await api.workflow_ai_providers_list() } catch (error) { - debugLog('Failed to fetch AI providers:', error) + log('Failed to fetch AI providers:', error) throw error } } @@ -32,7 +34,7 @@ export class AIProviderService { const created = await api.workflow_ai_providers_create(providerData) return schemas.AIProvider.parse(created) } catch (error) { - debugLog('Failed to create AI provider:', error) + log('Failed to create AI provider:', error) throw error } } @@ -47,7 +49,7 @@ export class AIProviderService { }) return schemas.AIProvider.parse(updated) } catch (error) { - debugLog(`Failed to update AI provider ${id}:`, error) + log(`Failed to update AI provider ${id}:`, error) throw error } } @@ -56,7 +58,7 @@ export class AIProviderService { try { await api.workflow_ai_providers_destroy(undefined, { params: { id } }) } catch (error) { - debugLog(`Failed to delete AI provider ${id}:`, error) + log(`Failed to delete AI provider ${id}:`, error) throw error } } @@ -65,7 +67,7 @@ export class AIProviderService { try { return await api.workflow_ai_providers_retrieve({ params: { id } }) } catch (error) { - debugLog(`Failed to get AI provider ${id}:`, error) + log(`Failed to get AI provider ${id}:`, error) throw error } } @@ -77,7 +79,7 @@ export class AIProviderService { }) return schemas.AIProvider.parse(response) } catch (error) { - debugLog(`Failed to set default AI provider ${id}:`, error) + log(`Failed to set default AI provider ${id}:`, error) throw error } } diff --git a/frontend/src/services/company-defaults.service.ts b/frontend/src/services/company-defaults.service.ts index 41a58b28c..5f4e7038f 100644 --- a/frontend/src/services/company-defaults.service.ts +++ b/frontend/src/services/company-defaults.service.ts @@ -1,8 +1,10 @@ import { api } from '@/api/client' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { z } from 'zod' +const log = debug('company:defaults') + type CompanyDefaults = z.infer<typeof schemas.CompanyDefaults> let cachedDefaults: CompanyDefaults | null = null @@ -13,12 +15,12 @@ export const CompanyDefaultsService = { return cachedDefaults } try { - debugLog('Loading company defaults from API...') + log('Loading company defaults from API...') cachedDefaults = await api.company_defaults_retrieve() - debugLog('Company defaults loaded successfully:', cachedDefaults) + log('Company defaults loaded successfully:', cachedDefaults) return cachedDefaults } catch (error) { - debugLog('Failed to load company defaults:', error) + log('Failed to load company defaults:', error) throw error } }, diff --git a/frontend/src/services/companyService.ts b/frontend/src/services/companyService.ts index 0e67a2b72..bc0345683 100644 --- a/frontend/src/services/companyService.ts +++ b/frontend/src/services/companyService.ts @@ -1,5 +1,5 @@ import { api } from '../api/client' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { z } from 'zod' import { schemas } from '../api/generated/api' @@ -9,6 +9,8 @@ export type Company = CompanySummary | CompanySearchResult export type CreateCompanyData = z.infer<typeof schemas.CompanyCreateRequest> import type { CreateCompanyResponse } from '@/constants/company-wrapper' +const log = debug('company:api') + export class CompanyService { private static instance: CompanyService @@ -27,7 +29,7 @@ export class CompanyService { company: response?.company, } } catch (error: unknown) { - debugLog('Error creating company:', error) + log('Error creating company:', error) return { success: false, error: 'Failed to create company', @@ -40,7 +42,7 @@ export class CompanyService { const response = await api.companies_all_list() return Array.isArray(response) ? response : [] } catch (error) { - debugLog('Error fetching companies:', error) + log('Error fetching companies:', error) throw new Error('Failed to load companies') } } diff --git a/frontend/src/services/costline.service.ts b/frontend/src/services/costline.service.ts index a4e0aae77..e79ef3f7b 100644 --- a/frontend/src/services/costline.service.ts +++ b/frontend/src/services/costline.service.ts @@ -1,7 +1,9 @@ import { api } from '@/api/client' import { schemas } from '@/api/generated/api' import type { z } from 'zod' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('cost:api') type CostLineRequest = z.infer<typeof schemas.CostLineCreateUpdateRequest> type PatchedCostLineRequest = z.infer<typeof schemas.PatchedCostLineCreateUpdateRequest> @@ -18,7 +20,7 @@ export const getTimesheetEntries = async ( queries: { staff_id: staffId, date }, }) } catch (error) { - debugLog('Error fetching timesheet entries:', error) + log('Error fetching timesheet entries:', error) throw error } } @@ -28,10 +30,10 @@ export const createCostLine = async ( kind: 'estimate' | 'quote' | 'actual', payload: CostLineRequest, ): Promise<CostLineResponse> => { - debugLog('COSTLINE SERVICE - Creating cost line:') - debugLog(' - Job ID:', jobId) - debugLog(' - Kind:', kind) - debugLog(' - Payload:', payload) + log('Creating cost line:') + log(' - Job ID:', jobId) + log(' - Kind:', kind) + log(' - Payload:', payload) const now = new Date().toISOString() const body: CostLineRequest = { @@ -51,7 +53,7 @@ export const createCostLine = async ( params: { job_id: String(jobId), kind }, }) - debugLog('COSTLINE SERVICE - Created cost line result:', result) + log('Created cost line result:', result) return schemas.CostLine.parse(result) } @@ -72,18 +74,18 @@ export const approveCostLine = async (id: string): Promise<CostLineResponse> => } export const deleteCostLine = async (id: string): Promise<void> => { - debugLog('SERVICE: Starting DELETE request for cost line ID:', id) - debugLog('COSTLINE SERVICE - Deleting cost line:', id) + log('Starting DELETE request for cost line ID:', id) + log('Deleting cost line:', id) try { await api.job_cost_lines_delete_destroy(undefined, { params: { cost_line_id: id }, }) - debugLog('SERVICE: DELETE request completed successfully') - debugLog('COSTLINE SERVICE - Successfully deleted cost line:', id) + log('DELETE request completed successfully') + log('Successfully deleted cost line:', id) } catch (error) { - debugLog('SERVICE: DELETE request failed:', error) - debugLog('COSTLINE SERVICE - Delete failed:', error) + log('DELETE request failed:', error) + log('Delete failed:', error) throw error } } diff --git a/frontend/src/services/job-aging-report.service.ts b/frontend/src/services/job-aging-report.service.ts index f78cf4752..6555097f9 100644 --- a/frontend/src/services/job-aging-report.service.ts +++ b/frontend/src/services/job-aging-report.service.ts @@ -1,8 +1,10 @@ import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { exportToCsv } from '@/utils/string-formatting' import { toLocalDateString } from '@/utils/dateUtils' +const log = debug('report:job-aging') + export interface JobAgingData { id: string job_number: number @@ -54,7 +56,7 @@ export class JobAgingReportService { queries, })) as JobAgingReportResponse } catch (error) { - debugLog('Error fetching job aging report:', error) + log('Error fetching job aging report:', error) throw new Error('Failed to load job aging report') } } diff --git a/frontend/src/services/job.service.ts b/frontend/src/services/job.service.ts index d36854848..5420fcc92 100644 --- a/frontend/src/services/job.service.ts +++ b/frontend/src/services/job.service.ts @@ -4,11 +4,13 @@ import axios from '../plugins/axios' import { z } from 'zod' import { type AdvancedFilters } from '../constants/advanced-filters' import { AxiosError, type AxiosProgressEvent } from 'axios' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { buildJobDeltaEnvelope, useJobDeltaQueue } from '../composables/useJobDelta' import { useAuthStore } from '../stores/auth' import { useJobETags } from '../composables/useJobETags' +const log = debug('job:api') + /** * Updates partial Job fields using PATCH endpoint with JobDelta envelope */ @@ -19,7 +21,7 @@ async function updateJobHeaderPartial( ): Promise<{ success: true; data: JobDetailResponse } | { success: false; error: string }> { try { const keys = Object.keys(payload || {}) - debugLog('[jobService.updateJobHeaderPartial] request', { jobId, keys }) + log('[updateJobHeaderPartial] request', { jobId, keys }) // normalizer to ensure checksum parity with backend (nullable fields use null, not '') const nullableKeys = new Set([ @@ -47,10 +49,10 @@ async function updateJobHeaderPartial( for (const field of keys) { beforeValues[field] = normalizeBefore(field, beforeSnapshot[field]) } - debugLog('[jobService.updateJobHeaderPartial] using company snapshot', { jobId, keys }) + log('[updateJobHeaderPartial] using company snapshot', { jobId, keys }) } else { // Fallback: get from server (should not happen in normal delta flow) - debugLog('[jobService.updateJobHeaderPartial] FALLBACK: fetching from server', { + log('[updateJobHeaderPartial] FALLBACK: fetching from server', { jobId, keys, }) @@ -141,7 +143,7 @@ async function updateJobHeaderPartial( deltaQueue.clearChangeId() - debugLog('[jobService.updateJobHeaderPartial] response', { + log('[updateJobHeaderPartial] response', { jobId, keys, ok: true, @@ -149,7 +151,7 @@ async function updateJobHeaderPartial( return { success: true, data: res } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error updating job header' - debugLog('[jobService.updateJobHeaderPartial] error', { jobId, error: msg }) + log('[updateJobHeaderPartial] error', { jobId, error: msg }) return { success: false, error: msg } } } @@ -343,22 +345,22 @@ export const jobService = { Array.isArray(filters.status) && filters.status.length > 0 ? filters.status.join(',') : '', } - console.log('Advanced search filters:', processedFilters) + log('Advanced search filters:', processedFilters) return api.job_jobs_advanced_search_retrieve({ queries: processedFilters }) }, // Update job status updateJobStatus(jobId: string, newStatus: string): Promise<JobStatusUpdateResponse> { - debugLog('[jobService.updateJobStatus] ->', { jobId, newStatus }) + log('[updateJobStatus] ->', { jobId, newStatus }) return api .job_jobs_update_status_create({ status: newStatus }, { params: { job_id: jobId } }) .then((r) => { - debugLog('[jobService.updateJobStatus] ok', { jobId, newStatus }) + log('[updateJobStatus] ok', { jobId, newStatus }) return r }) .catch((e) => { const msg = e instanceof Error ? e.message : String(e) - debugLog('[jobService.updateJobStatus] error', { jobId, newStatus, error: msg }) + log('[updateJobStatus] error', { jobId, newStatus, error: msg }) throw e }) }, @@ -375,16 +377,16 @@ export const jobService = { if (placement) payload.placement = placement if (status) payload.status = status - debugLog('[jobService.reorderJob] ->', { jobId, payload }) + log('[reorderJob] ->', { jobId, payload }) return api .job_jobs_reorder_create(payload, { params: { job_id: jobId } }) .then((r) => { - debugLog('[jobService.reorderJob] ok', { jobId }) + log('[reorderJob] ok', { jobId }) return r }) .catch((e) => { const msg = e instanceof Error ? e.message : String(e) - debugLog('[jobService.reorderJob] error', { jobId, payload, error: msg }) + log('[reorderJob] error', { jobId, payload, error: msg }) throw e }) }, diff --git a/frontend/src/services/kpi.service.ts b/frontend/src/services/kpi.service.ts index 07924201e..b94788472 100644 --- a/frontend/src/services/kpi.service.ts +++ b/frontend/src/services/kpi.service.ts @@ -1,10 +1,12 @@ import { api } from '@/api/client' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toLocalDateString } from '@/utils/dateUtils' import { formatCurrency, formatHoursDisplay } from '@/utils/string-formatting' import type { z } from 'zod' +const log = debug('report:kpi') + // Types for params - keeping as local since they're for input validation export interface KPICalendarParams { start_date: string @@ -37,7 +39,7 @@ class KPIService { }, }) } catch (error) { - debugLog('Error fetching KPI calendar data:', error) + log('Error fetching KPI calendar data:', error) throw error } } @@ -46,7 +48,7 @@ class KPIService { params: KPIAccountingParams = {}, ): Promise<KPICalendarResponse> { try { - debugLog('Fetching KPI data with params:', params) + log('Fetching KPI data with params:', params) // Use the generated Zodios company with query parameters const response = await api.accounting_reports_calendar_retrieve({ @@ -56,7 +58,7 @@ class KPIService { }, }) - debugLog('KPI data fetched successfully:', { + log('KPI data fetched successfully:', { year: response.year, month: response.month, daysCount: Object.keys(response.calendar_data).length, @@ -64,7 +66,7 @@ class KPIService { return response } catch (error) { - debugLog('Error fetching accounting KPI calendar data:', error) + log('Error fetching accounting KPI calendar data:', error) throw error } } diff --git a/frontend/src/services/notebookLmLinkService.ts b/frontend/src/services/notebookLmLinkService.ts index 340d3aa48..54d876994 100644 --- a/frontend/src/services/notebookLmLinkService.ts +++ b/frontend/src/services/notebookLmLinkService.ts @@ -1,8 +1,10 @@ import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { z } from 'zod' +const log = debug('notebooklm:links') + export type NotebookLmLink = z.infer<typeof schemas.NotebookLmLink> export type NotebookLmLinkCreateUpdate = z.infer<typeof schemas.NotebookLmLinkRequest> @@ -22,7 +24,7 @@ export class NotebookLmLinkService { try { return await api.workflow_notebook_lm_links_list() } catch (error) { - debugLog('Failed to fetch NotebookLM links:', error) + log('Failed to fetch NotebookLM links:', error) throw error } } @@ -35,7 +37,7 @@ export class NotebookLmLinkService { try { return await api.workflow_notebook_lm_links_menu_list() } catch (error) { - debugLog('Failed to fetch NotebookLM menu links:', error) + log('Failed to fetch NotebookLM menu links:', error) throw error } } @@ -45,7 +47,7 @@ export class NotebookLmLinkService { const created = await api.workflow_notebook_lm_links_create(linkData) return schemas.NotebookLmLink.parse(created) } catch (error) { - debugLog('Failed to create NotebookLM link:', error) + log('Failed to create NotebookLM link:', error) throw error } } @@ -60,7 +62,7 @@ export class NotebookLmLinkService { }) return schemas.NotebookLmLink.parse(updated) } catch (error) { - debugLog(`Failed to update NotebookLM link ${id}:`, error) + log(`Failed to update NotebookLM link ${id}:`, error) throw error } } @@ -69,7 +71,7 @@ export class NotebookLmLinkService { try { await api.workflow_notebook_lm_links_destroy(undefined, { params: { id } }) } catch (error) { - debugLog(`Failed to delete NotebookLM link ${id}:`, error) + log(`Failed to delete NotebookLM link ${id}:`, error) throw error } } @@ -78,7 +80,7 @@ export class NotebookLmLinkService { try { return await api.workflow_notebook_lm_links_retrieve({ params: { id } }) } catch (error) { - debugLog(`Failed to get NotebookLM link ${id}:`, error) + log(`Failed to get NotebookLM link ${id}:`, error) throw error } } diff --git a/frontend/src/services/payroll-reconciliation-report.service.ts b/frontend/src/services/payroll-reconciliation-report.service.ts index 955f86b89..96c3b7181 100644 --- a/frontend/src/services/payroll-reconciliation-report.service.ts +++ b/frontend/src/services/payroll-reconciliation-report.service.ts @@ -1,10 +1,12 @@ import { api } from '@/api/client' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toCsvString, downloadCsv } from '@/utils/string-formatting' import { toLocalDateString } from '@/utils/dateUtils' import type { z } from 'zod' +const log = debug('report:payroll-recon') + export type PayrollReconciliationResponse = z.infer<typeof schemas.PayrollReconciliationResponse> export async function fetchAlignedDateRange(startDate: string, endDate: string) { @@ -22,7 +24,7 @@ export async function fetchPayrollReconciliation( queries: { start_date: startDate, end_date: endDate }, }) } catch (error) { - debugLog('Error fetching payroll reconciliation:', error) + log('Error fetching payroll reconciliation:', error) throw new Error('Failed to load payroll reconciliation report') } } diff --git a/frontend/src/services/quote-chat.service.ts b/frontend/src/services/quote-chat.service.ts index 16152da92..9e93b3c06 100644 --- a/frontend/src/services/quote-chat.service.ts +++ b/frontend/src/services/quote-chat.service.ts @@ -1,9 +1,11 @@ import { api } from '@/api/client' import { schemas } from '@/api/generated/api' import type { VueChatMessage } from '@/constants/vue-chat-message' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { z } from 'zod' +const log = debug('quote:chat') + type JobQuoteChat = z.infer<typeof schemas.JobQuoteChat> type JobQuoteChatCreateRequest = z.infer<typeof schemas.JobQuoteChatCreateRequest> type JobQuoteChatUpdate = z.infer<typeof schemas.JobQuoteChatUpdate> @@ -27,7 +29,7 @@ export class QuoteChatService { try { return await api.job_jobs_quote_chat_retrieve({ params: { job_id: jobId } }) } catch (error) { - debugLog('Failed to load chat history:', error) + log('Failed to load chat history:', error) throw error } } @@ -39,7 +41,7 @@ export class QuoteChatService { try { return await api.job_jobs_quote_chat_create(message, { params: { job_id: jobId } }) } catch (error) { - debugLog('Failed to save chat message:', error) + log('Failed to save chat message:', error) throw error } } @@ -58,7 +60,7 @@ export class QuoteChatService { params: { job_id: jobId, message_id: messageId }, }) } catch (error) { - debugLog('Failed to update chat message:', error) + log('Failed to update chat message:', error) throw error } } @@ -67,7 +69,7 @@ export class QuoteChatService { try { await api.quote_chat_delete_all(undefined, { params: { job_id: jobId } }) } catch (error) { - debugLog('Failed to clear chat history:', error) + log('Failed to clear chat history:', error) throw error } } @@ -107,10 +109,10 @@ export class QuoteChatService { params: { job_id: jobId }, timeout: 120000, // 2 minutes for AI processing }) - debugLog('Assistant response:', response) + log('Assistant response:', response) return response.data } catch (error) { - debugLog('Failed to get assistant response:', error) + log('Failed to get assistant response:', error) throw error } } diff --git a/frontend/src/services/sales-pipeline-report.service.ts b/frontend/src/services/sales-pipeline-report.service.ts index d8e21e86a..088b314d8 100644 --- a/frontend/src/services/sales-pipeline-report.service.ts +++ b/frontend/src/services/sales-pipeline-report.service.ts @@ -1,7 +1,9 @@ import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import type { SalesPipelineReportParams, SalesPipelineResponse } from '@/types/sales-pipeline.types' +const log = debug('report:sales-pipeline') + export const salesPipelineReportService = { async getSalesPipelineReport(params: SalesPipelineReportParams): Promise<SalesPipelineResponse> { try { @@ -9,7 +11,7 @@ export const salesPipelineReportService = { queries: params, })) as unknown as SalesPipelineResponse } catch (error) { - debugLog('Error fetching sales pipeline report:', error) + log('Error fetching sales pipeline report:', error) throw new Error('Failed to load sales pipeline report') } }, diff --git a/frontend/src/services/searchTelemetry.service.ts b/frontend/src/services/searchTelemetry.service.ts index 353545e27..7ab434c15 100644 --- a/frontend/src/services/searchTelemetry.service.ts +++ b/frontend/src/services/searchTelemetry.service.ts @@ -1,5 +1,7 @@ import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('search:telemetry') type SearchDomain = 'company' | 'kanban' | 'stock' @@ -33,6 +35,6 @@ export async function logSearchResultClick(params: { metadata: params.metadata ?? {}, }) } catch (error) { - debugLog('Failed to log search click:', error) + log('Failed to log search click:', error) } } diff --git a/frontend/src/services/sessionReplayService.ts b/frontend/src/services/sessionReplayService.ts index ef8511e6e..e282580cb 100644 --- a/frontend/src/services/sessionReplayService.ts +++ b/frontend/src/services/sessionReplayService.ts @@ -1,12 +1,14 @@ import { record } from '@rrweb/record' import type { eventWithTime } from '@rrweb/types' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { getSessionReplayId as getCurrentSessionReplayId, setSessionReplayId, } from '@/services/sessionReplayState' +const log = debug('session:replay') + type StopRecording = () => void const FLUSH_INTERVAL_MS = 10_000 @@ -107,7 +109,7 @@ export async function startSessionReplay(): Promise<void> { stopRecording = stop flushTimer = window.setInterval(() => { flushSessionReplay().catch((error) => { - debugLog('[sessionReplay] periodic flush failed:', error) + log('periodic flush failed:', error) }) }, FLUSH_INTERVAL_MS) } @@ -141,7 +143,7 @@ export async function flushSessionReplay(): Promise<void> { sequence += 1 } catch (error) { if (isTerminalReplayUploadFailure(error)) { - debugLog('[sessionReplay] discarding terminal replay upload failure:', error) + log('discarding terminal replay upload failure:', error) if (responseStatus(error) === 409) { sequence += 1 return @@ -185,7 +187,7 @@ export async function reportFrontendError( const stack = reason instanceof Error ? reason.stack : undefined await flushSessionReplay().catch((flushError) => { - debugLog('[sessionReplay] flush before frontend error failed:', flushError) + log('flush before frontend error failed:', flushError) }) await api.session_replay_frontend_errors_create({ diff --git a/frontend/src/services/settings-schema.service.ts b/frontend/src/services/settings-schema.service.ts index c8a926a8d..ad8abb8fa 100644 --- a/frontend/src/services/settings-schema.service.ts +++ b/frontend/src/services/settings-schema.service.ts @@ -1,5 +1,5 @@ import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { getSectionIcon, getFieldIcon } from '@/utils/iconRegistry' import type { SettingsSchemaResponse, @@ -8,6 +8,8 @@ import type { } from '@/types/settings-schema.types' import { SPECIAL_HANDLERS } from '@/types/settings-schema.types' +const log = debug('settings:schema') + let cachedSchema: SettingsSchemaResponse | null = null export const SettingsSchemaService = { @@ -17,18 +19,18 @@ export const SettingsSchemaService = { */ async getSchema(): Promise<SettingsSchemaResponse> { if (cachedSchema) { - debugLog('[SettingsSchema] Returning cached schema') + log('Returning cached schema') return cachedSchema } try { - debugLog('[SettingsSchema] Fetching schema from API...') + log('Fetching schema from API...') const response = await api.company_defaults_schema_retrieve() cachedSchema = response - debugLog('[SettingsSchema] Schema loaded successfully:', cachedSchema) + log('Schema loaded successfully:', cachedSchema) return cachedSchema } catch (error) { - debugLog('[SettingsSchema] Failed to load schema:', error) + log('Failed to load schema:', error) throw error } }, @@ -78,7 +80,7 @@ export const SettingsSchemaService = { */ clearCache(): void { cachedSchema = null - debugLog('[SettingsSchema] Cache cleared') + log('Cache cleared') }, /** diff --git a/frontend/src/services/staff-performance-report.service.ts b/frontend/src/services/staff-performance-report.service.ts index ac12aaa85..3186ed124 100644 --- a/frontend/src/services/staff-performance-report.service.ts +++ b/frontend/src/services/staff-performance-report.service.ts @@ -1,5 +1,5 @@ import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { formatCurrency, formatHoursDisplay, @@ -14,6 +14,8 @@ import type { TeamAverages, } from '@/types/staff-performance.types' +const log = debug('report:staff-performance') + export class StaffPerformanceReportService { private static instance: StaffPerformanceReportService @@ -32,7 +34,7 @@ export class StaffPerformanceReportService { queries: { start_date: params.start_date, end_date: params.end_date }, })) as StaffPerformanceReportResponse } catch (error) { - debugLog('Error fetching staff performance summary:', error) + log('Error fetching staff performance summary:', error) throw new Error('Failed to load staff performance summary') } } @@ -47,7 +49,7 @@ export class StaffPerformanceReportService { queries: { start_date: params.start_date, end_date: params.end_date }, })) as StaffPerformanceReportResponse } catch (error) { - debugLog('Error fetching staff performance detail:', error) + log('Error fetching staff performance detail:', error) throw new Error('Failed to load staff performance detail') } } diff --git a/frontend/src/services/timesheet.service.ts b/frontend/src/services/timesheet.service.ts index 7e89c8660..ac0ddb81f 100644 --- a/frontend/src/services/timesheet.service.ts +++ b/frontend/src/services/timesheet.service.ts @@ -1,11 +1,13 @@ import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { toLocalDateString } from '../utils/dateUtils' import { formatHoursDisplay } from '@/utils/string-formatting' import { requiredNumber } from '@/utils/requiredNumber' import type { z } from 'zod' +const log = debug('timesheet:api') + type Staff = z.infer<typeof schemas.ModernStaff> type Job = z.infer<typeof schemas.ModernTimesheetJob> type WeeklyOverviewData = z.infer<typeof schemas.WeeklyTimesheetData> @@ -25,7 +27,7 @@ export class TimesheetService { wageRate: requiredNumber(staff.wageRate, `wageRate for staff ${staff.id}`), })) - debugLog('Staff normalized for timesheet:', { + log('Staff normalized for timesheet:', { count: normalizedStaff.length, sample: normalizedStaff[0], keys: normalizedStaff[0] ? Object.keys(normalizedStaff[0]) : [], @@ -33,7 +35,7 @@ export class TimesheetService { return normalizedStaff } catch (error) { - debugLog('Error fetching staff:', error) + log('Error fetching staff:', error) throw error } } @@ -43,7 +45,7 @@ export class TimesheetService { const jobsResponse = await api.timesheets_jobs_retrieve() return jobsResponse.jobs || [] } catch (error) { - debugLog('Error fetching jobs:', error) + log('Error fetching jobs:', error) throw error } } @@ -55,7 +57,7 @@ export class TimesheetService { }) return schemas.WeeklyTimesheetData.parse(response) } catch (error) { - debugLog('Error fetching weekly overview:', error) + log('Error fetching weekly overview:', error) throw error } } diff --git a/frontend/src/services/wip-report.service.ts b/frontend/src/services/wip-report.service.ts index df91d3cba..3cf269256 100644 --- a/frontend/src/services/wip-report.service.ts +++ b/frontend/src/services/wip-report.service.ts @@ -1,8 +1,10 @@ import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { exportToCsv } from '@/utils/string-formatting' import { toLocalDateString } from '@/utils/dateUtils' +const log = debug('report:wip') + export interface WIPJobData { job_number: number name: string @@ -66,7 +68,7 @@ export class WIPReportService { } return (await api.accounting_reports_wip_retrieve({ queries })) as WIPReportResponse } catch (error) { - debugLog('Error fetching WIP report:', error) + log('Error fetching WIP report:', error) throw new Error('Failed to load WIP report') } } diff --git a/frontend/src/services/workshop-schedule.service.ts b/frontend/src/services/workshop-schedule.service.ts index 10c4f3f25..0797b0e11 100644 --- a/frontend/src/services/workshop-schedule.service.ts +++ b/frontend/src/services/workshop-schedule.service.ts @@ -1,7 +1,9 @@ import { z } from 'zod' import { api } from '@/api/client' import { schemas } from '@/api/generated/api' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('workshop:schedule') export type WorkshopScheduleResponse = z.infer<typeof schemas.WorkshopScheduleResponse> export type ScheduledJob = z.infer<typeof schemas.ScheduledJob> @@ -13,31 +15,31 @@ export type AssignJobResponse = z.infer<typeof schemas.AssignJobResponse> export const workshopScheduleService = { async getSchedule(dayHorizon?: number): Promise<WorkshopScheduleResponse> { - debugLog('[workshopScheduleService.getSchedule] ->', { dayHorizon }) + log('getSchedule ->', { dayHorizon }) const queries = dayHorizon !== undefined ? { day_horizon: dayHorizon } : {} return api.operations_workshop_schedule_retrieve({ queries }) }, async recalculate(dayHorizon?: number): Promise<WorkshopScheduleResponse> { - debugLog('[workshopScheduleService.recalculate] ->', { dayHorizon }) + log('recalculate ->', { dayHorizon }) const queries = dayHorizon !== undefined ? { day_horizon: dayHorizon } : {} return api.operations_workshop_schedule_recalculate_create(undefined, { queries }) }, async listWorkshopStaff(): Promise<Staff[]> { - debugLog('[workshopScheduleService.listWorkshopStaff] ->') + log('listWorkshopStaff ->') const all = await api.accounts_staff_list() const today = new Date().toISOString().slice(0, 10) return all.filter((s) => s.is_workshop_staff === true && (!s.date_left || s.date_left > today)) }, async assignStaff(jobId: string, staffId: string): Promise<AssignJobResponse> { - debugLog('[workshopScheduleService.assignStaff] ->', { jobId, staffId }) + log('assignStaff ->', { jobId, staffId }) return api.job_job_assignment_create({ staff_id: staffId }, { params: { job_id: jobId } }) }, async unassignStaff(jobId: string, staffId: string): Promise<AssignJobResponse> { - debugLog('[workshopScheduleService.unassignStaff] ->', { jobId, staffId }) + log('unassignStaff ->', { jobId, staffId }) return api.job_job_assignment_destroy(undefined, { params: { job_id: jobId, staff_id: staffId }, }) diff --git a/frontend/src/stores/__tests__/auth.test.ts b/frontend/src/stores/__tests__/auth.test.ts index 46560c13e..60e8c74ea 100644 --- a/frontend/src/stores/__tests__/auth.test.ts +++ b/frontend/src/stores/__tests__/auth.test.ts @@ -11,10 +11,6 @@ vi.mock('@/api/client', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - const user = { id: '11111111-1111-4111-8111-111111111111', username: 'cindy@example.com', diff --git a/frontend/src/stores/__tests__/jobs.test.ts b/frontend/src/stores/__tests__/jobs.test.ts index 63e50bbae..2369cf7c1 100644 --- a/frontend/src/stores/__tests__/jobs.test.ts +++ b/frontend/src/stores/__tests__/jobs.test.ts @@ -16,10 +16,6 @@ vi.mock('@/composables/useDataFreshness', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - function buildKanbanJob(overrides: Partial<Record<string, unknown>> = {}) { return { id: overrides.id ?? 'job-1', diff --git a/frontend/src/stores/__tests__/timesheet.test.ts b/frontend/src/stores/__tests__/timesheet.test.ts index bdb446c2d..aa9d27e3e 100644 --- a/frontend/src/stores/__tests__/timesheet.test.ts +++ b/frontend/src/stores/__tests__/timesheet.test.ts @@ -18,10 +18,6 @@ vi.mock('vue-sonner', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - describe('timesheet store', () => { beforeEach(() => { setActivePinia(createPinia()) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e1e1bb252..f4c589f54 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -2,9 +2,11 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import type { z } from 'zod' +const log = debug('auth:store') + type User = z.infer<typeof schemas.UserProfile> type LoginCredentials = z.infer<typeof schemas.CustomTokenObtainPairRequest> export type SessionCheckStatus = 'authenticated' | 'unauthenticated' | 'unknown' @@ -74,7 +76,7 @@ export const useAuthStore = defineStore('auth', () => { } sessionCheckError.value = err - debugLog('Session check failed without auth rejection:', err) + log('Session check failed without auth rejection:', err) return 'unknown' } finally { hasCheckedSession.value = true @@ -143,7 +145,7 @@ export const useAuthStore = defineStore('auth', () => { } setError(errorMessage) - debugLog('Login error:', err) + log('Login error:', err) return false } finally { setLoading(false) @@ -154,7 +156,7 @@ export const useAuthStore = defineStore('auth', () => { try { await api.accounts_logout_create(undefined) } catch (err) { - debugLog('Backend logout failed:', err) + log('Backend logout failed:', err) } finally { user.value = null clearError() diff --git a/frontend/src/stores/costing.ts b/frontend/src/stores/costing.ts index 6d7a72a64..2e2de438a 100644 --- a/frontend/src/stores/costing.ts +++ b/frontend/src/stores/costing.ts @@ -2,9 +2,11 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import type { z } from 'zod' +const log = debug('cost:store') + type CostSet = z.infer<typeof schemas.CostSet> type CostLine = z.infer<typeof schemas.CostLine> type CostLineMeta = { @@ -57,7 +59,7 @@ export const useCostingStore = defineStore('costing', () => { const targetKind = kind || currentKind.value try { - debugLog(`Loading costing data for job ${jobId}, kind: ${targetKind}`) + log(`Loading costing data for job ${jobId}, kind: ${targetKind}`) const data = await api.job_jobs_cost_sets_retrieve({ params: { @@ -69,10 +71,10 @@ export const useCostingStore = defineStore('costing', () => { currentKind.value = targetKind - debugLog('Costing data loaded successfully') + log('Costing data loaded successfully') } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load costing data' - debugLog(`Error loading costing data for ${targetKind}:`, err) + log(`Error loading costing data for ${targetKind}:`, err) if (error.value) { throw err @@ -90,7 +92,7 @@ export const useCostingStore = defineStore('costing', () => { try { await load(jobId, kind) } catch { - debugLog(`Failed to load ${kind} data, keeping current data`) + log(`Failed to load ${kind} data, keeping current data`) } } } diff --git a/frontend/src/stores/deliveryReceiptStore.ts b/frontend/src/stores/deliveryReceiptStore.ts index 4872c401a..301e93a41 100644 --- a/frontend/src/stores/deliveryReceiptStore.ts +++ b/frontend/src/stores/deliveryReceiptStore.ts @@ -3,11 +3,13 @@ import { ref } from 'vue' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' import { useCompanyDefaultsStore } from '@/stores/companyDefaults' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toast } from 'vue-sonner' import { emitPoConcurrencyRetry } from '@/composables/usePoConcurrencyEvents' import type { z } from 'zod' +const log = debug('po:delivery-receipt') + type Job = z.infer<typeof schemas.JobForPurchasing> type PurchaseOrderDetail = z.infer<typeof schemas.PurchaseOrderDetail> type DeliveryReceiptRequest = z.infer<typeof schemas.DeliveryReceiptRequest> @@ -52,7 +54,7 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { } catch (err) { const errorMessage = handleApiError(err, `Failed to fetch purchase order ${id}`) error.value = errorMessage - debugLog(`Error fetching purchase order ${id}:`, err) + log(`Error fetching purchase order ${id}:`, err) throw new Error(errorMessage) } finally { loading.value = false @@ -90,7 +92,7 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { } catch (err) { const errorMessage = handleApiError(err, 'Failed to fetch jobs') error.value = errorMessage - debugLog('Error fetching jobs:', err) + log('Error fetching jobs:', err) throw new Error(errorMessage) } } @@ -110,7 +112,7 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { loading.value = true error.value = null - debugLog(`Submitting delivery receipt for PO: ${purchaseOrderId}`, receiptData) + log(`Submitting delivery receipt for PO: ${purchaseOrderId}`, receiptData) try { const payload: DeliveryReceiptRequest = { purchase_order_id: purchaseOrderId, @@ -124,7 +126,7 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { `Failed to submit delivery receipt for PO ${purchaseOrderId}`, ) error.value = errorMessage - debugLog(`Error submitting delivery receipt for PO ${purchaseOrderId}:`, err) + log(`Error submitting delivery receipt for PO ${purchaseOrderId}:`, err) // Handle concurrency conflicts if ( @@ -134,7 +136,7 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { errorMessage.includes('updated elsewhere') || errorMessage.includes('Data reloaded') ) { - debugLog('Concurrency conflict detected in delivery receipt store') + log('Concurrency conflict detected in delivery receipt store') // Show persistent user notification with retry option IMMEDIATELY toast.error('This purchase order was updated elsewhere. Data reloaded.', { @@ -150,9 +152,9 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { // Immediately reload data so user can see what changed try { await reloadPoOnConflict(purchaseOrderId) - debugLog('Reloaded PO data after concurrency conflict') + log('Reloaded PO data after concurrency conflict') } catch (reloadError) { - debugLog('Failed to reload PO data:', reloadError) + log('Failed to reload PO data:', reloadError) } // Create and throw ConcurrencyError @@ -174,15 +176,15 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { * @param poId - The purchase order ID to reload */ async function reloadPoOnConflict(poId: string): Promise<void> { - debugLog('Delivery Receipt Store - reloadPoOnConflict called:', { poId }) + log('reloadPoOnConflict called:', { poId }) try { // Fetch full PO detail (captures new ETag via interceptor) await fetchPurchaseOrder(poId) - debugLog('Delivery Receipt Store - reloadPoOnConflict success:', { poId }) + log('reloadPoOnConflict success:', { poId }) } catch (error) { - debugLog('Delivery Receipt Store - reloadPoOnConflict error:', { poId, error }) + log('reloadPoOnConflict error:', { poId, error }) throw error } } @@ -198,11 +200,11 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { error.value = null try { - debugLog(`Fetching existing allocations for PO: ${purchaseOrderId}`) + log(`Fetching existing allocations for PO: ${purchaseOrderId}`) const response = await api.purchasing_purchase_orders_allocations_retrieve({ params: { po_id: purchaseOrderId }, }) - debugLog('Existing allocations response:', response) + log('Existing allocations response:', response) return response } catch (err) { const errorMessage = handleApiError( @@ -210,7 +212,7 @@ export const useDeliveryReceiptStore = defineStore('deliveryReceipts', () => { `Failed to fetch existing allocations for PO ${purchaseOrderId}`, ) error.value = errorMessage - debugLog(`Error fetching existing allocations for PO ${purchaseOrderId}:`, err) + log(`Error fetching existing allocations for PO ${purchaseOrderId}:`, err) throw new Error(errorMessage) } finally { loading.value = false diff --git a/frontend/src/stores/feature-flags.ts b/frontend/src/stores/feature-flags.ts index 201a4691e..a8b44671e 100644 --- a/frontend/src/stores/feature-flags.ts +++ b/frontend/src/stores/feature-flags.ts @@ -1,5 +1,7 @@ import { defineStore } from 'pinia' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('app:feature-flags') export const useFeatureFlags = defineStore('featureFlags', { state: () => ({ @@ -8,7 +10,7 @@ export const useFeatureFlags = defineStore('featureFlags', { getters: { isCostingApiEnabled: (state) => { - debugLog('Feature flag for costing API:', state.useCostingApi) + log('Feature flag for costing API:', state.useCostingApi) return true }, }, diff --git a/frontend/src/stores/jobs.ts b/frontend/src/stores/jobs.ts index d8d4e686d..d75b94a0f 100644 --- a/frontend/src/stores/jobs.ts +++ b/frontend/src/stores/jobs.ts @@ -1,13 +1,15 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { schemas } from '../api/generated/api' -import { debugLog } from '../utils/debug' +import debug from 'debug' import type { z } from 'zod' import { api } from '../api/client' import { jobService } from '../services/job.service' import { dataFreshness } from '../composables/useDataFreshness' import { createDatasetCache } from '../composables/useDatasetCache' +const log = debug('job:store') + type Job = z.infer<typeof schemas.Job> type JobDetail = z.infer<typeof schemas.JobDetailResponse>['data'] @@ -80,13 +82,13 @@ export const useJobsStore = defineStore('jobs', () => { const setDetailedJob = (jobDetail: JobDetail): void => { if (!jobDetail) { - debugLog('Store - setDetailedJob called with null/undefined jobDetail') + log('setDetailedJob called with null/undefined jobDetail') return } // JobDetail has structure: {job: {...}, events: [...], company_defaults: {...}} if (!jobDetail.job || !jobDetail.job.id || typeof jobDetail.job.id !== 'string') { - debugLog('Store - setDetailedJob called with invalid jobDetail structure:', { + log('setDetailedJob called with invalid jobDetail structure:', { hasJob: !!jobDetail.job, jobId: jobDetail.job?.id, jobIdType: typeof jobDetail.job?.id, @@ -115,11 +117,11 @@ export const useJobsStore = defineStore('jobs', () => { } if (existingJob && JSON.stringify(existingJob) === JSON.stringify(mergedJobDetail)) { - debugLog('Store - JobDetail data identical, skipping update to prevent loop:', jobId) + log('JobDetail data identical, skipping update to prevent loop:', jobId) return } - debugLog('Store - setDetailedJob called:', { + log('setDetailedJob called:', { jobId, jobStatus: mergedJobDetail.job.job_status, hasEvents: Array.isArray(mergedJobDetail.events), @@ -132,7 +134,7 @@ export const useJobsStore = defineStore('jobs', () => { [jobId]: mergedJobDetail, } - debugLog('Store - Job updated successfully:', { + log('Job updated successfully:', { jobId, newStatus: detailedJobs.value[jobId]?.job?.job_status, }) @@ -141,7 +143,7 @@ export const useJobsStore = defineStore('jobs', () => { setHeader(jobToHeader(mergedJobDetail.job)) if (kanbanJobs.value[jobId]) { - debugLog('Store - Also updating kanban job') + log('Also updating kanban job') updateKanbanJobFromDetailed(mergedJobDetail) } } @@ -425,11 +427,11 @@ export const useJobsStore = defineStore('jobs', () => { params: { job_id: jobId }, }) - debugLog('Store - loadBasicInfo success:', { jobId, data }) + log('loadBasicInfo success:', { jobId, data }) setBasicInfo(jobId, data) return data } catch (error) { - debugLog('Store - loadBasicInfo error:', error) + log('loadBasicInfo error:', error) throw error } } @@ -537,7 +539,7 @@ export const useJobsStore = defineStore('jobs', () => { throw new Error('Job not found or invalid response format') } catch (error) { - debugLog('Store - fetchJob error:', error) + log('fetchJob error:', error) throw error } } @@ -548,7 +550,7 @@ export const useJobsStore = defineStore('jobs', () => { * @param jobId - The job ID to reload */ async function reloadJobOnConflict(jobId: string): Promise<void> { - debugLog('Store - reloadJobOnConflict called:', { jobId }) + log('reloadJobOnConflict called:', { jobId }) try { // 1) Fetch full job detail (captures new ETag via interceptor and updates detailed + header) @@ -561,13 +563,13 @@ export const useJobsStore = defineStore('jobs', () => { params: { job_id: jobId }, }) setHeader(headerResponse) - debugLog('Store - header refreshed after conflict:', { + log('header refreshed after conflict:', { jobId, name: headerResponse.name, status: headerResponse.status, }) } catch (headerErr) { - debugLog('Store - header refresh failed after conflict (will rely on full job):', { + log('header refresh failed after conflict (will rely on full job):', { jobId, error: headerErr, }) @@ -579,12 +581,12 @@ export const useJobsStore = defineStore('jobs', () => { params: { job_id: jobId }, }) setBasicInfo(jobId, bi) - debugLog('Store - basic info refreshed after conflict:', { + log('basic info refreshed after conflict:', { jobId, hasDescription: !!bi.description, }) } catch (biErr) { - debugLog('Store - basic info refresh failed after conflict:', { jobId, error: biErr }) + log('basic info refresh failed after conflict:', { jobId, error: biErr }) } // 4) Mark conflict reload timestamp so components can force local sync @@ -593,9 +595,9 @@ export const useJobsStore = defineStore('jobs', () => { [jobId]: Date.now(), } - debugLog('Store - reloadJobOnConflict success:', { jobId }) + log('reloadJobOnConflict success:', { jobId }) } catch (error) { - debugLog('Store - reloadJobOnConflict error:', { jobId, error }) + log('reloadJobOnConflict error:', { jobId, error }) throw error } } diff --git a/frontend/src/stores/purchaseOrderStore.ts b/frontend/src/stores/purchaseOrderStore.ts index 16cb3cb92..49b7f2182 100644 --- a/frontend/src/stores/purchaseOrderStore.ts +++ b/frontend/src/stores/purchaseOrderStore.ts @@ -1,11 +1,13 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { api } from '../api/client' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { schemas } from '../api/generated/api' import axios from '@/plugins/axios' import type { z } from 'zod' +const log = debug('po:store') + // Type definitions type PurchaseOrder = z.infer<typeof schemas.PurchaseOrderList> type PurchaseOrderCreate = z.infer<typeof schemas.PurchaseOrderCreateRequest> @@ -28,7 +30,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { orders.value = response } catch (err) { error.value = 'Failed to fetch purchase orders' - debugLog('Error fetching purchase orders:', err) + log('Error fetching purchase orders:', err) throw err } finally { loading.value = false @@ -43,7 +45,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { return response } catch (err) { error.value = 'Failed to create purchase order' - debugLog('Error creating purchase order:', err) + log('Error creating purchase order:', err) throw err } } @@ -62,7 +64,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { return response } catch (err) { error.value = `Failed to fetch purchase order ${id}` - debugLog(`Error fetching purchase order ${id}:`, err) + log(`Error fetching purchase order ${id}:`, err) throw err } } @@ -81,7 +83,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { return response } catch (err) { error.value = `Failed to update purchase order ${id}` - debugLog(`Error updating purchase order ${id}:`, err) + log(`Error updating purchase order ${id}:`, err) throw err } } @@ -92,7 +94,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { * @param poId - The purchase order ID to reload */ async function reloadPoOnConflict(poId: string): Promise<void> { - debugLog('PO Store - reloadPoOnConflict called:', { poId }) + log('reloadPoOnConflict called:', { poId }) try { // Fetch full PO detail (captures new ETag via interceptor) @@ -104,9 +106,9 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { [poId]: Date.now(), } - debugLog('PO Store - reloadPoOnConflict success:', { poId }) + log('reloadPoOnConflict success:', { poId }) } catch (error) { - debugLog('PO Store - reloadPoOnConflict error:', { poId, error }) + log('reloadPoOnConflict error:', { poId, error }) throw error } } @@ -124,7 +126,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { return response.data } catch (err) { - debugLog(`Error fetching PDF for purchase order ${id}:`, err) + log(`Error fetching PDF for purchase order ${id}:`, err) throw err } } @@ -144,7 +146,7 @@ export const usePurchaseOrderStore = defineStore('purchaseOrders', () => { }) return response } catch (err) { - debugLog(`Error emailing purchase order ${id}:`, err) + log(`Error emailing purchase order ${id}:`, err) throw err } } diff --git a/frontend/src/stores/timesheet.ts b/frontend/src/stores/timesheet.ts index 28f135543..323b8c96f 100644 --- a/frontend/src/stores/timesheet.ts +++ b/frontend/src/stores/timesheet.ts @@ -2,13 +2,16 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { schemas } from '@/api/generated/api' import { api } from '@/api/client' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toLocalDateString } from '@/utils/dateUtils' import type { z } from 'zod' import { toast } from 'vue-sonner' import { useCompanyDefaultsStore } from '@/stores/companyDefaults' import { validateFields } from '@/utils/contractValidation' import { formatHoursDisplay } from '@/utils/string-formatting' + +const log = debug('timesheet:store') + type CostLineMeta = Record<string, unknown> & { date?: string staff_id?: string @@ -133,7 +136,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { async function load(targetJobId: string, targetKind: 'estimate' | 'quote' | 'actual' = 'actual') { if (!targetJobId) { - debugLog('Load called without jobId') + log('Load called without jobId') return } @@ -141,7 +144,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { error.value = null try { - debugLog(`Loading cost lines for job ${targetJobId}, kind: ${targetKind}`) + log(`Loading cost lines for job ${targetJobId}, kind: ${targetKind}`) const costSet = await api.job_jobs_cost_sets_retrieve({ params: { @@ -154,11 +157,11 @@ export const useTimesheetStore = defineStore('timesheet', () => { jobId.value = targetJobId kind.value = targetKind - debugLog(`Loaded ${costSet.cost_lines.length} cost lines successfully`) + log(`Loaded ${costSet.cost_lines.length} cost lines successfully`) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load cost lines' error.value = errorMessage - debugLog('Error loading cost lines:', err) + log('Error loading cost lines:', err) throw err } finally { loading.value = false @@ -183,12 +186,12 @@ export const useTimesheetStore = defineStore('timesheet', () => { lines.value.push(newLine) - debugLog('Cost line added successfully:', newLine.id) + log('Cost line added successfully:', newLine.id) return newLine } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to add cost line' error.value = errorMessage - debugLog('Error adding cost line:', err) + log('Error adding cost line:', err) throw err } finally { loading.value = false @@ -212,12 +215,12 @@ export const useTimesheetStore = defineStore('timesheet', () => { Object.assign(lines.value[lineIndex], updatedLine) - debugLog('Cost line updated successfully:', id) + log('Cost line updated successfully:', id) return updatedLine } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to update cost line' error.value = errorMessage - debugLog('Error updating cost line:', err) + log('Error updating cost line:', err) throw err } finally { loading.value = false @@ -233,11 +236,11 @@ export const useTimesheetStore = defineStore('timesheet', () => { lines.value = lines.value.filter((line) => line.id !== id) - debugLog('Cost line deleted successfully:', id) + log('Cost line deleted successfully:', id) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to delete cost line' error.value = errorMessage - debugLog('Error deleting cost line:', err) + log('Error deleting cost line:', err) throw err } finally { loading.value = false @@ -277,7 +280,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { staff.value = response.staff } catch (err) { error.value = 'Failed to load staff members' - debugLog('Error loading staff:', err) + log('Error loading staff:', err) } finally { loading.value = false } @@ -311,7 +314,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { try { const items = await api.workflow_xero_pay_items_list() xeroPayItems.value = items - debugLog('Loaded Xero pay items:', items.length) + log('Loaded Xero pay items:', items.length) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load Xero pay items' toast.error(message) @@ -344,7 +347,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { await load(jobId.value, 'actual') } catch (err) { error.value = 'Failed to load time lines' - debugLog('Error loading time lines:', err) + log('Error loading time lines:', err) } finally { loading.value = false } @@ -357,20 +360,20 @@ export const useTimesheetStore = defineStore('timesheet', () => { try { // Use current date if no start date provided const weekStart = startDate || toLocalDateString() - debugLog('Loading weekly overview for:', weekStart) + log('Loading weekly overview for:', weekStart) currentWeekData.value = await api.timesheets_weekly_retrieve({ queries: { start_date: weekStart }, }) - debugLog('Weekly overview loaded successfully:', { + log('Weekly overview loaded successfully:', { staffCount: currentWeekData.value?.staff_data?.length || 0, startDate: currentWeekData.value?.start_date, endDate: currentWeekData.value?.end_date, }) } catch (err) { error.value = 'Failed to load weekly overview' - debugLog('Error loading weekly overview:', err) + log('Error loading weekly overview:', err) throw err } finally { loading.value = false @@ -398,7 +401,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { error.value = null try { - debugLog('Creating new time entry:', entryData) + log('Creating new time entry:', entryData) // Use generated API to create time entry const accountingDate = selectedDate.value || toLocalDateString() @@ -442,14 +445,14 @@ export const useTimesheetStore = defineStore('timesheet', () => { lines.value.push(newCostLine) } - debugLog('Time entry created successfully:', newCostLine) + log('Time entry created successfully:', newCostLine) // Reload cost lines to get updated data await loadTimeLines() } } catch (err) { error.value = 'Failed to create time entry' - debugLog('Error creating time entry:', err) + log('Error creating time entry:', err) throw err } finally { loading.value = false @@ -476,7 +479,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { error.value = null try { - debugLog('Updating time entry:', entryId, updates) + log('Updating time entry:', entryId, updates) // Use generated API to update time entry const updatePayload: PatchedCostLineCreateUpdate = {} @@ -518,7 +521,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { lines.value[index] = updatedCostLine } - debugLog('Time entry updated successfully:', updatedCostLine) + log('Time entry updated successfully:', updatedCostLine) // Reload cost lines to get updated data await loadTimeLines() @@ -527,7 +530,7 @@ export const useTimesheetStore = defineStore('timesheet', () => { } } catch (err) { error.value = 'Failed to update time entry' - debugLog('Error updating time entry:', err) + log('Error updating time entry:', err) throw err } finally { loading.value = false @@ -568,9 +571,9 @@ export const useTimesheetStore = defineStore('timesheet', () => { const existingIndex = attachedJobs.value.findIndex((j) => j.id === job.id) if (existingIndex === -1) { attachedJobs.value.push(job) - debugLog('Job attached to timesheet:', job.name) + log('Job attached to timesheet:', job.name) } else { - debugLog('Job already attached:', job.name) + log('Job already attached:', job.name) } } @@ -579,9 +582,9 @@ export const useTimesheetStore = defineStore('timesheet', () => { if (index !== -1) { const removedJob = attachedJobs.value[index] attachedJobs.value.splice(index, 1) - debugLog('Job removed from timesheet:', removedJob.name) + log('Job removed from timesheet:', removedJob.name) } else { - debugLog('Job not found in attached jobs:', jobId) + log('Job not found in attached jobs:', jobId) } } diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts index 366e3a8ae..cc86edc20 100644 --- a/frontend/src/utils/dateUtils.ts +++ b/frontend/src/utils/dateUtils.ts @@ -1,6 +1,8 @@ import type { DateValue } from '@internationalized/date' import { CalendarDate } from '@internationalized/date' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('app:date') /** * Formats a Date object as YYYY-MM-DD string in local timezone. @@ -46,7 +48,7 @@ export function toDateValue(date: Date | string | null | undefined): DateValue | return new CalendarDate(year, month, day) } catch (error) { - debugLog('Failed to convert date to DateValue:', error) + log('Failed to convert date to DateValue:', error) return undefined } } @@ -59,7 +61,7 @@ export function fromDateValue(dateValue: DateValue | null | undefined): Date | n try { return new Date(dateValue.year, dateValue.month - 1, dateValue.day) } catch (error) { - debugLog('Failed to convert DateValue to Date:', error) + log('Failed to convert DateValue to Date:', error) return null } } diff --git a/frontend/src/utils/debug.ts b/frontend/src/utils/debug.ts deleted file mode 100644 index c3ed05deb..000000000 --- a/frontend/src/utils/debug.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Debug logging - enable via localStorage.setItem('debug', 'true') -// `import.meta.env` is injected by Vite; when this module is loaded outside Vite -// (e.g. Playwright tests importing a sibling utility), fall back to `off`. -const isDevelopment = (() => { - try { - return import.meta.env?.MODE === 'development' - } catch { - return false - } -})() - -function isEnabled(): boolean { - if (isDevelopment) return true - try { - return localStorage.getItem('debug') === 'true' - } catch { - return false - } -} - -export function debugLog(...args: unknown[]): void { - if (isEnabled()) { - console.log('[DEBUG]', ...args) - } -} - -export const isDebugEnabled = isEnabled diff --git a/frontend/src/utils/error-handler.ts b/frontend/src/utils/error-handler.ts index 193229b80..9ec08d4ce 100644 --- a/frontend/src/utils/error-handler.ts +++ b/frontend/src/utils/error-handler.ts @@ -1,8 +1,10 @@ import { AxiosError } from 'axios' -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('app:error-handler') export function extractErrorMessage(error: unknown): string { - debugLog('Extracting error message from:', error) + log('Extracting error message from:', error) if (error && typeof error === 'object' && 'isAxiosError' in error) { const axiosError = error as AxiosError @@ -132,10 +134,10 @@ export function logError( additionalData?: Record<string, unknown>, ): void { console.group(`Error in ${context}`) - debugLog('Original error:', error) - debugLog('Extracted message:', extractErrorMessage(error)) + log('Original error:', error) + log('Extracted message:', extractErrorMessage(error)) if (additionalData) { - debugLog('Additional data:', additionalData) + log('Additional data:', additionalData) } console.groupEnd() } diff --git a/frontend/src/utils/safetyUtils.ts b/frontend/src/utils/safetyUtils.ts index a10071c31..da7067196 100644 --- a/frontend/src/utils/safetyUtils.ts +++ b/frontend/src/utils/safetyUtils.ts @@ -1,4 +1,7 @@ -import { debugLog } from '@/utils/debug' +import debug from 'debug' + +const log = debug('app:safety') + export function createSafeDate(dateValue: string | undefined): Date { if (!dateValue) { return new Date() @@ -8,12 +11,12 @@ export function createSafeDate(dateValue: string | undefined): Date { const date = new Date(dateValue) if (isNaN(date.getTime())) { - debugLog('Invalid date value:', dateValue) + log('Invalid date value:', dateValue) return new Date() } return date } catch (error) { - debugLog('Error creating date:', error) + log('Error creating date:', error) return new Date() } } diff --git a/frontend/src/views/AdminMonthEnd.vue b/frontend/src/views/AdminMonthEnd.vue index 601a9fa10..7e59277cb 100644 --- a/frontend/src/views/AdminMonthEnd.vue +++ b/frontend/src/views/AdminMonthEnd.vue @@ -254,9 +254,11 @@ import type { z } from 'zod' import { toast } from 'vue-sonner' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import MonthEndSummary from '@/components/admin/MonthEndSummary.vue' -import { debugLog } from '../utils/debug' +import debug from 'debug' import { formatHoursDisplay } from '@/utils/string-formatting' +const log = debug('admin:month-end') + interface MonthTab { key: string label: string @@ -444,7 +446,7 @@ async function confirmRun() { loadMonthData(tab) } } catch (err) { - debugLog('Error trying to run month-end: ', err) + log('Error trying to run month-end: ', err) toast.error('Failed to run Month-End') } finally { loading.value = false diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 37899f293..13dbc49f5 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -51,7 +51,9 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' import { useAppLayout } from '@/composables/useAppLayout' import { adminPages, adminExternalLinks } from '@/config/adminPages' -import { debugLog } from '../utils/debug' +import debug from 'debug' + +const log = debug('admin:view') const { userInfo } = useAppLayout() const isStaff = computed(() => Boolean(userInfo.value?.is_office_staff)) @@ -65,7 +67,7 @@ const username = computed(() => { ) }) -debugLog('UserInfo in AdminView:', userInfo.value) +log('UserInfo in AdminView:', userInfo.value) const today = new Date().toLocaleDateString('en-NZ', { weekday: 'long', diff --git a/frontend/src/views/WorkshopKanbanView.vue b/frontend/src/views/WorkshopKanbanView.vue index 3273705e3..8b73bb86d 100644 --- a/frontend/src/views/WorkshopKanbanView.vue +++ b/frontend/src/views/WorkshopKanbanView.vue @@ -9,9 +9,11 @@ import AppLayout from '@/components/AppLayout.vue' import router from '@/router' import { UserRound, NotebookText, Briefcase } from 'lucide-vue-next' import StaffAvatar from '@/components/StaffAvatar.vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toast } from 'vue-sonner' +const log = debug('workshop:kanban') + type Job = z.infer<typeof schemas.WorkshopJob> const jobs = ref<Job[]>([]) const loading = ref(false) @@ -34,7 +36,7 @@ onMounted(async () => { try { jobs.value = await api.job_jobs_workshop_list() } catch (error) { - debugLog('Error when trying to load jobs for workshop kanban: ', error) + log('Error when trying to load jobs for workshop kanban: ', error) toast('Failed to load jobs. Please try again and contact Corrin if the problem persists.') } finally { loading.value = false diff --git a/frontend/src/views/WorkshopView.vue b/frontend/src/views/WorkshopView.vue index 9269c6094..4072ea1a7 100644 --- a/frontend/src/views/WorkshopView.vue +++ b/frontend/src/views/WorkshopView.vue @@ -9,9 +9,11 @@ import AppLayout from '@/components/AppLayout.vue' import router from '@/router' import { UserRound, NotebookText, Briefcase } from 'lucide-vue-next' import StaffAvatar from '@/components/StaffAvatar.vue' -import { debugLog } from '@/utils/debug' +import debug from 'debug' import { toast } from 'vue-sonner' +const log = debug('workshop:view') + type Job = z.infer<typeof schemas.WorkshopJob> const jobs = ref<Job[]>([]) const loading = ref(false) @@ -34,7 +36,7 @@ onMounted(async () => { try { jobs.value = await api.job_jobs_workshop_list() } catch (error) { - debugLog('Error when trying to load jobs for workshop kanban: ', error) + log('Error when trying to load jobs for workshop kanban: ', error) toast('Failed to load jobs. Please try again and contact Corrin if the problem persists.') } finally { loading.value = false diff --git a/frontend/src/views/__tests__/QuotingChatView.test.ts b/frontend/src/views/__tests__/QuotingChatView.test.ts index d89afd3bf..7b2b984a8 100644 --- a/frontend/src/views/__tests__/QuotingChatView.test.ts +++ b/frontend/src/views/__tests__/QuotingChatView.test.ts @@ -83,10 +83,6 @@ vi.mock('@/services/quote-chat.service', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), diff --git a/frontend/src/views/__tests__/WeeklyTimesheetView.test.ts b/frontend/src/views/__tests__/WeeklyTimesheetView.test.ts index 0ab67dbce..19ff94759 100644 --- a/frontend/src/views/__tests__/WeeklyTimesheetView.test.ts +++ b/frontend/src/views/__tests__/WeeklyTimesheetView.test.ts @@ -59,10 +59,6 @@ vi.mock('vue-sonner', () => ({ }, })) -vi.mock('@/utils/debug', () => ({ - debugLog: vi.fn(), -})) - vi.mock('@/api/generated/api', () => ({ schemas: {}, endpoints: [], diff --git a/frontend/src/views/purchasing/__tests__/StockView.test.ts b/frontend/src/views/purchasing/__tests__/StockView.test.ts index 12b801532..1f96c7209 100644 --- a/frontend/src/views/purchasing/__tests__/StockView.test.ts +++ b/frontend/src/views/purchasing/__tests__/StockView.test.ts @@ -28,8 +28,6 @@ vi.mock('@/components/AppLayout.vue', () => ({ default: { template: '<div><slot /></div>' }, })) -vi.mock('@/utils/debug', () => ({ debugLog: vi.fn() })) - vi.mock('@/utils/string-formatting', () => ({ formatCurrency: (n: number | null | undefined) => `$${(n ?? 0).toFixed(2)}`, })) diff --git a/frontend/tests/CLAUDE.md b/frontend/tests/CLAUDE.md new file mode 100644 index 000000000..9b3446cd0 --- /dev/null +++ b/frontend/tests/CLAUDE.md @@ -0,0 +1,3 @@ +# E2E Test Rules + +1. E2E success-path narration is silent by default: `import debug from 'debug'; const log = debug('e2e:<area>')`. Delete a `console.log` only when its value is already in a neighbouring `expect()` or the failure trace; keep bad-state / error-branch / skip-notice logs as bare `console.log`. Never add per-test `page.on('console')` forwarders — app→test surfacing is the shared `tests/fixtures/debug-forwarder.ts`, opt-in via `DEBUG=e2e:<area>` (ADR 0031). diff --git a/frontend/tests/company-defaults.spec.ts b/frontend/tests/company-defaults.spec.ts index 7e2e2cf78..80686c62a 100644 --- a/frontend/tests/company-defaults.spec.ts +++ b/frontend/tests/company-defaults.spec.ts @@ -1,13 +1,14 @@ +import debug from 'debug' import { test, expect } from './fixtures/auth' import { autoId } from './fixtures/helpers' +const log = debug('e2e:company-defaults') + test('test can call backend API directly', async ({ authenticatedPage: page }) => { const response = await page.request.get('/api/company-defaults/', { headers: { Accept: 'application/json' }, }) - console.log(`API response status: ${response.status()}`) - // If we get HTML back, log it for debugging const contentType = response.headers()['content-type'] || '' if (!contentType.includes('application/json')) { @@ -22,8 +23,6 @@ test('test can call backend API directly', async ({ authenticatedPage: page }) = const data = await response.json() expect(data.test_company_name).toBeDefined() expect(data.test_company_name).not.toBe('') - - console.log(`Test company name from API: ${data.test_company_name}`) }) test('test company defaults edit and save', async ({ authenticatedPage: page }) => { @@ -52,7 +51,7 @@ test('test company defaults edit and save', async ({ authenticatedPage: page }) // Get the original value const originalValue = await companyEmailInput.inputValue() - console.log(`Original company email: ${originalValue}`) + log(`Original company email: ${originalValue}`) // Change to a test value with timestamp const testValue = `test${Date.now()}@example.com` @@ -60,7 +59,6 @@ test('test company defaults edit and save', async ({ authenticatedPage: page }) await companyEmailInput.fill(testValue) await page.keyboard.press('Tab') // Blur to ensure v-model syncs await page.waitForTimeout(500) // Wait for Vue reactivity to propagate - console.log(`Changed company email to: ${testValue}`) // Click the page-level Save button await page.click('[data-automation-id="AdminCompanySectionView-save-button"]') @@ -79,7 +77,6 @@ test('test company defaults edit and save', async ({ authenticatedPage: page }) const savedInput = page.locator('[data-automation-id="SectionForm-company-field-company_email"]') await expect(savedInput).toBeVisible() const savedValue = await savedInput.inputValue() - console.log(`Saved company email: ${savedValue}`) expect(savedValue).toBe(testValue) // Restore original value and save @@ -90,7 +87,7 @@ test('test company defaults edit and save', async ({ authenticatedPage: page }) await page.click('[data-automation-id="AdminCompanySectionView-save-button"]') await page.waitForTimeout(1500) - console.log(`Restored company email to: ${originalValue}`) + log(`Restored company email to: ${originalValue}`) }) test('test Xero sales branding theme save, reload, and restore', async ({ diff --git a/frontend/tests/fixtures/auth.ts b/frontend/tests/fixtures/auth.ts index 461db6ec3..43e97af45 100644 --- a/frontend/tests/fixtures/auth.ts +++ b/frontend/tests/fixtures/auth.ts @@ -1,3 +1,4 @@ +import debug from 'debug' import { test as base, expect, type Page, type Response } from '@playwright/test' import { dismissToasts, @@ -14,6 +15,9 @@ import { LOGIN_ME_PATH, type CapturedBrowserError, } from '@/utils/authConsoleErrors' +import { browserDebugGlob, installBrowserDebugForwarder } from './debug-forwarder' + +const log = debug('e2e:job') // Define fixture types type AuthFixtures = { @@ -176,9 +180,14 @@ export const test = base.extend<AuthFixtures, WorkerFixtures>({ // Every test's page fails on unexpected browser console errors and uncaught // page exceptions. authenticatedPage wraps this fixture, so login is covered too. page: async ({ page, expectedConsoleErrors, sessionCheckConsoleAllowance }, use) => { - await page.addInitScript(() => { + const debugGlob = browserDebugGlob() + await page.addInitScript((glob) => { window.localStorage.setItem('e2e:disable-session-replay', 'true') - }) + if (glob) { + window.localStorage.setItem('debug', glob) + } + }, debugGlob) + installBrowserDebugForwarder(page) const captured: CapturedBrowserError[] = [] page.on('response', (response) => { @@ -243,11 +252,6 @@ export const test = base.extend<AuthFixtures, WorkerFixtures>({ ) }) - // Enable debug logging if DEBUG env var is set - if (process.env.DEBUG === 'true') { - await page.evaluate(() => localStorage.setItem('debug', 'true')) - } - // Pass the authenticated page to the test await use(page) }, @@ -346,7 +350,7 @@ export const test = base.extend<AuthFixtures, WorkerFixtures>({ ) const jobUrl = page.url() - console.log(`[Fixture] Created shared edit job at: ${jobUrl}`) + log(`Created shared edit job at: ${jobUrl}`) await context.close() diff --git a/frontend/tests/fixtures/debug-forwarder.ts b/frontend/tests/fixtures/debug-forwarder.ts new file mode 100644 index 000000000..77f557582 --- /dev/null +++ b/frontend/tests/fixtures/debug-forwarder.ts @@ -0,0 +1,74 @@ +import type { Page } from '@playwright/test' + +/** + * Bridge from E2E "area" tokens to the app-side `debug` namespace globs they + * enable in the browser. + * + * A test run selects coarse areas via `DEBUG=e2e:kanban,e2e:job`. The browser + * app logs under fine-grained `debug` namespaces (`kanban:*`, `job:autosave`, + * ...). This map is the single source of truth translating one to the other, + * so a run's `DEBUG` value controls both the Node-side test forwarder and the + * `localStorage.debug` glob the app reads at boot. + */ +export const AREA_TO_APP_NAMESPACES: Record<string, string> = { + 'e2e:autosave': 'job:autosave', + 'e2e:kanban': 'kanban:*', + 'e2e:timesheet': 'timesheet:*', + 'e2e:job': 'job:*', + 'e2e:purchasing': 'po:*', + 'e2e:crm': 'company:*,person:*', + 'e2e:reports': 'report:*', + 'e2e:staff': 'staff:*', +} + +/** + * Parse `process.env.DEBUG` into the list of enabled E2E area tokens. + * + * Splits on whitespace/commas and keeps only tokens in the `e2e:` family; + * unrelated `debug` namespaces (e.g. a raw `job:autosave`) are ignored so they + * never leak into the area bridge. Returns an empty array when `DEBUG` is + * unset or contains no `e2e:` tokens. + */ +export function enabledAreas(): string[] { + const raw = process.env.DEBUG ?? '' + return raw.split(/[\s,]+/).filter((token) => token.startsWith('e2e:')) +} + +/** + * Map the enabled E2E areas to the comma-joined app-side `debug` glob the + * browser should enable, or `null` when nothing enables browser logging. + * + * Areas with no bridge entry are dropped. `null` (rather than an empty string) + * is the explicit "no browser logging" signal callers branch on. + */ +export function browserDebugGlob(): string | null { + const globs = enabledAreas() + .map((area) => AREA_TO_APP_NAMESPACES[area]) + .filter((glob): glob is string => Boolean(glob)) + if (globs.length === 0) { + return null + } + return globs.join(',') +} + +/** + * Forward browser `console` output (except errors) to the Node test log, + * prefixed with `[browser]`, so app-side `debug` logging is visible in the + * Playwright run output. + * + * No-op unless `browserDebugGlob()` selects at least one namespace, so quiet + * runs stay quiet. Errors are intentionally skipped here: the auth fixture's + * console-error guard owns `type() === 'error'`, and the two coexist without + * double-handling. + */ +export function installBrowserDebugForwarder(page: Page): void { + if (browserDebugGlob() === null) { + return + } + page.on('console', (msg) => { + if (msg.type() === 'error') { + return + } + console.log(`[browser] ${msg.text()}`) + }) +} diff --git a/frontend/tests/fixtures/helpers.ts b/frontend/tests/fixtures/helpers.ts index acc678d08..a9c2f851e 100644 --- a/frontend/tests/fixtures/helpers.ts +++ b/frontend/tests/fixtures/helpers.ts @@ -408,7 +408,6 @@ export async function createTestPurchaseOrder(page: Page): Promise<string> { await page.waitForURL(/\/purchasing\/po\/[a-f0-9-]+$/, { timeout: 15000 }) const poUrl = page.url() - console.log(`Created PO at: ${poUrl}`) return poUrl } diff --git a/frontend/tests/job/create-estimate-entry.spec.ts b/frontend/tests/job/create-estimate-entry.spec.ts index 158681a81..e168c88ae 100644 --- a/frontend/tests/job/create-estimate-entry.spec.ts +++ b/frontend/tests/job/create-estimate-entry.spec.ts @@ -128,7 +128,6 @@ test.describe.serial('estimate operations', () => { // Create ONE job for all tests jobUrl = await createTestJob(page, 'Estimate') - console.log(`Created job at: ${jobUrl}`) await context.close() }) @@ -222,8 +221,6 @@ test.describe.serial('estimate operations', () => { expect(labourRow).not.toBeNull() expect(materialRow).not.toBeNull() expect(adjustmentRow).not.toBeNull() - - console.log('All 3 entry types verified') }) test('edit quantity and unit cost', async ({ authenticatedPage: page }) => { @@ -295,7 +292,6 @@ test.describe.serial('estimate operations', () => { // Count M8 ZINC rows before change const { indices: m8IndicesBefore } = await findRowsByDescription(page, 'M8 ZINC WING NUT') - console.log(`M8 ZINC rows before: ${m8IndicesBefore.length}`) expect(m8IndicesBefore.length).toBeGreaterThan(0) const materialRowIndex = m8IndicesBefore[0] @@ -324,7 +320,6 @@ test.describe.serial('estimate operations', () => { // Count M8 ZINC rows after - should be one less const { indices: m8IndicesAfter } = await findRowsByDescription(page, 'M8 ZINC WING NUT') - console.log(`M8 ZINC rows after: ${m8IndicesAfter.length}`) expect(m8IndicesAfter.length).toBe(m8IndicesBefore.length - 1) // Check for M10 row using the helper with 'includes' matcher diff --git a/frontend/tests/job/create-job-with-new-company.spec.ts b/frontend/tests/job/create-job-with-new-company.spec.ts index 6b76dc37a..74929d8ea 100644 --- a/frontend/tests/job/create-job-with-new-company.spec.ts +++ b/frontend/tests/job/create-job-with-new-company.spec.ts @@ -1,3 +1,4 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import { autoId, @@ -6,6 +7,8 @@ import { waitForCompanyCreateResponse, } from '../fixtures/helpers' +const log = debug('e2e:job') + /** * Tests for creating a job with a new company in Xero. * Creates a new company during job creation, verifies Xero sync. @@ -24,7 +27,7 @@ test.describe('create job with new xero company', () => { const newCompanyName = `[TEST] Company ${randomSuffix}` const jobName = `[TEST] Job for ${newCompanyName}` - console.log(`Testing with new company: ${newCompanyName}`) + log(`Testing with new company: ${newCompanyName}`) // Navigate to create job page await autoId(page, 'AppNavbar-create-job').click() @@ -51,7 +54,7 @@ test.describe('create job with new xero company', () => { const xeroIndicator = autoId(page, 'CompanyLookup-xero-valid') await expect(xeroIndicator).toBeVisible({ timeout: 10000 }) - console.log(`Company "${newCompanyName}" created with Xero ID`) + log(`Company "${newCompanyName}" created with Xero ID`) // Fill in the rest of the job form await autoId(page, 'JobCreateView-name-input').fill(jobName) @@ -91,14 +94,12 @@ test.describe('create job with new xero company', () => { expect(url).toContain('/jobs/') expect(url).not.toContain('/create') - console.log(`Job created successfully at: ${url}`) - // Verify the job number is displayed - wait for it to be populated const jobNumberElement = autoId(page, 'JobView-job-number').first() await expect(jobNumberElement).toContainText(/#\d+/, { timeout: 10000 }) const jobNumberText = await jobNumberElement.innerText() - console.log(`Created job ${jobNumberText} with new company "${newCompanyName}"`) + log(`Created job ${jobNumberText} with new company "${newCompanyName}"`) }) test('create new company via modal and complete job creation', async ({ @@ -109,7 +110,7 @@ test.describe('create job with new xero company', () => { const newCompanyName = `[TEST] Modal Company ${randomSuffix}` const jobName = `[TEST] Modal Job ${randomSuffix}` - console.log(`Testing with new company (modal method): ${newCompanyName}`) + log(`Testing with new company (modal method): ${newCompanyName}`) // Navigate to create job page await autoId(page, 'AppNavbar-create-job').click() @@ -127,7 +128,7 @@ test.describe('create job with new xero company', () => { const createCompanyModal = page.locator('div[role="dialog"]:has-text("Add New Company")') await createCompanyModal.waitFor({ timeout: 5000 }) - console.log('CreateCompanyModal opened') + log('CreateCompanyModal opened') // The company name should already be filled in the modal // Click "Create Company" button to create the company @@ -143,7 +144,7 @@ test.describe('create job with new xero company', () => { const xeroIndicator = autoId(page, 'CompanyLookup-xero-valid') await expect(xeroIndicator).toBeVisible({ timeout: 10000 }) - console.log(`Company "${newCompanyName}" created with Xero ID via modal`) + log(`Company "${newCompanyName}" created with Xero ID via modal`) // Fill in job details await autoId(page, 'JobCreateView-name-input').fill(jobName) @@ -175,7 +176,5 @@ test.describe('create job with new xero company', () => { await dismissToasts(page) const url = await submitJobAndWaitForCreatedJob(page, 'quote') expect(url).toContain('/jobs/') - - console.log(`Job created via modal method at: ${url}`) }) }) diff --git a/frontend/tests/job/create-job.spec.ts b/frontend/tests/job/create-job.spec.ts index c004fe17f..ba87188df 100644 --- a/frontend/tests/job/create-job.spec.ts +++ b/frontend/tests/job/create-job.spec.ts @@ -1,3 +1,4 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import { autoId, @@ -8,6 +9,8 @@ import { waitForSettingsInitialized, } from '../fixtures/helpers' +const log = debug('e2e:job') + /** * Sequential test cases for job creation. * These tests MUST run in order as each builds on the previous state: @@ -76,7 +79,7 @@ test.describe.serial('create job', () => { 'search and select company', CREATE_JOB_BUDGET_MS.searchAndSelectCompany, async () => { - console.log('Searching for company ABC...') + log('Searching for company ABC...') const companyInput = autoId(page, 'CompanyLookup-input') await companyInput.fill('ABC') @@ -84,7 +87,7 @@ test.describe.serial('create job', () => { await autoId(page, 'CompanyLookup-results').waitFor({ timeout: 10000 }) // Click on the test company using role - console.log(`Selecting ${TEST_COMPANY_NAME}...`) + log(`Selecting ${TEST_COMPANY_NAME}...`) await page.getByRole('option', { name: new RegExp(TEST_COMPANY_NAME) }).click() // Verify selection @@ -101,21 +104,21 @@ test.describe.serial('create job', () => { CREATE_JOB_BUDGET_MS.personSelection, async () => { // Click the button to open person modal - console.log('Opening person modal...') + log('Opening person modal...') await autoId(page, 'PersonSelector-modal-button').click({ timeout: 10000 }) // Wait for modal - console.log('Waiting for modal...') + log('Waiting for modal...') await autoId(page, 'PersonSelectionModal-container').waitFor({ timeout: 10000 }) if (tc.createPerson && tc.personToCreate) { - console.log(`Creating new person: ${tc.personToCreate.name}`) + log(`Creating new person: ${tc.personToCreate.name}`) // Debug: capture button state const submitButton = autoId(page, 'PersonSelectionModal-submit') const buttonText = await submitButton.textContent() const buttonDisabled = await submitButton.isDisabled() - console.log(`Button text: "${buttonText}", disabled: ${buttonDisabled}`) + log(`Button text: "${buttonText}", disabled: ${buttonDisabled}`) // Wait for form to be ready - button should show "Create Person" not "Saving..." try { @@ -135,7 +138,7 @@ test.describe.serial('create job', () => { // Click Create Person await submitButton.click() } else if (tc.personToSelect) { - console.log(`Selecting existing person: ${tc.personToSelect}`) + log(`Selecting existing person: ${tc.personToSelect}`) // Wait for people list await autoId(page, 'PersonSelectionModal-select-button') .first() @@ -154,7 +157,7 @@ test.describe.serial('create job', () => { } // Wait for modal to close - console.log('Waiting for modal to close...') + log('Waiting for modal to close...') await autoId(page, 'PersonSelectionModal-container').waitFor({ state: 'hidden', timeout: 10000, @@ -176,23 +179,21 @@ test.describe.serial('create job', () => { CREATE_JOB_BUDGET_MS.submitAndRedirect, async () => { const startTime = Date.now() - console.log(`[${new Date().toISOString()}] Submitting job...`) + log(`[${new Date().toISOString()}] Submitting job...`) // Dismiss any toast notifications that might block the button await dismissToasts(page) const url = await submitJobAndWaitForCreatedJob(page, tc.expectedTab) - console.log( + log( `[${new Date().toISOString()}] Clicked Create Job button (${Date.now() - startTime}ms)`, ) - console.log( - `[${new Date().toISOString()}] Redirected (${Date.now() - startTime}ms total)`, - ) + log(`[${new Date().toISOString()}] Redirected (${Date.now() - startTime}ms total)`) expect(url).toContain('/jobs/') expect(url).toContain(`tab=${tc.expectedTab}`) - console.log( + log( `[${new Date().toISOString()}] Successfully created ${tc.name} job: ${jobName} (${Date.now() - startTime}ms total)`, ) }, @@ -277,8 +278,6 @@ test.describe('new job default pay item', () => { const selectedOption = payItemSelect.locator('option:checked') const selectedText = await selectedOption.textContent() - console.log(`Default pay item for new job: "${selectedText}"`) - // Verify it's "Ordinary Time" (the expected default) expect(selectedText).toBe('Ordinary Time') }, diff --git a/frontend/tests/job/edit-job-settings.spec.ts b/frontend/tests/job/edit-job-settings.spec.ts index be964d08f..f3db35608 100644 --- a/frontend/tests/job/edit-job-settings.spec.ts +++ b/frontend/tests/job/edit-job-settings.spec.ts @@ -1,3 +1,4 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import { getCompanyDefaults } from '../fixtures/api' import { @@ -9,6 +10,8 @@ import { TEST_COMPANY_NAME, } from '../fixtures/helpers' +const log = debug('e2e:job') + const EDIT_JOB_BUDGET_MS = { navigateSettingsTab: 2000, autosave: 1500, @@ -59,7 +62,6 @@ test.describe.serial('edit job', () => { await test.step('verify job name contains test identifier', async () => { const jobNameInput = autoId(page, 'JobSettingsTab-job-name') const jobName = await jobNameInput.inputValue() - console.log('Job name value:', jobName) expect(jobName).toContain('[TEST] Edit Job') }) @@ -75,14 +77,6 @@ test.describe.serial('edit job', () => { }) test('change job name', async ({ authenticatedPage: page, sharedEditJobUrl }) => { - // Capture browser console logs for autosave debugging - page.on('console', (msg) => { - const text = msg.text() - if (text.includes('JobAutosave') || text.includes('DEBUG')) { - console.log(`[Browser] ${text}`) - } - }) - await page.goto(sharedEditJobUrl) await page.waitForLoadState('networkidle') @@ -229,18 +223,6 @@ test.describe.serial('edit job', () => { }) test('change speed vs quality', async ({ authenticatedPage: page, sharedEditJobUrl }) => { - // Capture browser console logs for autosave debugging - page.on('console', (msg) => { - const text = msg.text() - if ( - text.includes('JobAutosave') || - text.includes('DEBUG') || - text.includes('handleFieldInput') - ) { - console.log(`[Browser] ${text}`) - } - }) - await page.goto(sharedEditJobUrl) await page.waitForLoadState('networkidle') @@ -252,13 +234,13 @@ test.describe.serial('edit job', () => { const speedQualitySelect = autoId(page, 'JobSettingsTab-speed-quality') // Log current value before change const beforeValue = await speedQualitySelect.inputValue() - console.log(`Speed/Quality before change: ${beforeValue}`) + log(`Speed/Quality before change: ${beforeValue}`) await speedQualitySelect.selectOption('quality') // Log value after change const afterValue = await speedQualitySelect.inputValue() - console.log(`Speed/Quality after change: ${afterValue}`) + log(`Speed/Quality after change: ${afterValue}`) await speedQualitySelect.blur() }) @@ -665,7 +647,7 @@ test.describe.serial('edit job', () => { const shopCompany = companies.find((company) => company.id === shopCompanyId) expect(shopCompany).toBeTruthy() const shopCompanyName = shopCompany?.name as string - console.log(`Using shop company name: ${shopCompanyName}`) + log(`Using shop company name: ${shopCompanyName}`) // Navigate to Job Settings tab await autoId(page, 'JobViewTabs-jobSettings').click() @@ -747,7 +729,7 @@ test.describe.serial('edit job', () => { } }) - console.log('Values before reload:', valuesBefore) + log('Values before reload:', valuesBefore) // Reload multiple times to ensure stability for (let i = 1; i <= 3; i++) { @@ -780,8 +762,6 @@ test.describe.serial('edit job', () => { }, ) } - - console.log('All 3 reloads completed with no data drift') }) test('change default pay item', async ({ authenticatedPage: page, sharedEditJobUrl }) => { diff --git a/frontend/tests/job/job-xero-quote.spec.ts b/frontend/tests/job/job-xero-quote.spec.ts index 41964c0f4..ffbc1131b 100644 --- a/frontend/tests/job/job-xero-quote.spec.ts +++ b/frontend/tests/job/job-xero-quote.spec.ts @@ -2,10 +2,13 @@ import { spawnSync } from 'child_process' import path from 'path' import { fileURLToPath } from 'url' import { z } from 'zod' +import debug from 'debug' import { schemas } from '@/api/generated/api' import { test, expect } from '../fixtures/auth' import { autoId } from '../fixtures/helpers' +const log = debug('e2e:job') + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(__dirname, '../../..') const managePy = path.join(repoRoot, 'manage.py') @@ -247,8 +250,8 @@ test.describe('job xero quote', () => { const quoteSummary = await summarizeQuoteCostSet(page, jobId) const quoteUiBefore = await getQuoteUiSummary(page) - console.log(`[Quote preflight] ${quoteSummary}`) - console.log(`[Quote UI preflight] ${quoteUiBefore.summary}`) + log(`Quote preflight ${quoteSummary}`) + log(`Quote UI preflight ${quoteUiBefore.summary}`) if ( quoteUiBefore.counts.missingItemCount > 0 || @@ -259,7 +262,7 @@ test.describe('job xero quote', () => { } const quoteUiAfter = await getQuoteUiSummary(page) - console.log(`[Quote UI preflight after] ${quoteUiAfter.summary}`) + log(`Quote UI preflight after ${quoteUiAfter.summary}`) const createQuoteButton = page.getByRole('button', { name: 'Create Quote' }) await expect( @@ -293,7 +296,7 @@ test.describe('job xero quote', () => { throw new Error(`Xero quote create reported failure | ${quoteSummary}`) } - console.log(`[Xero quote] Created quote ID: ${responseBody.xero_id}`) + log(`Xero quote created quote ID: ${responseBody.xero_id}`) await expect(page.getByRole('button', { name: /Open in Xero/ })).toBeVisible({ timeout: 20000, diff --git a/frontend/tests/kanban/debug-drag-bugs.spec.ts b/frontend/tests/kanban/debug-drag-bugs.spec.ts index ec5d57891..9cdd87234 100644 --- a/frontend/tests/kanban/debug-drag-bugs.spec.ts +++ b/frontend/tests/kanban/debug-drag-bugs.spec.ts @@ -7,10 +7,13 @@ * * Failures confirm the bugs exist. Passes mean the bugs can't be reproduced. */ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import type { Page, Locator } from '@playwright/test' import { expectStepUnder } from '../fixtures/helpers' +const log = debug('e2e:kanban') + const DESKTOP_VIEWPORT = { width: 1280, height: 720 } const TABLET_VIEWPORT = { width: 768, height: 1024 } const KANBAN_BUDGET_MS = { @@ -180,14 +183,14 @@ test.describe('debug: drag-and-drop bugs', () => { // timeout — drop didn't fire } - console.log(`[DEBUG] Drop completed (API called): ${dropCompleted}`) + log(`Drop completed (API called): ${dropCompleted}`) // Wait 3s for any async cleanup / safety timeout to settle await page.waitForTimeout(3000) // Diagnose drag state — this is the key check regardless of whether drop completed const diag = await getDragDiagnostics(page, jobId) - console.log('[DEBUG] isDragging diagnostics after drop:', JSON.stringify(diag, null, 2)) + log('isDragging diagnostics after drop:', JSON.stringify(diag, null, 2)) // These assertions will FAIL if the bug is present expect(diag.bodyHasDragClass, 'body should NOT have is-dragging class after drop').toBe(false) @@ -241,12 +244,12 @@ test.describe('debug: drag-and-drop bugs', () => { await expect(page.locator(`[data-job-id="${jobId}"]:visible`)).toHaveCount(1, { timeout: 15000, }) - console.log(`[DEBUG] First drag succeeded: ${sourceStatus1} → ${targetStatus1}`) + log(`First drag succeeded: ${sourceStatus1} → ${targetStatus1}`) }, ) await expectStepUnder('switch to tablet layout', KANBAN_BUDGET_MS.layoutSwitch, async () => { - console.log('[DEBUG] Switching to tablet viewport...') + log('Switching to tablet viewport...') await page.setViewportSize(TABLET_VIEWPORT) await expect(getVisibleJobCard(page, jobId)).toBeVisible({ timeout: 15000 }) }) @@ -255,7 +258,7 @@ test.describe('debug: drag-and-drop bugs', () => { 'switch back to desktop layout', KANBAN_BUDGET_MS.layoutSwitch, async () => { - console.log('[DEBUG] Switching back to desktop viewport...') + log('Switching back to desktop viewport...') await page.setViewportSize(DESKTOP_VIEWPORT) await expect(getVisibleJobCard(page, jobId)).toBeVisible({ timeout: 15000 }) }, @@ -298,14 +301,14 @@ test.describe('debug: drag-and-drop bugs', () => { throw error }) - console.log(`[DEBUG] Second drag (after layout switch): ${dragSucceeded ? 'PASSED' : 'FAILED'}`) + log(`Second drag (after layout switch): ${dragSucceeded ? 'PASSED' : 'FAILED'}`) const diag = await expectStepUnder( 'post-layout-switch diagnostics complete quickly', KANBAN_BUDGET_MS.diagnostics, async () => await getDragDiagnostics(page), ) - console.log('[DEBUG] Post-layout-switch diagnostics:', JSON.stringify(diag, null, 2)) + log('Post-layout-switch diagnostics:', JSON.stringify(diag, null, 2)) // Check if sortable containers are connected to DOM // Note: :visible is a Playwright pseudo-selector, not valid in native querySelectorAll @@ -329,7 +332,7 @@ test.describe('debug: drag-and-drop bugs', () => { }) return results }) - console.log('[DEBUG] Sortable container check:', JSON.stringify(sortableCheck, null, 2)) + log('Sortable container check:', JSON.stringify(sortableCheck, null, 2)) expect(dragSucceeded, 'Drag-and-drop should work after layout switch').toBe(true) expect(diag.bodyHasDragClass, 'body should NOT have is-dragging class').toBe(false) @@ -351,13 +354,13 @@ test.describe('debug: drag-and-drop bugs', () => { await expect(jobCard).toBeVisible({ timeout: 15000 }) // Rapidly toggle viewport between desktop and tablet 5 times - console.log('[DEBUG] Starting rapid layout switching...') + log('Starting rapid layout switching...') for (let i = 0; i < 5; i++) { await page.setViewportSize(TABLET_VIEWPORT) await page.waitForTimeout(300) await page.setViewportSize(DESKTOP_VIEWPORT) await page.waitForTimeout(300) - console.log(`[DEBUG] Layout switch cycle ${i + 1}/5`) + log(`Layout switch cycle ${i + 1}/5`) } // Settle at desktop @@ -409,7 +412,7 @@ test.describe('debug: drag-and-drop bugs', () => { await page.waitForTimeout(2000) const diag = await getDragDiagnostics(page) - console.log('[DEBUG] Post-stress-test diagnostics:', JSON.stringify(diag, null, 2)) + log('Post-stress-test diagnostics:', JSON.stringify(diag, null, 2)) expect(dragSucceeded, 'Drag should work after rapid layout switching').toBe(true) expect(diag.bodyHasDragClass, 'body should NOT have is-dragging class').toBe(false) diff --git a/frontend/tests/purchasing/create-purchase-order.spec.ts b/frontend/tests/purchasing/create-purchase-order.spec.ts index b8b98b275..6362a391a 100644 --- a/frontend/tests/purchasing/create-purchase-order.spec.ts +++ b/frontend/tests/purchasing/create-purchase-order.spec.ts @@ -1,7 +1,10 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import type { Page } from '@playwright/test' import { autoId, createTestJob, createTestPurchaseOrder } from '../fixtures/helpers' +const log = debug('e2e:purchasing') + /** * Tests for purchase order operations. * Creates a PO, adds line items, assigns job, verifies data. @@ -59,7 +62,6 @@ test.describe.serial('purchase order operations', () => { // Create a job for PO line assignment testing const jobUrl = await createTestJob(page, 'PurchaseOrder') - console.log(`Created job at: ${jobUrl}`) // Extract job number from the page await page.goto(jobUrl.split('?')[0]) @@ -69,14 +71,12 @@ test.describe.serial('purchase order operations', () => { const jobNumberText = await jobNumberElement.innerText() const match = jobNumberText.match(/#(\d+)/) jobNumber = match ? match[1] : '' - console.log(`Extracted job number: ${jobNumber}`) if (!jobNumber) { throw new Error(`Failed to extract job number from: "${jobNumberText}"`) } // Create a purchase order using helper poUrl = await createTestPurchaseOrder(page) - console.log(`Created PO at: ${poUrl}`) await context.close() }) @@ -149,7 +149,7 @@ test.describe.serial('purchase order operations', () => { await autosavePromise await page.waitForTimeout(500) - console.log( + log( `PO ItemSelect timing: open=${openMs}ms search=${searchMs}ms select=${selectMs}ms item="${selected.description}"`, ) }) @@ -191,7 +191,7 @@ test.describe.serial('purchase order operations', () => { const inputValue = await jobSearchInput.inputValue() expect(inputValue).toContain(jobNumber) - console.log(`Assigned job ${jobNumber} to PO line`) + log(`Assigned job ${jobNumber} to PO line`) }) test('verify purchase order status can be changed', async ({ authenticatedPage: page }) => { @@ -214,6 +214,6 @@ test.describe.serial('purchase order operations', () => { const statusTrigger = autoId(page, 'PoSummaryCard-status-trigger') await expect(statusTrigger).toContainText('Submitted') - console.log('Changed PO status to Submitted') + log('Changed PO status to Submitted') }) }) diff --git a/frontend/tests/purchasing/pickup-address.spec.ts b/frontend/tests/purchasing/pickup-address.spec.ts index 722cc18fd..6f990a292 100644 --- a/frontend/tests/purchasing/pickup-address.spec.ts +++ b/frontend/tests/purchasing/pickup-address.spec.ts @@ -1,7 +1,10 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import type { Page } from '@playwright/test' import { autoId, dismissToasts, TEST_COMPANY_NAME } from '../fixtures/helpers' +const log = debug('e2e:purchasing') + /** * Tests for pickup address functionality on purchase orders. */ @@ -61,7 +64,6 @@ test.describe('pickup address selector', () => { await page.waitForURL('**/kanban') poUrl = await createPOWithExistingSupplier(page) - console.log(`Created PO at: ${poUrl}`) await context.close() }) @@ -78,7 +80,7 @@ test.describe('pickup address selector', () => { const modalButton = autoId(page, 'PickupAddressSelector-modal-button') await expect(modalButton).toBeEnabled() - console.log('Pickup address selector is visible and enabled') + log('Pickup address selector is visible and enabled') }) test('opens modal when clicking selector button', async ({ authenticatedPage: page }) => { @@ -90,7 +92,7 @@ test.describe('pickup address selector', () => { const modal = autoId(page, 'PickupAddressSelectionModal-container') await expect(modal).toBeVisible({ timeout: 5000 }) - console.log('Modal opened successfully') + log('Modal opened successfully') }) }) @@ -148,7 +150,7 @@ test.describe('address autocomplete', () => { // Should contain the exact Hillsborough road suggestion await expect(suggestionDropdown).toContainText(/7C Aldersgate.*Road/i) - console.log('Autocomplete returned Hillsborough suggestion for "7C Aldersgate"') + log('Autocomplete returned Hillsborough suggestion for "7C Aldersgate"') }) }) @@ -172,7 +174,6 @@ test.describe('pickup address CRUD', () => { await page.waitForURL('**/kanban') poUrl = await createPOWithExistingSupplier(page) - console.log(`Created PO at: ${poUrl}`) await context.close() }) @@ -224,7 +225,7 @@ test.describe('pickup address CRUD', () => { // Address should be selected const display = autoId(page, 'PickupAddressSelector-display') await expect(display).toHaveValue(/7C Aldersgate.*Road/i, { timeout: 5000 }) - console.log('Created and selected new address') + log('Created and selected new address') // --- Step 2: Clear the selection --- await dismissToasts(page) @@ -242,7 +243,7 @@ test.describe('pickup address CRUD', () => { ) await expect(display).toHaveValue('') - console.log('Cleared selection') + log('Cleared selection') // --- Step 3: Re-select the address --- await dismissToasts(page) @@ -257,7 +258,7 @@ test.describe('pickup address CRUD', () => { // Modal closes and address is selected await expect(modal).toBeHidden({ timeout: 5000 }) await expect(display).toHaveValue(/7C Aldersgate.*Road/i, { timeout: 5000 }) - console.log('Re-selected existing address') + log('Re-selected existing address') }) test('can edit an existing address', async ({ authenticatedPage: page }) => { @@ -311,7 +312,7 @@ test.describe('pickup address CRUD', () => { await updatePromise await expect(modal).toBeHidden({ timeout: 5000 }) - console.log('Updated address successfully') + log('Updated address successfully') }) test('can delete an existing address', async ({ authenticatedPage: page }) => { @@ -362,7 +363,7 @@ test.describe('pickup address CRUD', () => { const newCount = await addressCards.count() expect(newCount).toBeLessThan(initialCount) - console.log(`Deleted address. Count went from ${initialCount} to ${newCount}`) + log(`Deleted address. Count went from ${initialCount} to ${newCount}`) await page.keyboard.press('Escape') }) @@ -398,7 +399,7 @@ test.describe('pickup address CRUD', () => { const cityValue = await cityInput.inputValue() expect(cityValue.length).toBeGreaterThan(0) - console.log(`City field populated: ${cityValue}`) + log(`City field populated: ${cityValue}`) await page.keyboard.press('Escape') }) }) @@ -417,7 +418,7 @@ test.describe('pickup address without supplier', () => { const selector = autoId(page, 'PickupAddressSelector-display') await expect(selector).not.toBeVisible({ timeout: 5000 }) - console.log('Pickup address selector not visible without supplier') + log('Pickup address selector not visible without supplier') }) test('modal button is disabled without supplier', async ({ authenticatedPage: page }) => { @@ -436,6 +437,6 @@ test.describe('pickup address without supplier', () => { const modalButton = autoId(page, 'PickupAddressSelector-modal-button') await expect(modalButton).toBeEnabled({ timeout: 5000 }) - console.log('Modal button is enabled after supplier selection') + log('Modal button is enabled after supplier selection') }) }) diff --git a/frontend/tests/purchasing/po-created-by.spec.ts b/frontend/tests/purchasing/po-created-by.spec.ts index 9a5fe6005..9ad717cab 100644 --- a/frontend/tests/purchasing/po-created-by.spec.ts +++ b/frontend/tests/purchasing/po-created-by.spec.ts @@ -10,7 +10,6 @@ test.describe('purchase order created by', () => { test('displays created by on list and detail screens', async ({ authenticatedPage: page }) => { // Create a purchase order const poUrl = await createTestPurchaseOrder(page) - console.log(`Created PO at: ${poUrl}`) // Extract PO ID from URL const poId = poUrl.split('/').pop() @@ -28,7 +27,6 @@ test.describe('purchase order created by', () => { // Should have a name, not be empty or just a dash expect(createdByText.trim()).not.toBe('') expect(createdByText.trim()).not.toBe('—') - console.log(`List view - Created By: ${createdByText}`) // Navigate to PO detail await page.goto(poUrl) @@ -41,7 +39,6 @@ test.describe('purchase order created by', () => { const createdByInputValue = await createdByInput.inputValue() expect(createdByInputValue.trim()).not.toBe('') - console.log(`Detail view - Created By: ${createdByInputValue}`) // Both should match expect(createdByInputValue.trim()).toBe(createdByText.trim()) diff --git a/frontend/tests/reports/companies.spec.ts b/frontend/tests/reports/companies.spec.ts index c52f09ebb..3f4968d94 100644 --- a/frontend/tests/reports/companies.spec.ts +++ b/frontend/tests/reports/companies.spec.ts @@ -1,6 +1,9 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import { autoId, TEST_COMPANY_NAME } from '../fixtures/helpers' +const log = debug('e2e:reports') + test.describe('Companies Report', () => { test('sorts by spend, verifies company detail, and searches for testing company', async ({ authenticatedPage: page, @@ -43,7 +46,6 @@ test.describe('Companies Report', () => { await expect(firstRowSpend).toBeVisible() const spendText = await firstRowSpend.textContent() - console.log(`Top spender total: ${spendText}`) // Validate the biggest spender has total_spend > $0 // Format is like "$1,234.56" or "$0.00" @@ -57,7 +59,7 @@ test.describe('Companies Report', () => { // Get the company name before clicking const companyName = await autoId(page, `CompaniesTable-cell-${companyId}-name`).textContent() - console.log(`Clicking on top spender: ${companyName}`) + log(`Clicking on top spender: ${companyName}`) // Click on the company row to navigate to details await firstRow.click() @@ -86,7 +88,6 @@ test.describe('Companies Report', () => { await expect(totalSpendValue).toBeVisible() const detailSpendText = await totalSpendValue.textContent() - console.log(`Company detail Total Spend: ${detailSpendText}`) // Verify the spend amount matches what we saw in the table expect(detailSpendText).toBe(spendText) @@ -124,14 +125,5 @@ test.describe('Companies Report', () => { hasText: new RegExp(TEST_COMPANY_NAME, 'i'), }) await expect(testCompanyRow.first()).toBeVisible({ timeout: 10000 }) - - // Verify results are filtered (should be much fewer than all companies) - const resultText = page.locator('text=/Found \\d+ company/') - const resultCount = await resultText.textContent() - console.log(`Search results: ${resultCount}`) - - console.log( - 'Companies report test passed: sorted by spend, validated company detail, found test company', - ) }) }) diff --git a/frontend/tests/reports/job-movement.spec.ts b/frontend/tests/reports/job-movement.spec.ts index ca2827f59..eab9e9772 100644 --- a/frontend/tests/reports/job-movement.spec.ts +++ b/frontend/tests/reports/job-movement.spec.ts @@ -47,7 +47,5 @@ test.describe('Job Movement Report', () => { // Verify the "Additional Metrics" section is visible await expect(autoId(page, 'JobMovementReport-additional-metrics')).toBeVisible() - - console.log('Job Movement Report test passed with data displayed') }) }) diff --git a/frontend/tests/reports/payroll-reconciliation.spec.ts b/frontend/tests/reports/payroll-reconciliation.spec.ts index 90ee7c2f7..8a78ffc89 100644 --- a/frontend/tests/reports/payroll-reconciliation.spec.ts +++ b/frontend/tests/reports/payroll-reconciliation.spec.ts @@ -1,5 +1,8 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' +const log = debug('e2e:reports') + test.describe('Payroll Reconciliation Report', () => { test('loads and displays reconciliation data', async ({ authenticatedPage: page }) => { // Navigate to the report @@ -29,8 +32,8 @@ test.describe('Payroll Reconciliation Report', () => { const responseBody = await apiResponse.json() const weekCount = responseBody.heatmap?.rows?.length ?? 0 const staffCount = responseBody.heatmap?.staff_names?.length ?? 0 - console.log(`API response: ${weekCount} weeks, ${staffCount} staff`) - console.log(`Grand totals:`, JSON.stringify(responseBody.grand_totals)) + log(`API response: ${weekCount} weeks, ${staffCount} staff`) + log(`Grand totals:`, JSON.stringify(responseBody.grand_totals)) // Verify summary cards are visible with real values const xeroTotal = page.locator('[data-automation-id="PayrollReconciliation-xero-total"]') @@ -50,14 +53,12 @@ test.describe('Payroll Reconciliation Report', () => { const heatmapRows = heatmapTable.locator('tbody tr') const rowCount = await heatmapRows.count() expect(rowCount).toBeGreaterThan(0) - console.log(`Heatmap has ${rowCount} week rows`) // Count staff columns — should have at least 1 const staffHeaders = heatmapTable.locator('thead th') const colCount = await staffHeaders.count() // First column is "Week" label, rest are staff names const displayedStaffCount = colCount - 1 - console.log(`Heatmap has ${displayedStaffCount} staff columns`) expect(displayedStaffCount).toBeGreaterThan(0) // Verify at least some cells have dollar values (not all empty/null) @@ -68,11 +69,6 @@ test.describe('Payroll Reconciliation Report', () => { const text = await cellsWithValues.nth(i).textContent() if (text && text.trim() !== '') nonEmptyCells++ } - console.log(`Found ${nonEmptyCells} non-empty cells in first 50 checked`) expect(nonEmptyCells).toBeGreaterThan(0) - - console.log( - `Payroll Reconciliation test passed: ${rowCount} weeks, ${displayedStaffCount} staff, ${nonEmptyCells}+ cells with data`, - ) }) }) diff --git a/frontend/tests/reports/sales-forecast.spec.ts b/frontend/tests/reports/sales-forecast.spec.ts index 1f61f584d..de16bd6a0 100644 --- a/frontend/tests/reports/sales-forecast.spec.ts +++ b/frontend/tests/reports/sales-forecast.spec.ts @@ -47,7 +47,5 @@ test.describe('Sales Forecast Report', () => { const tableRows = autoId(page, 'SalesForecastReport-table').locator('tbody tr') const rowCount = await tableRows.count() expect(rowCount).toBeGreaterThan(0) - - console.log(`Sales Forecast Report test passed with ${rowCount} month rows displayed`) }) }) diff --git a/frontend/tests/reports/wip-report.spec.ts b/frontend/tests/reports/wip-report.spec.ts index bfd9b4e96..7ef8d1930 100644 --- a/frontend/tests/reports/wip-report.spec.ts +++ b/frontend/tests/reports/wip-report.spec.ts @@ -32,7 +32,5 @@ test.describe('WIP Report', () => { const tableRows = autoId(page, 'WIPReport-table').locator('tbody tr') const rowCount = await tableRows.count() expect(rowCount).toBeGreaterThan(0) - - console.log(`WIP Report test passed with ${rowCount} job rows displayed`) }) }) diff --git a/frontend/tests/staff/create-staff.spec.ts b/frontend/tests/staff/create-staff.spec.ts index af014afc0..e9e077917 100644 --- a/frontend/tests/staff/create-staff.spec.ts +++ b/frontend/tests/staff/create-staff.spec.ts @@ -52,8 +52,6 @@ test.describe('create staff', () => { await expect(page.locator('[data-sonner-toast]')).toContainText('successfully', { timeout: 5000, }) - - console.log(`Successfully created staff member: [TEST] Staff User ${timestamp}`) }) }) }) diff --git a/frontend/tests/staff/staff-wage-loading.spec.ts b/frontend/tests/staff/staff-wage-loading.spec.ts index 9e7db018a..0d6c801c9 100644 --- a/frontend/tests/staff/staff-wage-loading.spec.ts +++ b/frontend/tests/staff/staff-wage-loading.spec.ts @@ -1,8 +1,11 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import { getCompanyDefaults, getStaffList } from '../fixtures/api' import { autoId, createTestJob, getPhantomRowIndex } from '../fixtures/helpers' import { getLatestWeekdayDate } from '../../src/utils/dateUtils' +const log = debug('e2e:staff') + /** * Tests that staff wage_rate includes annual leave loading and that * timesheet entries use the loaded rate for cost calculations. @@ -36,7 +39,6 @@ test.describe.serial('staff wage loading', () => { // Fetch company defaults for annual_leave_loading const defaults = await getCompanyDefaults(page) annualLeaveLoading = defaults.annual_leave_loading - console.log(`Annual leave loading: ${annualLeaveLoading}%`) if (!annualLeaveLoading || annualLeaveLoading <= 0) { throw new Error( `annual_leave_loading must be > 0 for this test (got ${annualLeaveLoading}). ` + @@ -60,14 +62,14 @@ test.describe.serial('staff wage loading', () => { staffId = activeStaff.id baseWageRate = activeStaff.base_wage_rate wageRate = activeStaff.wage_rate - console.log( + log( `Using staff: ${activeStaff.first_name} ${activeStaff.last_name} ` + `(id=${staffId}, base_wage_rate=$${baseWageRate}, wage_rate=$${wageRate})`, ) // Create a test job const jobUrl = await createTestJob(page, 'WageLoading') - console.log(`Created job at: ${jobUrl}`) + log(`Created job at: ${jobUrl}`) // Extract job number await page.goto(jobUrl.split('?')[0]) @@ -77,7 +79,7 @@ test.describe.serial('staff wage loading', () => { const jobNumberText = await jobNumberElement.innerText() const match = jobNumberText.match(/#(\d+)/) jobNumber = match ? match[1] : '' - console.log(`Extracted job number: ${jobNumber}`) + log(`Extracted job number: ${jobNumber}`) if (!jobNumber) { throw new Error(`Failed to extract job number from: "${jobNumberText}"`) } @@ -87,10 +89,6 @@ test.describe.serial('staff wage loading', () => { test('wage_rate equals base_wage_rate with annual leave loading applied', async () => { const expected = Math.round(baseWageRate * (1 + annualLeaveLoading / 100) * 100) / 100 - console.log( - `Wage rate calculation: $${baseWageRate} * (1 + ${annualLeaveLoading}/100) = $${expected}`, - ) - console.log(`API wage_rate: $${wageRate}`) expect(wageRate).toBeCloseTo(expected, 2) }) @@ -135,17 +133,11 @@ test.describe.serial('staff wage loading', () => { const wageCell = autoId(page, `SmartTimesheetTable-wage-${rowIndex}`) await wageCell.waitFor({ timeout: 5000 }) const wageText = await wageCell.textContent() - console.log(`Displayed wage: ${wageText}`) // Parse currency value (e.g., "$43.20" -> 43.20) const currencyMatch = wageText?.match(/\$?([\d,]+\.?\d*)/) const displayedWage = currencyMatch ? parseFloat(currencyMatch[1].replace(/,/g, '')) : 0 - console.log( - `Wage comparison: displayed=$${displayedWage}, ` + - `loaded_rate=$${wageRate}, base_rate=$${baseWageRate}`, - ) - // The displayed wage for 1 hour at ordinary rate should equal the loaded wage_rate expect(displayedWage).toBeCloseTo(wageRate, 2) // It should NOT equal the base wage rate (unless loading is 0, but we checked that) diff --git a/frontend/tests/timesheet/create-timesheet-entry.spec.ts b/frontend/tests/timesheet/create-timesheet-entry.spec.ts index 11ff5a806..71fc235be 100644 --- a/frontend/tests/timesheet/create-timesheet-entry.spec.ts +++ b/frontend/tests/timesheet/create-timesheet-entry.spec.ts @@ -1,8 +1,11 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import type { Page } from '@playwright/test' import { autoId, createTestJob, getPhantomRowIndex } from '../fixtures/helpers' import { getLatestWeekdayDate } from '../../src/utils/dateUtils' +const log = debug('e2e:timesheet') + /** * Tests for timesheet entry operations. * Creates a job, adds time via daily timesheet, verifies on job's Actuals tab. @@ -122,7 +125,6 @@ test.describe.serial('timesheet entry operations', () => { await page.waitForURL('**/kanban') jobUrl = await createTestJob(page, 'Timesheet') - console.log(`Created job at: ${jobUrl}`) await page.goto(jobUrl.split('?')[0]) await page.waitForLoadState('networkidle') @@ -131,7 +133,6 @@ test.describe.serial('timesheet entry operations', () => { const jobNumberText = await jobNumberElement.innerText() const match = jobNumberText.match(/#(\d+)/) jobNumber = match ? match[1] : '' - console.log(`Extracted job number: ${jobNumber}`) if (!jobNumber) { throw new Error(`Failed to extract job number from: "${jobNumberText}"`) } @@ -143,7 +144,6 @@ test.describe.serial('timesheet entry operations', () => { await navigateToActualsTab(page, jobUrl) const timeExpenses = await getTimeAndExpensesValue(page) - console.log(`Initial Time & Expenses: $${timeExpenses}`) expect(timeExpenses).toBe(0) }) @@ -151,7 +151,7 @@ test.describe.serial('timesheet entry operations', () => { await navigateToTimesheetEntry(page) const rowIndex = await getPhantomRowIndex(page) - console.log(`Phantom row index: ${rowIndex}`) + log(`Phantom row index: ${rowIndex}`) await selectJobByNumber(page, rowIndex, jobNumber) await enterHours(page, rowIndex, '2') @@ -163,7 +163,7 @@ test.describe.serial('timesheet entry operations', () => { // "2" (HoursCell formats whole numbers with no decimal). const hoursInput = autoId(page, `SmartTimesheetTable-hours-${rowIndex}`) await expect(hoursInput).toHaveValue(/^2/) - console.log(`Added 2 hours to job ${jobNumber}`) + log(`Added 2 hours to job ${jobNumber}`) }) test('edit description on the saved row persists after reload', async ({ @@ -197,7 +197,7 @@ test.describe.serial('timesheet entry operations', () => { ) await page.keyboard.press('Enter') await patchPromise - console.log(`PATCHed description to: "${newDesc}"`) + log(`PATCHed description to: "${newDesc}"`) // Reload to prove the new description came back from the server, not just // from the optimistic in-memory update. @@ -212,7 +212,6 @@ test.describe.serial('timesheet entry operations', () => { await navigateToActualsTab(page, jobUrl) const timeExpenses = await getTimeAndExpensesValue(page) - console.log(`Time & Expenses after entry: $${timeExpenses}`) expect(timeExpenses).toBeGreaterThan(0) }) }) @@ -237,7 +236,6 @@ test.describe.serial('xero pay item validation', () => { await page.waitForURL('**/kanban') const jobUrl = await createTestJob(page, 'PayItem') - console.log(`Created job for pay item tests at: ${jobUrl}`) await page.goto(jobUrl.split('?')[0]) await page.waitForLoadState('networkidle') @@ -246,7 +244,6 @@ test.describe.serial('xero pay item validation', () => { const jobNumberText = await jobNumberElement.innerText() const match = jobNumberText.match(/#(\d+)/) testJobNumber = match ? match[1] : '' - console.log(`Extracted job number for pay item tests: ${testJobNumber}`) if (!testJobNumber) { throw new Error(`Failed to extract job number from: "${jobNumberText}"`) } @@ -259,28 +256,26 @@ test.describe.serial('xero pay item validation', () => { }) => { await navigateToTimesheetEntry(page) const rowIndex = await getPhantomRowIndex(page) - console.log(`Phantom row index for Annual Leave test: ${rowIndex}`) + log(`Phantom row index for Annual Leave test: ${rowIndex}`) await selectJobByName(page, rowIndex, 'Annual Leave') await enterHours(page, rowIndex, '4') await waitForCreatePost(page) const payItem = await getPayItemValue(page, rowIndex) - console.log(`Annual Leave entry pay item: "${payItem}"`) expect(payItem).toBe('Annual Leave') }) test('regular job defaults to Ordinary Time pay item', async ({ authenticatedPage: page }) => { await navigateToTimesheetEntry(page) const rowIndex = await getPhantomRowIndex(page) - console.log(`Phantom row index for regular job test: ${rowIndex}`) + log(`Phantom row index for regular job test: ${rowIndex}`) await selectJobByNumber(page, rowIndex, testJobNumber) await enterHours(page, rowIndex, '2') await waitForCreatePost(page) const payItem = await getPayItemValue(page, rowIndex) - console.log(`Regular job entry pay item: "${payItem}"`) expect(payItem).toBe('Ordinary Time') }) @@ -303,7 +298,7 @@ test.describe.serial('xero pay item validation', () => { const match = triggerId?.match(/jobPicker-(\d+)-trigger/) const rowIndex = match ? Number(match[1]) : -1 expect(rowIndex).toBeGreaterThanOrEqual(0) - console.log(`Found regular-job row at index ${rowIndex} (#${testJobNumber})`) + log(`Found regular-job row at index ${rowIndex} (#${testJobNumber})`) await setRateMultiplier(page, rowIndex, '2.0') @@ -315,7 +310,6 @@ test.describe.serial('xero pay item validation', () => { ) const payItem = await getPayItemValue(page, rowIndex) - console.log(`After rate change to 2.0, pay item: "${payItem}"`) expect(['Double Time', 'Overtime (2.0)']).toContain(payItem) }) }) diff --git a/frontend/tests/timesheet/performance.spec.ts b/frontend/tests/timesheet/performance.spec.ts index c37427c60..eb150ac6a 100644 --- a/frontend/tests/timesheet/performance.spec.ts +++ b/frontend/tests/timesheet/performance.spec.ts @@ -1,6 +1,9 @@ +import debug from 'debug' import { test, expect } from '../fixtures/auth' import { getLatestWeekdayDate } from '../../src/utils/dateUtils' +const log = debug('e2e:perf') + /** * Performance test for timesheet entry page. * Measures page load time and network request timing. @@ -51,7 +54,7 @@ test.describe('timesheet entry performance', () => { const automationId = await firstStaffRow.getAttribute('data-automation-id') const staffId = automationId?.replace('StaffRow-name-', '') || '' - console.log(`\n=== Navigating to timesheet entry for staff: ${staffId} ===\n`) + log(`\n=== Navigating to timesheet entry for staff: ${staffId} ===\n`) // Clear tracked requests before navigating to entry page networkRequests.length = 0 @@ -75,13 +78,11 @@ test.describe('timesheet entry performance', () => { const totalLoadTime = Date.now() - entryStartTime - console.log('\n=== PERFORMANCE REPORT ===\n') - console.log( - `Total page load time: ${totalLoadTime}ms (${(totalLoadTime / 1000).toFixed(1)}s)\n`, - ) + log('\n=== PERFORMANCE REPORT ===\n') + log(`Total page load time: ${totalLoadTime}ms (${(totalLoadTime / 1000).toFixed(1)}s)\n`) - console.log('Network requests (in order):') - console.log('----------------------------') + log('Network requests (in order):') + log('----------------------------') // Sort by start time networkRequests.sort((a, b) => a.startTime - b.startTime) @@ -90,16 +91,14 @@ test.describe('timesheet entry performance', () => { for (const req of networkRequests) { const duration = req.duration || 0 totalApiTime += duration - console.log( - `[${req.startTime}ms] ${req.method} ${req.url.substring(0, 80)}... → ${duration}ms`, - ) + log(`[${req.startTime}ms] ${req.method} ${req.url.substring(0, 80)}... → ${duration}ms`) } - console.log('\n----------------------------') - console.log(`Total API time (sum): ${totalApiTime}ms`) - console.log(`Total page load time: ${totalLoadTime}ms`) - console.log(`Frontend overhead: ${totalLoadTime - totalApiTime}ms`) - console.log(`Number of API calls: ${networkRequests.length}`) + log('\n----------------------------') + log(`Total API time (sum): ${totalApiTime}ms`) + log(`Total page load time: ${totalLoadTime}ms`) + log(`Frontend overhead: ${totalLoadTime - totalApiTime}ms`) + log(`Number of API calls: ${networkRequests.length}`) // Check for duplicate API calls const urlCounts: Record<string, number> = {} @@ -110,14 +109,14 @@ test.describe('timesheet entry performance', () => { const duplicates = Object.entries(urlCounts).filter(([, count]) => count > 1) if (duplicates.length > 0) { - console.log('\nDuplicate API calls detected:') + log('\nDuplicate API calls detected:') for (const [url, count] of duplicates) { - console.log(` ${count}x ${url}`) + log(` ${count}x ${url}`) } } // Assertions - console.log('\n=== ASSERTIONS ===\n') + log('\n=== ASSERTIONS ===\n') // Log if page takes more than 5 seconds if (totalLoadTime > 5000) { @@ -165,7 +164,7 @@ test.describe('timesheet entry performance', () => { await page.locator('.smart-timesheet-table').waitFor({ state: 'visible', timeout: 60000 }) await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }) - console.log('\n=== API REQUEST TIMELINE ===\n') + log('\n=== API REQUEST TIMELINE ===\n') if (apiTiming.length === 0) { console.log('No API requests captured') @@ -180,13 +179,11 @@ test.describe('timesheet entry performance', () => { const duration = Math.max(0, req.end - req.start) const shortUrl = req.url.split('?')[0].substring(0, 50) const bar = '█'.repeat(Math.min(50, Math.ceil(duration / 100))) - console.log( - `${relStart.toString().padStart(5)}ms: ${shortUrl.padEnd(50)} |${bar}| ${duration}ms`, - ) + log(`${relStart.toString().padStart(5)}ms: ${shortUrl.padEnd(50)} |${bar}| ${duration}ms`) } // Check for sequential patterns - console.log('\n=== SEQUENTIAL VS PARALLEL ANALYSIS ===\n') + log('\n=== SEQUENTIAL VS PARALLEL ANALYSIS ===\n') let sequentialCount = 0 let parallelCount = 0 @@ -203,8 +200,8 @@ test.describe('timesheet entry performance', () => { } } - console.log(`Sequential request patterns: ${sequentialCount}`) - console.log(`Parallel request patterns: ${parallelCount}`) + log(`Sequential request patterns: ${sequentialCount}`) + log(`Parallel request patterns: ${parallelCount}`) if (sequentialCount > parallelCount * 2) { console.log('\nWARNING: Requests appear to be mostly sequential - could be parallelized!') diff --git a/frontend/tests/timesheet/workshop-my-time-view.spec.ts b/frontend/tests/timesheet/workshop-my-time-view.spec.ts index d33531343..2c9801822 100644 --- a/frontend/tests/timesheet/workshop-my-time-view.spec.ts +++ b/frontend/tests/timesheet/workshop-my-time-view.spec.ts @@ -134,7 +134,6 @@ test.describe.serial('workshop my time view', () => { const jobResult = await loginAndCreateJob(page) jobNumber = jobResult.jobNumber - console.log(`Created job for Workshop My Time tests: #${jobNumber}`) await context.close() }) From 3d5566443a4dd0271bf5ae3397f823852fe0384b Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 18:04:42 +1200 Subject: [PATCH 50/66] perf(e2e): serve production build through ngrok to fix the ~2.5x E2E slowdown E2E ran ~2.5x slow (1.2h vs ~25min). Root cause (single-variable A/B): ngrok-free's tunnel is throughput-capped (~1 MB/s; loopback is 67-247 MB/s), and Vite dev serves a 21 MB uncompressed module payload (lucide 4 MB, vue 1.7 MB, rrweb, generated api.ts ...). 21 MB / 1 MB/s = the 20-30s page loads. Not the laptop, not the logging change, not prod (prod ships a built, tree-shaken, gzipped ~1.5 MB bundle). Fix: serve the production build for E2E, kept on the ngrok public URL (Xero OAuth callback + real round-trip preserved) and exposed on the LAN. Fast because the payload is ~14x smaller, not because the network is removed -- so it also matches what real users load, instead of masking the cost via loopback. - vite.config.ts: add a preview{} block. `vite preview` does NOT inherit `server.proxy`, so mirror the /api + /media proxy and allowedHosts (or the whole suite 404s), plus host 0.0.0.0 for LAN exposure. - package.json: preview:e2e = "vite build && vite preview". - .vscode/tasks.json: "Frontend Preview (build)" task + "Start E2E Environment" compound (mirror of Start Dev Environment, frontend swapped). The two modes both bind :5173, so you pick one. Verified on loopback: build serves, /api proxies (401 from Django), login 2.3s, LAN-exposed. Suite-level "<30min through ngrok" confirmation is still pending a run via the E2E environment -- merge gate until then. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UTKr8tHRW5iam4YriPSzc --- .vscode/tasks.json | 38 ++++++++++++++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/vite.config.ts | 20 ++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index dc49282b5..21680e2ed 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,6 +27,32 @@ "panel": "dedicated" } }, + { + "label": "Frontend Preview (build)", + "type": "process", + "command": "${env:HOME}/.nvm/nvm-exec", + "args": ["npm", "run", "preview:e2e"], + "hide": true, + "options": { + "cwd": "${workspaceFolder}/frontend" + }, + "isBackground": true, + "problemMatcher": { + "owner": "vite-preview", + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "Local:" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, { "label": "Ngrok Tunnels", "type": "shell", @@ -104,6 +130,18 @@ "dependsOrder": "parallel", "problemMatcher": [] }, + { + "label": "Start E2E Environment", + "detail": "Serves the production BUILD on :5173 (fast over ngrok/LAN) for E2E runs. Stop 'Start Dev Environment' first — both bind :5173.", + "dependsOn": [ + "Frontend Preview (build)", + "Ngrok Tunnels", + "Celery Worker", + "Celery Beat" + ], + "dependsOrder": "parallel", + "problemMatcher": [] + }, { "label": "Rebuild Schema", "type": "shell", diff --git a/frontend/package.json b/frontend/package.json index 96fb5d2f8..906dd9086 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", + "preview:e2e": "vite build && vite preview", "build-only": "vite build", "type-check": "vue-tsc --build", "lint": "eslint . --fix", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2c383445f..46bb51637 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -118,5 +118,25 @@ export default defineConfig(({ mode }) => { }, }, }, + // `vite preview` serves the production build (used for E2E over ngrok, and for LAN + // access) — it does NOT inherit `server.proxy`, so the API/media proxy and + // allowedHosts are mirrored here or every /api call 404s. host 0.0.0.0 exposes the + // built bundle on the LAN, matching what real users load. + preview: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + allowedHosts, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/media': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, } }) From 830a8ca5f59071e38145804ba56d4a6084366374 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 18:22:13 +1200 Subject: [PATCH 51/66] fix(e2e): give the E2E environment its own plain Django backend Start E2E Environment started frontend-preview + ngrok + celery but not Django, so every /api call would fail. Add a hidden Django (runserver) task (plain `runserver --noreload`, no debugpy -- faster per request and no Run>Debug dependency) and include it in the compound. The E2E env is now self-contained; still only two visible tasks (Dev / E2E). Run>Debug stays for actual debugging. Binds :8000, so stop the debugpy session first (same conflict pattern as :5173). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UTKr8tHRW5iam4YriPSzc --- .vscode/tasks.json | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 21680e2ed..2ca3ff660 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -75,6 +75,34 @@ "panel": "dedicated" } }, + { + "label": "Django (runserver)", + "type": "process", + "command": "${workspaceFolder}/.venv/bin/python", + "args": ["${workspaceFolder}/manage.py", "runserver", "--noreload"], + "options": { + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + "hide": true, + "isBackground": true, + "problemMatcher": { + "owner": "django", + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "Starting development server" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, { "label": "Celery Worker", "type": "shell", @@ -132,9 +160,10 @@ }, { "label": "Start E2E Environment", - "detail": "Serves the production BUILD on :5173 (fast over ngrok/LAN) for E2E runs. Stop 'Start Dev Environment' first — both bind :5173.", + "detail": "Serves the production BUILD on :5173 + plain Django on :8000 (no debugger) for E2E runs, fast over ngrok/LAN. Stop 'Start Dev Environment' AND the Run>Debug 'Django' session first — both bind :5173/:8000.", "dependsOn": [ "Frontend Preview (build)", + "Django (runserver)", "Ngrok Tunnels", "Celery Worker", "Celery Beat" From 059f3af735a3495a87d5fefc63807dd2c673292b Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 19:41:44 +1200 Subject: [PATCH 52/66] fix(e2e): resolve APP_DOMAIN uniformly so the preview build is reachable via ngrok readBackendAppDomain() returned "" under NODE_ENV=production -- the mode that `vite build && vite preview` runs in -- so appDomain was empty, preview allowedHosts collapsed to ["localhost"], and the ngrok host got a 403 (the built app never loaded). Remove the NODE_ENV fork: read APP_DOMAIN whenever the backend .env is present (all local use), "" only if absent (CI). Dev and preview now resolve allowedHosts identically. Verified on the REAL path (not localhost): ngrok/login -> 200, and login through the actual ngrok URL against the build = ~3.0s (vs ~20s against the dev server). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UTKr8tHRW5iam4YriPSzc --- frontend/vite.config.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 46bb51637..02f074d03 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,13 +8,14 @@ import { defineConfig, loadEnv } from 'vite' import { routerAutoOptions } from './router-auto-options' function readBackendAppDomain(): string { - if (process.env.NODE_ENV === 'production') { - return '' - } - const backendEnvPath = path.resolve(__dirname, '..', '.env') if (!fs.existsSync(backendEnvPath)) { - throw new Error(`Backend .env not found at ${backendEnvPath}`) + // Absent only in CI / prod-artifact builds (frontend built without the backend .env + // alongside); return empty there. When the file is present -- all local use, including + // `vite build && vite preview` for E2E over ngrok -- the domain is read uniformly, with + // no NODE_ENV fork, so dev and preview resolve allowedHosts identically and the ngrok + // host is admitted. allowedHosts is server-side config, never baked into the built bundle. + return '' } const content = fs.readFileSync(backendEnvPath, 'utf8') const match = content.match(/^APP_DOMAIN=(.+)$/m) From b57d3dd9ad100ee651138cff774268eec5fb8c2a Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sat, 25 Jul 2026 22:43:42 +1200 Subject: [PATCH 53/66] fix(e2e): stop Xero replaying E2E data into the restored dev DB Preflight kept aborting on leftover [TEST] companies. Teardown was not at fault: it restores the DB and its post-restore checkSafeToTest passed. The rows came back afterwards, from Xero. E2E creates real Contacts/Invoices/Quotes in the dev Xero org (XERO_READONLY=False by design), and xero_regular_sync_task polls hourly with if_modified_since. The run always sits inside that window, so the next poll re-imported the run's artifacts into the freshly restored database: teardown clean at 20:15, poll at 20:19:44, 12 [TEST] companies by 20:53. The surviving row had xero_last_modified 20:14 but django_created_at 21:19. Not the webhook - none fired. No pause shorter than the poll interval can help, so the existing 90s settle covers the wrong channel. Record each run's wall-clock window, and skip an inbound Xero object when both its timestamp falls inside a closed window and it belongs to a test company. Neither condition works alone: the window alone would discard genuine Xero edits landing inside it, and the name alone is equally true mid-run, which would blind the round trip the run exists to exercise. Windows stay open while a run executes, so a poll firing mid-run - or after a pause of a day - behaves exactly as production. Development only, via the existing PRODUCTION_LIKE setting plus the windows file being absent anywhere E2E has never run. No new env var, so no deployment, .env.example or instance template changes. State lives in a temp file rather than the database because teardown restores from a backup taken before the run, so database state cannot describe a run that has just finished. Filtering happens on the fetched batch before sync_function: the invoice, quote and PO importers resolve their contact through resolve_company_from_xero_contact, which raises rather than skips when the company is gone, so a contact and its documents must drop together. The sync cursor deliberately advances over the unfiltered batch so suppressed objects are not refetched every hour. Also fixes the login flake that surfaced this: the E2E /me waiter resolved on the expected pre-auth 401 session check instead of the authenticated response. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZwrpicKhDWgYN7hHAitTm --- apps/workflow/api/xero/sync.py | 39 +++-- .../management/commands/e2e_cleanup.py | 6 +- apps/workflow/services/__init__.py | 12 ++ apps/workflow/services/e2e_artifacts.py | 147 +++++++++++++++++ apps/workflow/tests/test_e2e_artifacts.py | 152 ++++++++++++++++++ .../utils/__tests__/authConsoleErrors.test.ts | 27 ++++ frontend/src/utils/authConsoleErrors.ts | 12 ++ frontend/tests/fixtures/auth.ts | 28 +++- frontend/tests/scripts/e2e-sync-windows.ts | 65 ++++++++ frontend/tests/scripts/global-setup.ts | 24 ++- frontend/tests/scripts/global-teardown.ts | 13 ++ frontend/tests/scripts/history-reporter.ts | 20 ++- 12 files changed, 524 insertions(+), 21 deletions(-) create mode 100644 apps/workflow/services/e2e_artifacts.py create mode 100644 apps/workflow/tests/test_e2e_artifacts.py create mode 100644 frontend/tests/scripts/e2e-sync-windows.ts diff --git a/apps/workflow/api/xero/sync.py b/apps/workflow/api/xero/sync.py index 31841dcc2..0e8bf3184 100644 --- a/apps/workflow/api/xero/sync.py +++ b/apps/workflow/api/xero/sync.py @@ -46,6 +46,7 @@ XeroPaySlip, XeroSyncCursor, ) +from apps.workflow.services.e2e_artifacts import drop_e2e_artifacts from apps.workflow.services.error_persistence import ( app_error_for, persist_app_error, @@ -255,17 +256,29 @@ def sync_xero_data( if not items: break - try: - sync_function(items) - total_processed += len(items) - except XeroValidationError as exc: - persist_xero_error(exc) - raise - except Exception as exc: - persist_app_error(exc) - raise - - # Track the max updated_date_utc across all pages for cursor update + # Drop objects a finished E2E run created in Xero. Filtered here rather + # than inside the sync functions so a suppressed contact and the + # documents referencing it are dropped together, and pagination still + # terminates on the fetched page rather than the filtered one. + items_to_sync = drop_e2e_artifacts(items, our_entity_type) + + if items_to_sync: + try: + sync_function(items_to_sync) + total_processed += len(items_to_sync) + except XeroValidationError as exc: + persist_xero_error(exc) + raise + except Exception as exc: + persist_app_error(exc) + raise + else: + pass # Whole page suppressed; the cursor below still advances past it. + + # Track the max updated_date_utc across all pages for cursor update. + # Deliberately over the fetched items, not the filtered ones, so the + # cursor advances past suppressed objects instead of refetching them + # from Xero every hour. for item in items: item_updated = getattr(item, "updated_date_utc", None) if item_updated and ( @@ -277,9 +290,9 @@ def sync_xero_data( "datetime": timezone.now().isoformat(), "entity": our_entity_type, "severity": "info", - "message": f"Processed {len(items)} {our_entity_type}", + "message": f"Processed {len(items_to_sync)} {our_entity_type}", "progress": None, - "recordsUpdated": len(items), + "recordsUpdated": len(items_to_sync), } # Check if done diff --git a/apps/workflow/management/commands/e2e_cleanup.py b/apps/workflow/management/commands/e2e_cleanup.py index 9666e77cd..72153aaee 100644 --- a/apps/workflow/management/commands/e2e_cleanup.py +++ b/apps/workflow/management/commands/e2e_cleanup.py @@ -19,11 +19,13 @@ from apps.company.models import Company, CompanyPersonLink, Person from apps.job.models import Job, QuoteSpreadsheet from apps.purchasing.models import PurchaseOrder, PurchaseOrderLine +from apps.workflow.services.e2e_artifacts import ( + TEST_COMPANY_NAME, + TEST_DATA_PREFIX, +) logger = logging.getLogger(__name__) -TEST_DATA_PREFIX = "[TEST]" -TEST_COMPANY_NAME = "ABC Carpet Cleaning TEST IGNORE" LEGACY_E2E_PREFIXES = ["E2E Test Client", "E2E Modal Client", "E2E Test Supplier"] diff --git a/apps/workflow/services/__init__.py b/apps/workflow/services/__init__.py index 4b1353095..653b969c4 100644 --- a/apps/workflow/services/__init__.py +++ b/apps/workflow/services/__init__.py @@ -7,6 +7,13 @@ if apps.ready: from .db_scrubber import scrub from .dev_demo_export_scrubber import ScrubResult, scrub_dev_demo_export + from .e2e_artifacts import ( + InboundXeroObject, + XeroContactLike, + drop_e2e_artifacts, + get_closed_e2e_windows, + is_test_company_name, + ) from .error_grouping import ( list_grouped_app_errors, list_grouped_xero_errors, @@ -51,19 +58,24 @@ pass __all__ = [ + "InboundXeroObject", "LLMService", "ScrubResult", "SearchTelemetryService", + "XeroContactLike", "XeroSyncService", "XeroSyncStartResult", "app_error_for", "append_chunk", "apply_text_search", "create_recording", + "drop_e2e_artifacts", "extract_job_context", "extract_request_context", "finalize_instance_onboarding", "get_client_ip", + "get_closed_e2e_windows", + "is_test_company_name", "list_app_errors", "list_grouped_app_errors", "list_grouped_xero_errors", diff --git a/apps/workflow/services/e2e_artifacts.py b/apps/workflow/services/e2e_artifacts.py new file mode 100644 index 000000000..7fe4859cc --- /dev/null +++ b/apps/workflow/services/e2e_artifacts.py @@ -0,0 +1,147 @@ +"""Ignore Xero objects created by finished E2E runs. Development only. + +E2E runs against a live Xero organisation with writes enabled, so the contacts, +invoices and quotes a run creates persist in Xero after the run's database has +been restored from backup. The hourly Xero sync then re-imports them into the +clean database, which is what this module exists to prevent. + +An inbound Xero object is skipped only when *both* hold: + +1. its timestamp falls inside a closed E2E run window, and +2. it belongs to a test company. + +Neither is sufficient alone. The window alone would also discard genuine Xero +changes that happened to land inside it. The name alone is equally true while a +run is executing, and would blind the inbound path the run exists to exercise. + +Run windows live in a temp file written by the E2E harness, not in the database: +teardown restores the database from a backup taken before the run, so database +state cannot describe a run that has just finished. Written by +frontend/tests/scripts/e2e-sync-windows.ts. +""" + +import json +import logging +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Protocol + +from django.conf import settings + +logger = logging.getLogger("xero") + + +class XeroContactLike(Protocol): + """The embedded contact a Xero document carries.""" + + name: str | None + + +class InboundXeroObject(Protocol): + """Structural view of the Xero SDK objects the sync feeds through here. + + The SDK types share no base class, and only some carry a company: contacts + hold their own `name`, documents hold a `contact`, and accounts and stock + hold neither. This declares the one attribute they all have; the rest are + read defensively. + """ + + updated_date_utc: datetime | None + + +# Fixed path so the backend and the E2E harness agree without one telling the +# other; same convention as the E2E lock file. Absent on any machine that has +# never run E2E, which is what keeps this module inert. +E2E_SYNC_WINDOWS_FILE = ( + Path(tempfile.gettempdir()) / "docketworks-e2e-sync-windows.json" +) + +# Names reserved for E2E test data. TEST_DATA_PREFIX marks companies, jobs and +# people a run creates; TEST_COMPANY_NAME is the standing fixture company that +# test jobs — and so the invoices and quotes raised against them — hang off. +# Mirrored in frontend/tests/scripts/db-backup-utils.ts. +TEST_DATA_PREFIX = "[TEST]" +TEST_COMPANY_NAME = "ABC Carpet Cleaning TEST IGNORE" + + +def is_test_company_name(name: str | None) -> bool: + """True when a company name is reserved for E2E test data.""" + if not name: + return False + return name.startswith(TEST_DATA_PREFIX) or name == TEST_COMPANY_NAME + + +def get_closed_e2e_windows() -> list[tuple[datetime, datetime]]: + """Finished E2E runs, as (started_at, ended_at) pairs. + + A run still in progress has no `ended_at` and is skipped: while tests + execute, inbound Xero data must behave exactly as it does in production, + because that round trip is what the run exercises. + """ + if not E2E_SYNC_WINDOWS_FILE.exists(): + return [] + + return [ + ( + datetime.fromisoformat(entry["started_at"]), + datetime.fromisoformat(entry["ended_at"]), + ) + for entry in json.loads(E2E_SYNC_WINDOWS_FILE.read_text()) + if entry["ended_at"] + ] + + +def _owning_company_name(item: InboundXeroObject) -> str | None: + """Name of the company an inbound Xero object belongs to. + + A contact carries its own name. Documents — invoices, quotes, bills, + purchase orders, credit notes — carry an embedded contact. Entities with no + company at all, such as accounts and stock, return None and are never + skipped. + """ + contact: XeroContactLike | None = getattr(item, "contact", None) + if contact is not None: + return contact.name + + name: str | None = getattr(item, "name", None) + return name + + +def _is_e2e_artifact( + item: InboundXeroObject, closed_windows: list[tuple[datetime, datetime]] +) -> bool: + """True when this Xero object was created by a finished E2E run.""" + updated_at: datetime | None = getattr(item, "updated_date_utc", None) + if updated_at is None: + return False + + if not is_test_company_name(_owning_company_name(item)): + return False + + return any(start <= updated_at <= end for start, end in closed_windows) + + +def drop_e2e_artifacts( + items: list[InboundXeroObject], entity_type: str +) -> list[InboundXeroObject]: + """Remove objects created by finished E2E runs from an inbound batch. + + Filtering the batch before it reaches the sync function is deliberate: the + invoice, quote and purchase-order importers resolve their contact through + ``resolve_company_from_xero_contact``, which raises rather than skips when a + company cannot be synced. Dropping a suppressed contact and the documents + that reference it together keeps that path from ever being reached. + """ + if settings.PRODUCTION_LIKE: + return items + + closed_windows = get_closed_e2e_windows() + if not closed_windows: + return items + + kept = [item for item in items if not _is_e2e_artifact(item, closed_windows)] + dropped = len(items) - len(kept) + if dropped: + logger.info("Skipped %d %s created by a finished E2E run", dropped, entity_type) + return kept diff --git a/apps/workflow/tests/test_e2e_artifacts.py b/apps/workflow/tests/test_e2e_artifacts.py new file mode 100644 index 000000000..1c972248c --- /dev/null +++ b/apps/workflow/tests/test_e2e_artifacts.py @@ -0,0 +1,152 @@ +"""Tests for ignoring Xero objects created by finished E2E runs. + +E2E runs write real Contacts, Invoices and Quotes to a development Xero org. +Those survive the post-run database restore, and the hourly Xero sync would +otherwise replay them into the clean database. These tests pin the two +conditions that together decide the skip, and the gate that stops it ever +running outside dev. +""" + +import json +from datetime import timedelta +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase, override_settings +from django.utils import timezone + +from apps.workflow.services.e2e_artifacts import ( + TEST_COMPANY_NAME, + TEST_DATA_PREFIX, + drop_e2e_artifacts, +) + + +def _contact(name: str, updated_at) -> SimpleNamespace: + """An inbound Xero contact: carries its own company name.""" + return SimpleNamespace(name=name, updated_date_utc=updated_at) + + +def _invoice(contact_name: str, updated_at) -> SimpleNamespace: + """An inbound Xero document: carries its company via an embedded contact.""" + return SimpleNamespace( + contact=SimpleNamespace(name=contact_name), + updated_date_utc=updated_at, + ) + + +class E2EWindowFileTestCase(TestCase): + """Base that points the module at a temporary windows file.""" + + def setUp(self): + self.now = timezone.now() + self.run_start = self.now - timedelta(minutes=30) + self.run_end = self.now - timedelta(minutes=5) + self.during_run = self.now - timedelta(minutes=20) + + self._tmp = TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.windows_file = Path(self._tmp.name) / "sync-windows.json" + + patcher = patch( + "apps.workflow.services.e2e_artifacts.E2E_SYNC_WINDOWS_FILE", + self.windows_file, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def write_window(self, *, ended: bool): + self.windows_file.write_text( + json.dumps( + [ + { + "run_id": "testrun1", + "started_at": self.run_start.isoformat(), + "ended_at": self.run_end.isoformat() if ended else None, + } + ] + ) + ) + + +class DropE2EArtifactsTests(E2EWindowFileTestCase): + def test_test_company_inside_closed_window_is_dropped(self): + self.write_window(ended=True) + item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), []) + + @override_settings(PRODUCTION_LIKE=True) + def test_production_like_never_drops_anything(self): + """Every server is production_like; only DJANGO_ENV=local is not. + + Discarding inbound Xero data is only ever correct against a development + org, so a stray windows file must not be enough to cause it. + """ + self.write_window(ended=True) + item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) + + def test_fixture_company_inside_closed_window_is_dropped(self): + """The standing fixture company is test data too. + + Test jobs hang off it, so the invoices and quotes a run raises are its + documents, not those of a [TEST]-prefixed company. + """ + self.write_window(ended=True) + item = _contact(TEST_COMPANY_NAME, self.during_run) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), []) + + def test_ordinary_company_inside_closed_window_is_kept(self): + """The window alone must never suppress; this is the over-reach guard. + + A real Xero edit landing inside a run's window would otherwise be + discarded, and because the sync cursor still advances it would never be + fetched again. + """ + self.write_window(ended=True) + item = _contact("Morris Sheetmetal", self.during_run) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) + + def test_test_company_outside_any_window_is_kept(self): + self.write_window(ended=True) + item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.now) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) + + def test_open_window_suppresses_nothing(self): + """The mid-run guarantee. + + While a run executes, inbound Xero data must behave exactly as it does + in production — that round trip is what the run exercises. Fails if the + ended_at condition is ever dropped. + """ + self.write_window(ended=False) + item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) + + def test_document_is_dropped_with_its_contact(self): + """A suppressed contact's documents must go with it. + + The invoice, quote and purchase-order importers resolve their contact + through resolve_company_from_xero_contact, which raises rather than + skips when the company cannot be synced. Leaving the document behind + would abort the whole sync run. + """ + self.write_window(ended=True) + test_contact = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) + its_invoice = _invoice(f"{TEST_DATA_PREFIX} Company 123", self.during_run) + + self.assertEqual(drop_e2e_artifacts([test_contact, its_invoice], "mixed"), []) + + def test_absent_windows_file_suppresses_nothing(self): + """The ordinary state of any machine that has never run E2E.""" + item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) + + self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) diff --git a/frontend/src/utils/__tests__/authConsoleErrors.test.ts b/frontend/src/utils/__tests__/authConsoleErrors.test.ts index e3e48e8e0..3d9599961 100644 --- a/frontend/src/utils/__tests__/authConsoleErrors.test.ts +++ b/frontend/src/utils/__tests__/authConsoleErrors.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { createLoginSessionCheckConsoleAllowance, + isLoginCompletionResponse, isUnauthenticatedSessionCheckResponse, LOGIN_ME_PATH, UNAUTHENTICATED_SESSION_CHECK_CONSOLE_ERROR, + type AuthResponseEvent, type CapturedBrowserError, } from '@/utils/authConsoleErrors' @@ -90,6 +92,31 @@ describe('auth E2E console allowance', () => { expect(allowance.consumeIfExpected(console401(7000))).toBe(false) }) + it('login /me waiter selects the authenticated response over the expected pre-auth 401', () => { + // Order the E2E login fixture actually observes: the app's unauthenticated session-check + // 401 can land in the login window before the authenticated 200. The waiter must resolve + // on the 200, never the 401 — otherwise login flakes (~1/35) on slow hydration. + const preAuth401: AuthResponseEvent = { pathname: LOGIN_ME_PATH, method: 'GET', status: 401 } + const authenticated200: AuthResponseEvent = { + pathname: LOGIN_ME_PATH, + method: 'GET', + status: 200, + } + + expect(isLoginCompletionResponse(preAuth401)).toBe(false) + expect(isLoginCompletionResponse(authenticated200)).toBe(true) + // The waiter takes the first accepted response from the observed sequence. + expect([preAuth401, authenticated200].filter(isLoginCompletionResponse)).toEqual([ + authenticated200, + ]) + }) + + it('login /me waiter ignores non-/me responses', () => { + expect( + isLoginCompletionResponse({ pathname: '/api/accounts/token/', method: 'POST', status: 200 }), + ).toBe(false) + }) + it('does not consume page errors with the same text', () => { const allowance = createLoginSessionCheckConsoleAllowance(() => 1000) const stop = allowance.startLoginWindow() diff --git a/frontend/src/utils/authConsoleErrors.ts b/frontend/src/utils/authConsoleErrors.ts index b04af9241..d39d71a18 100644 --- a/frontend/src/utils/authConsoleErrors.ts +++ b/frontend/src/utils/authConsoleErrors.ts @@ -25,6 +25,18 @@ export function isUnauthenticatedSessionCheckResponse(event: AuthResponseEvent): return event.pathname === LOGIN_ME_PATH && event.method === 'GET' && event.status === 401 } +// The E2E login flow waits for the authenticated GET /me to confirm login completed. During +// the same login window the app also fires an expected unauthenticated GET /me → 401 (see +// isUnauthenticatedSessionCheckResponse); the waiter must skip that and resolve only on the +// authenticated response, or it flakes when the 401 lands after the waiter is registered. +export function isLoginCompletionResponse(event: AuthResponseEvent): boolean { + return ( + event.pathname === LOGIN_ME_PATH && + event.method === 'GET' && + !isUnauthenticatedSessionCheckResponse(event) + ) +} + export function createLoginSessionCheckConsoleAllowance(now: () => number = Date.now): { startLoginWindow: () => () => void recordResponse: (event: AuthResponseEvent) => void diff --git a/frontend/tests/fixtures/auth.ts b/frontend/tests/fixtures/auth.ts index 43e97af45..bd8bb3524 100644 --- a/frontend/tests/fixtures/auth.ts +++ b/frontend/tests/fixtures/auth.ts @@ -12,6 +12,7 @@ import { } from './helpers' import { createLoginSessionCheckConsoleAllowance, + isLoginCompletionResponse, LOGIN_ME_PATH, type CapturedBrowserError, } from '@/utils/authConsoleErrors' @@ -52,16 +53,32 @@ function isExpectedBrowserError(text: string, patterns: ReadonlyArray<string | R ) } -function waitForLoginResponse(page: Page, path: string, method: 'GET' | 'POST'): Promise<Response> { +function waitForLoginResponse( + page: Page, + path: string, + method: 'GET' | 'POST', + accept: (response: Response) => boolean = () => true, +): Promise<Response> { return page.waitForResponse( (candidate) => { const url = new URL(candidate.url()) - return url.pathname === path && candidate.request().method() === method + return url.pathname === path && candidate.request().method() === method && accept(candidate) }, { timeout: INFINITE_TIMEOUT }, ) } +// Adapts a Playwright Response to the login-completion decision (isLoginCompletionResponse): +// the /me waiter must skip the expected pre-auth 401 session check and resolve on the +// authenticated response, or it flakes when the 401 lands after the waiter is registered. +function isAuthenticatedMeResponse(response: Response): boolean { + return isLoginCompletionResponse({ + method: response.request().method(), + pathname: new URL(response.url()).pathname, + status: response.status(), + }) +} + async function responseDiagnostics(label: string, response?: Response): Promise<string> { if (!response) { return `${label}: no response observed` @@ -139,7 +156,12 @@ async function authenticateViaLoginPage( await expect(submitButton).toBeEnabled() const tokenResponsePromise = waitForLoginResponse(page, LOGIN_TOKEN_PATH, 'POST') - const meResponsePromise = waitForLoginResponse(page, LOGIN_ME_PATH, 'GET') + const meResponsePromise = waitForLoginResponse( + page, + LOGIN_ME_PATH, + 'GET', + isAuthenticatedMeResponse, + ) void tokenResponsePromise.catch(() => undefined) void meResponsePromise.catch(() => undefined) diff --git a/frontend/tests/scripts/e2e-sync-windows.ts b/frontend/tests/scripts/e2e-sync-windows.ts new file mode 100644 index 000000000..ee54d20e9 --- /dev/null +++ b/frontend/tests/scripts/e2e-sync-windows.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs' +import os from 'os' +import path from 'path' + +/** + * Records the wall-clock span of each E2E run, so the backend Xero sync can + * ignore the contacts, invoices and quotes the run created in Xero. + * + * E2E writes to a live Xero org, and those objects outlive the database + * restore that teardown performs. The hourly Xero poll would otherwise replay + * them into the clean database an hour later. + * + * Deliberately a file, not a database table: teardown restores the database + * from a backup taken before the run, so nothing written to the database + * during a run can describe that run afterwards. Fixed path in the system temp + * dir, same convention as the E2E lock file, so the backend agrees on it + * without being told. + * + * Read by apps/workflow/services/e2e_artifacts.py. + */ +const WINDOWS_FILE = path.join(os.tmpdir(), 'docketworks-e2e-sync-windows.json') + +type SyncWindow = { + run_id: string + started_at: string + ended_at: string | null +} + +function read(): SyncWindow[] { + if (!fs.existsSync(WINDOWS_FILE)) { + return [] + } + return JSON.parse(fs.readFileSync(WINDOWS_FILE, 'utf8')) as SyncWindow[] +} + +function write(windows: SyncWindow[]): void { + fs.mkdirSync(path.dirname(WINDOWS_FILE), { recursive: true }) + fs.writeFileSync(WINDOWS_FILE, JSON.stringify(windows, null, 2), 'utf8') +} + +/** + * Open this run's window. Left open for the whole run: while tests execute, + * inbound Xero data must behave exactly as it does in production, because that + * round trip is what the run exercises. Only closed windows suppress. + */ +export function openSyncWindow(runId: string): void { + const windows = read() + windows.push({ run_id: runId, started_at: new Date().toISOString(), ended_at: null }) + write(windows) +} + +/** + * Close this run's window, making everything it created in Xero inert. + */ +export function closeSyncWindow(runId: string): void { + const windows = read() + const window = windows.find((w) => w.run_id === runId) + if (!window) { + throw new Error(`No E2E sync window recorded for run ${runId} in ${WINDOWS_FILE}`) + } + window.ended_at = new Date().toISOString() + write(windows) +} + +export { WINDOWS_FILE } diff --git a/frontend/tests/scripts/global-setup.ts b/frontend/tests/scripts/global-setup.ts index b55a74eaf..3054f2ce8 100644 --- a/frontend/tests/scripts/global-setup.ts +++ b/frontend/tests/scripts/global-setup.ts @@ -9,9 +9,19 @@ import { checkSafeToTest, syncSequences, } from './db-backup-utils' +import { openSyncWindow } from './e2e-sync-windows' const LOCK_FILE = path.join(os.tmpdir(), 'playwright-e2e.lock') +/** + * Identifier for this run, shared by the history reporter and this run's Xero + * sync window. Minted here rather than in the reporter because setup runs + * first and the window must carry it. + */ +function mintRunId(): string { + return Math.random().toString(36).substring(2, 10) +} + function formatTimestamp(date: Date): string { const pad = (value: number) => value.toString().padStart(2, '0') return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad( @@ -140,6 +150,14 @@ export default async function globalSetup() { syncSequences(dbConfig) console.log('[db] Sequences synced.') + // Open this run's Xero sync window. It stays OPEN for the whole run: while + // tests execute, inbound Xero data must behave exactly as it does in + // production, because that round trip is what the run exercises. Teardown + // closes it, and only closed windows suppress. + const runId = mintRunId() + openSyncWindow(runId) + console.log(`[e2e] Run ${runId}: Xero sync window opened.`) + // Take backup console.log('[db] Backing up database before tests...') const backupDir = getBackupsDir() @@ -179,8 +197,10 @@ export default async function globalSetup() { } // Record backup path in the lock file (line 2) so teardown knows a backup - // was taken in this run and where to find it. - fs.appendFileSync(LOCK_FILE, `\n${backupFile}`, 'utf8') + // was taken in this run and where to find it, then the run id (line 3) so + // teardown can close this run's sync window. Order matters: teardown reads + // the backup path positionally. + fs.appendFileSync(LOCK_FILE, `\n${backupFile}\n${runId}`, 'utf8') console.log(`[db] Backup complete: ${backupFile}`) } diff --git a/frontend/tests/scripts/global-teardown.ts b/frontend/tests/scripts/global-teardown.ts index 48ee00106..8474e4b15 100644 --- a/frontend/tests/scripts/global-teardown.ts +++ b/frontend/tests/scripts/global-teardown.ts @@ -11,6 +11,7 @@ import { runPsql, syncSequences, } from './db-backup-utils' +import { closeSyncWindow } from './e2e-sync-windows' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const LOCK_FILE = path.join(os.tmpdir(), 'playwright-e2e.lock') @@ -204,6 +205,18 @@ function restoreDatabase(lockContents: string) { console.log('[db] Syncing sequences...') syncSequences(dbConfig) + // Close this run's Xero sync window. Until now it was open, so nothing this + // run created in Xero was suppressed on the way back in — that round trip is + // what the run exercises. Closing it makes the run's Xero artifacts inert, so + // the hourly poll can no longer replay them into the restored database. + const runId = lockContents.split('\n')[2]?.trim() + if (!runId) { + console.warn('[e2e] No run id in lock file; sync window left open.') + } else { + closeSyncWindow(runId) + console.log(`[e2e] Run ${runId}: Xero sync window closed.`) + } + // Prove the restored DB is E2E-clean before deleting the backup. This also // catches any Xero/Celery work that still managed to land after the restore. console.log('[db] Running post-restore E2E safety check...') diff --git a/frontend/tests/scripts/history-reporter.ts b/frontend/tests/scripts/history-reporter.ts index c25573af8..8caf68f58 100644 --- a/frontend/tests/scripts/history-reporter.ts +++ b/frontend/tests/scripts/history-reporter.ts @@ -8,12 +8,30 @@ import type { TestStep, } from '@playwright/test/reporter' import * as fs from 'fs' +import os from 'os' import * as path from 'path' import { execFileSync } from 'child_process' import { fileURLToPath } from 'url' import AdmZip from 'adm-zip' const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const LOCK_FILE = path.join(os.tmpdir(), 'playwright-e2e.lock') + +/** + * The run id minted by global-setup (lock file line 3), shared with this run's + * Xero sync window so the two can be correlated. Falls back to a fresh id when + * the lock file isn't there — the id only labels history rows, and a reporter + * that threw would turn a clean pre-flight abort into a confusing crash. + */ +function readRunId(): string { + if (fs.existsSync(LOCK_FILE)) { + const runId = fs.readFileSync(LOCK_FILE, 'utf8').split('\n')[2]?.trim() + if (runId) { + return runId + } + } + return Math.random().toString(36).substring(2, 10) +} type CompletedStatus = 'passed' | 'failed' | 'timedOut' | 'interrupted' | 'perf-fail' @@ -195,7 +213,7 @@ export default class HistoryReporter implements Reporter { onBegin(_config: FullConfig, suite: Suite): void { this.rootSuite = suite - this.runId = Math.random().toString(36).substring(2, 10) + this.runId = readRunId() this.runDate = new Date().toISOString() this.gitMetadata = getGitMetadata() this.completedSteps = [] From 7a4a1fe0f7ed51bd30920a4a0c5e290013eb67dc Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sun, 26 Jul 2026 08:41:40 +1200 Subject: [PATCH 54/66] test(e2e): add mypy type annotations to test_e2e_artifacts Strict mypy (no-untyped-def) requires return + parameter annotations; add them (test methods -> None, helper updated_at: datetime). No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- apps/workflow/tests/test_e2e_artifacts.py | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/workflow/tests/test_e2e_artifacts.py b/apps/workflow/tests/test_e2e_artifacts.py index 1c972248c..ba68860ba 100644 --- a/apps/workflow/tests/test_e2e_artifacts.py +++ b/apps/workflow/tests/test_e2e_artifacts.py @@ -8,7 +8,7 @@ """ import json -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace @@ -24,12 +24,12 @@ ) -def _contact(name: str, updated_at) -> SimpleNamespace: +def _contact(name: str, updated_at: datetime) -> SimpleNamespace: """An inbound Xero contact: carries its own company name.""" return SimpleNamespace(name=name, updated_date_utc=updated_at) -def _invoice(contact_name: str, updated_at) -> SimpleNamespace: +def _invoice(contact_name: str, updated_at: datetime) -> SimpleNamespace: """An inbound Xero document: carries its company via an embedded contact.""" return SimpleNamespace( contact=SimpleNamespace(name=contact_name), @@ -40,7 +40,7 @@ def _invoice(contact_name: str, updated_at) -> SimpleNamespace: class E2EWindowFileTestCase(TestCase): """Base that points the module at a temporary windows file.""" - def setUp(self): + def setUp(self) -> None: self.now = timezone.now() self.run_start = self.now - timedelta(minutes=30) self.run_end = self.now - timedelta(minutes=5) @@ -57,7 +57,7 @@ def setUp(self): patcher.start() self.addCleanup(patcher.stop) - def write_window(self, *, ended: bool): + def write_window(self, *, ended: bool) -> None: self.windows_file.write_text( json.dumps( [ @@ -72,14 +72,14 @@ def write_window(self, *, ended: bool): class DropE2EArtifactsTests(E2EWindowFileTestCase): - def test_test_company_inside_closed_window_is_dropped(self): + def test_test_company_inside_closed_window_is_dropped(self) -> None: self.write_window(ended=True) item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) self.assertEqual(drop_e2e_artifacts([item], "contacts"), []) @override_settings(PRODUCTION_LIKE=True) - def test_production_like_never_drops_anything(self): + def test_production_like_never_drops_anything(self) -> None: """Every server is production_like; only DJANGO_ENV=local is not. Discarding inbound Xero data is only ever correct against a development @@ -90,7 +90,7 @@ def test_production_like_never_drops_anything(self): self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) - def test_fixture_company_inside_closed_window_is_dropped(self): + def test_fixture_company_inside_closed_window_is_dropped(self) -> None: """The standing fixture company is test data too. Test jobs hang off it, so the invoices and quotes a run raises are its @@ -101,7 +101,7 @@ def test_fixture_company_inside_closed_window_is_dropped(self): self.assertEqual(drop_e2e_artifacts([item], "contacts"), []) - def test_ordinary_company_inside_closed_window_is_kept(self): + def test_ordinary_company_inside_closed_window_is_kept(self) -> None: """The window alone must never suppress; this is the over-reach guard. A real Xero edit landing inside a run's window would otherwise be @@ -113,13 +113,13 @@ def test_ordinary_company_inside_closed_window_is_kept(self): self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) - def test_test_company_outside_any_window_is_kept(self): + def test_test_company_outside_any_window_is_kept(self) -> None: self.write_window(ended=True) item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.now) self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) - def test_open_window_suppresses_nothing(self): + def test_open_window_suppresses_nothing(self) -> None: """The mid-run guarantee. While a run executes, inbound Xero data must behave exactly as it does @@ -131,7 +131,7 @@ def test_open_window_suppresses_nothing(self): self.assertEqual(drop_e2e_artifacts([item], "contacts"), [item]) - def test_document_is_dropped_with_its_contact(self): + def test_document_is_dropped_with_its_contact(self) -> None: """A suppressed contact's documents must go with it. The invoice, quote and purchase-order importers resolve their contact @@ -145,7 +145,7 @@ def test_document_is_dropped_with_its_contact(self): self.assertEqual(drop_e2e_artifacts([test_contact, its_invoice], "mixed"), []) - def test_absent_windows_file_suppresses_nothing(self): + def test_absent_windows_file_suppresses_nothing(self) -> None: """The ordinary state of any machine that has never run E2E.""" item = _contact(f"{TEST_DATA_PREFIX} Company 123", self.during_run) From de977914ab9bb266d3335ec10f9733c3037549e1 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sun, 26 Jul 2026 09:59:32 +1200 Subject: [PATCH 55/66] fix(staff): make the staff API JSON-only, upload icons separately Creating a staff member always returned 400, and editing any current employee did too. The three staff endpoints accepted only multipart, and zodios serialises a body with formData.append(), which turns null into the literal string "null". A blank Date Left therefore arrived as "null" and DRF's DateField rejected it. Every current employee has a blank date_left, so the only staff you could edit were ones already offboarded. The encoding was the defect, not the field. Multipart cannot express null, numbers, booleans or arrays, so GenericStaffMethodsMixin existed purely to rebuild the types it destroyed ("" -> [], "" -> "0.00"). That exception list never covered dates. The endpoints were multipart only to carry the optional profile icon -- and PUT/PATCH never even sent one. Give the staff resource JSONParser and move the icon to its own multipart endpoint, mirroring the company-logo and job-file split. The coercion mixin is deleted rather than extended. As a side effect the icon now uploads on edit as well as create; previously the file was silently discarded on edit, with no error shown. Two latent bugs surfaced once a staff member could actually have a photo. Neither was reachable before, because no staff row has ever had one: - icon_url was built with request.build_absolute_uri, but USE_X_FORWARDED_HOST is set only under PRODUCTION_LIKE. Behind a proxy that yields the internal host, and the browser blocks the image as a cross-origin loopback request. It now returns a site-root-relative path, matching what workshop_view, daily_timesheet_service and kanban_service already emit and what StaffAvatar's startsWith('/') branch was written to consume. - KanbanJobPersonSerializer declared icon_url as URLField, which reaches the generated client as z.string().url() and rejects a relative path. kanban_service already returned relative URLs, so this was armed for the first upload. Now CharField. Tests: staff create/edit had no backend coverage and the edit path had no E2E coverage at all, which is how the regression reached main. Adds backend tests for the JSON contract (blank date_left leaves staff active, clearing it reinstates, groups round-trip as arrays) and for the icon endpoint, plus E2E covering create, edit, and a photo upload that must actually serve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- apps/accounts/__init__.py | 4 - apps/accounts/models.py | 3 +- apps/accounts/serializers.py | 83 ++----- apps/accounts/tests/test_staff_api.py | 240 ++++++++++++++++++++- apps/accounts/urls.py | 6 + apps/accounts/views/__init__.py | 2 + apps/accounts/views/staff_api.py | 21 +- apps/accounts/views/staff_icon_api.py | 75 +++++++ apps/job/serializers/kanban_serializer.py | 6 +- docs/urls/accounts.md | 1 + frontend/schema.yml | 135 ++++++++---- frontend/src/api/generated/api.ts | 41 ++-- frontend/src/components/StaffFormModal.vue | 27 ++- frontend/src/composables/useStaffApi.ts | 19 ++ frontend/src/views/AdminStaffView.vue | 1 + frontend/tests/staff/create-staff.spec.ts | 132 +++++++++++- mypy-baseline.txt | 5 - 17 files changed, 642 insertions(+), 159 deletions(-) create mode 100644 apps/accounts/views/staff_icon_api.py diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py index 4754efe70..8ed6cda3d 100644 --- a/apps/accounts/__init__.py +++ b/apps/accounts/__init__.py @@ -10,10 +10,8 @@ from .models import Staff, StaffManager from .permissions import CanManageTimesheets, IsStaff, IsSuperuser from .serializers import ( - BaseStaffSerializer, CustomTokenObtainPairSerializer, EmptySerializer, - GenericStaffMethodsMixin, KanbanStaffSerializer, StaffCreateSerializer, StaffSerializer, @@ -34,11 +32,9 @@ __all__ = [ "AccountsConfig", - "BaseStaffSerializer", "CanManageTimesheets", "CustomTokenObtainPairSerializer", "EmptySerializer", - "GenericStaffMethodsMixin", "IsStaff", "IsSuperuser", "KanbanStaffSerializer", diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 817ba5a86..fbe85628f 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -109,9 +109,10 @@ class Staff(AbstractBaseUser, PermissionsMixin): ] # Internal fields not exposed via API (write-only or internal use). + # `icon` is deliberately absent: it is written only by the dedicated icon + # upload endpoint and read via the icon_url property. STAFF_INTERNAL_FIELDS = [ "password", - "icon", # Raw ImageField - use icon_url property for API ] # Computed properties exposed via API (read-only). diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 1c5440eff..b62fc113c 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,5 +1,4 @@ import logging -from decimal import Decimal from typing import Any, Dict, Optional from django.contrib.auth import authenticate @@ -11,18 +10,20 @@ logger = logging.getLogger(__name__) -def _build_icon_url(staff: Staff, context: Optional[Dict[str, Any]]) -> Optional[str]: - """ - Build an absolute icon URL when possible, otherwise fall back to the stored path. +def _build_icon_url(staff: Staff) -> Optional[str]: + """Return the icon path relative to the site root. + + Deliberately relative. The browser resolves it against its own origin, so + the same value is correct behind ngrok in dev and behind the proxy in + UAT/production. Building an absolute URL from the request instead leaks the + internal host (http://localhost:8000/...) wherever the forwarded-host + headers aren't trusted, and the browser then blocks the image as a + cross-origin request to the loopback address space. """ if not staff.icon: return None - request = (context or {}).get("request") - try: - return request.build_absolute_uri(staff.icon.url) if request else staff.icon.url - except Exception: - return staff.icon.url + return staff.icon.url class EmptySerializer(serializers.Serializer): @@ -68,62 +69,11 @@ def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: raise serializers.ValidationError("Invalid credentials") -class GenericStaffMethodsMixin: - """ - Utilitary methods shared between StaffSerializer and StaffCreateSerializer - - Normalises arrays received as "" (groups, user_permissions) - - Normalises decimal fields received as "" to "0.00" (wage_rate, hours_*) - """ - - ARRAY_FIELDS = ["groups", "user_permissions"] - DECIMAL_FIELDS = [ - "base_wage_rate", - "hours_mon", - "hours_tue", - "hours_wed", - "hours_thu", - "hours_fri", - "hours_sat", - "hours_sun", - ] - - def to_internal_value(self, data: Any) -> Dict[str, Any]: - is_querydict = hasattr(data, "getlist") - if is_querydict: - data = data.copy() - - for field in self.ARRAY_FIELDS: - if is_querydict: - values = data.getlist(field) - if values == [""] or values == [] or not values: - data.setlist(field, []) - else: - value = data.get(field) - if value in ("", None): - data[field] = [] - - for field in self.DECIMAL_FIELDS: - if is_querydict: - value = data.get(field) - if value == "": - data[field] = "0.00" - else: - if field in data and data[field] == "": - data[field] = str(Decimal("0.00")) - - return super().to_internal_value(data) - - -class BaseStaffSerializer(GenericStaffMethodsMixin, serializers.ModelSerializer): - """Base serializer for Staff model with shared logic for create and update operations.""" - - -class StaffSerializer(BaseStaffSerializer): - icon = serializers.ImageField(required=False, allow_null=True, write_only=True) +class StaffSerializer(serializers.ModelSerializer[Staff]): icon_url = serializers.SerializerMethodField(read_only=True) def get_icon_url(self, obj: Staff) -> Optional[str]: - return _build_icon_url(obj, self.context) + return _build_icon_url(obj) def update(self, instance: Staff, validated_data: Dict[str, Any]) -> Staff: password = validated_data.pop("password", None) @@ -153,16 +103,14 @@ class Meta: "preferred_name": {"required": False}, "xero_user_id": {"required": False}, "date_left": {"required": False}, - "icon": {"required": False, "write_only": True}, } -class StaffCreateSerializer(BaseStaffSerializer): - icon = serializers.ImageField(required=False, allow_null=True, write_only=True) +class StaffCreateSerializer(serializers.ModelSerializer[Staff]): icon_url = serializers.SerializerMethodField(read_only=True) def get_icon_url(self, obj: Staff) -> Optional[str]: - return _build_icon_url(obj, self.context) + return _build_icon_url(obj) def create(self, validated_data: Dict[str, Any]) -> Staff: password = validated_data.pop("password", None) @@ -199,7 +147,6 @@ class Meta: "xero_user_id": {"required": False}, "date_left": {"required": False}, "password_needs_reset": {"required": False}, - "icon": {"required": False, "write_only": True}, } @@ -211,7 +158,7 @@ class KanbanStaffSerializer(serializers.ModelSerializer): icon_url = serializers.SerializerMethodField() def get_icon_url(self, obj: Staff) -> Optional[str]: - return _build_icon_url(obj, self.context) + return _build_icon_url(obj) class Meta: model = Staff diff --git a/apps/accounts/tests/test_staff_api.py b/apps/accounts/tests/test_staff_api.py index 0a116c5c2..46ab1c879 100644 --- a/apps/accounts/tests/test_staff_api.py +++ b/apps/accounts/tests/test_staff_api.py @@ -1,12 +1,26 @@ +import datetime +import io +import os + from django.contrib.auth.models import Group +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection from django.test.utils import CaptureQueriesContext +from PIL import Image +from rest_framework.response import Response from rest_framework.test import APIClient from apps.accounts.models import Staff from apps.testing import BaseTestCase +def _png_bytes(size: int = 8) -> bytes: + """Build a real PNG so ImageField validation has something to accept.""" + buffer = io.BytesIO() + Image.new("RGB", (size, size), color="red").save(buffer, format="PNG") + return buffer.getvalue() + + class StaffListCreateAPIViewTests(BaseTestCase): def test_staff_list_prefetches_groups_for_serializer(self): office_user = Staff.objects.create_user( @@ -77,4 +91,228 @@ def test_staff_cannot_be_deleted_via_api(self) -> None: response = client.delete(f"/api/accounts/staff/{target.id}/") self.assertEqual(response.status_code, 405) - self.assertTrue(Staff.objects.filter(pk=target.id).exists()) + + +class StaffJSONContractTests(BaseTestCase): + """The staff resource is JSON-only. + + These pin the shapes the admin staff form actually sends. They exist + because the endpoints previously accepted only multipart, which cannot + express null, numbers, or arrays — every value arrived as a string and a + serializer mixin hand-rebuilt the types. A blank Date Left was serialised + as the literal text "null" and rejected, breaking staff create and edit. + """ + + def setUp(self) -> None: + super().setUp() + self.admin = Staff.objects.create_user( + email="admin@example.test", + password="testpass", + first_name="Admin", + last_name="User", + is_office_staff=True, + ) + self.client_api = APIClient() + self.client_api.force_authenticate(user=self.admin) + + def test_create_leaves_a_new_staff_member_active(self) -> None: + """A new staff member has no leaving date, so they are current.""" + response = self.client_api.post( + "/api/accounts/staff/", + { + "email": "newstarter@example.test", + "first_name": "New", + "last_name": "Starter", + "password": "TestPassword123!", + "base_wage_rate": 32.5, + "date_left": None, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201, response.content) + created = Staff.objects.get(email="newstarter@example.test") + self.assertIsNone(created.date_left) + self.assertTrue(created.is_currently_active) + + def test_setting_date_left_offboards_a_staff_member(self) -> None: + target = Staff.objects.create_user( + email="leaving@example.test", + password="testpass", + first_name="Going", + last_name="Away", + ) + + response = self.client_api.patch( + f"/api/accounts/staff/{target.id}/", + {"date_left": "2026-07-01"}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + target.refresh_from_db() + self.assertEqual(target.date_left, datetime.date(2026, 7, 1)) + + def test_clearing_date_left_reinstates_an_offboarded_staff_member(self) -> None: + """Clearing the Date Left field brings someone back onto the books. + + This is why date_left is always sent rather than omitted — omitting a + field cannot clear it on a PATCH. + """ + target = Staff.objects.create_user( + email="returning@example.test", + password="testpass", + first_name="Back", + last_name="Again", + ) + target.date_left = datetime.date(2026, 1, 31) + target.save(update_fields=["date_left"]) + + response = self.client_api.patch( + f"/api/accounts/staff/{target.id}/", + {"date_left": None}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + target.refresh_from_db() + self.assertIsNone(target.date_left) + self.assertTrue(target.is_currently_active) + + def test_groups_round_trip_as_a_json_array(self) -> None: + """Permissions arrive as a real array, not a comma-joined string.""" + group = Group.objects.create(name="Estimators") + target = Staff.objects.create_user( + email="grouped@example.test", + password="testpass", + first_name="Group", + last_name="Member", + ) + + response = self.client_api.patch( + f"/api/accounts/staff/{target.id}/", + {"groups": [group.id]}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(list(target.groups.values_list("id", flat=True)), [group.id]) + + def test_empty_groups_array_clears_membership(self) -> None: + group = Group.objects.create(name="Temporary") + target = Staff.objects.create_user( + email="ungrouped@example.test", + password="testpass", + first_name="No", + last_name="Groups", + ) + target.groups.add(group) + + response = self.client_api.patch( + f"/api/accounts/staff/{target.id}/", + {"groups": []}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(list(target.groups.all()), []) + + +class StaffIconAPIViewTests(BaseTestCase): + """Profile pictures upload through their own endpoint. + + The staff resource is JSON, which cannot carry a file, so the icon has a + dedicated multipart endpoint — the same split already used for company + logos and job files. + """ + + def setUp(self) -> None: + super().setUp() + self.admin = Staff.objects.create_user( + email="iconadmin@example.test", + password="testpass", + first_name="Icon", + last_name="Admin", + is_office_staff=True, + ) + self.target = Staff.objects.create_user( + email="photographed@example.test", + password="testpass", + first_name="Photo", + last_name="Subject", + ) + self.client_api = APIClient() + self.client_api.force_authenticate(user=self.admin) + + def _upload(self, upload: SimpleUploadedFile, staff_id: object = None) -> Response: + return self.client_api.post( + f"/api/accounts/staff/{staff_id or self.target.id}/icon/", + {"file": upload}, + format="multipart", + ) + + def test_upload_sets_the_icon_and_returns_its_url(self) -> None: + response = self._upload( + SimpleUploadedFile("face.png", _png_bytes(), content_type="image/png") + ) + + self.assertEqual(response.status_code, 200, response.content) + self.target.refresh_from_db() + self.assertTrue(self.target.icon) + + # Relative on purpose: the browser resolves it against its own origin. + # An absolute URL built from the request would embed the internal host + # and be blocked as a cross-origin request wherever the app is proxied. + icon_url = response.data["icon_url"] + self.assertTrue(icon_url.startswith("/"), icon_url) + + def test_replacing_an_icon_removes_the_previous_file(self) -> None: + """Repeated uploads must not leave orphaned images on disk.""" + self._upload( + SimpleUploadedFile("first.png", _png_bytes(), content_type="image/png") + ) + self.target.refresh_from_db() + first_path = self.target.icon.path + self.assertTrue(os.path.exists(first_path)) + + self._upload( + SimpleUploadedFile("second.png", _png_bytes(16), content_type="image/png") + ) + self.target.refresh_from_db() + + self.assertNotEqual(self.target.icon.path, first_path) + self.assertFalse(os.path.exists(first_path)) + self.assertTrue(os.path.exists(self.target.icon.path)) + + def test_upload_rejects_a_non_image_extension(self) -> None: + response = self._upload( + SimpleUploadedFile("resume.txt", b"not an image", content_type="text/plain") + ) + + self.assertEqual(response.status_code, 400) + self.target.refresh_from_db() + self.assertFalse(self.target.icon) + + def test_upload_rejects_a_file_over_the_size_limit(self) -> None: + oversized = SimpleUploadedFile( + "huge.png", b"x" * (5 * 1024 * 1024 + 1), content_type="image/png" + ) + + response = self._upload(oversized) + + self.assertEqual(response.status_code, 400) + + def test_upload_requires_a_file(self) -> None: + response = self.client_api.post( + f"/api/accounts/staff/{self.target.id}/icon/", {}, format="multipart" + ) + + self.assertEqual(response.status_code, 400) + + def test_upload_to_an_unknown_staff_member_is_not_found(self) -> None: + response = self._upload( + SimpleUploadedFile("face.png", _png_bytes(), content_type="image/png"), + staff_id="00000000-0000-0000-0000-000000000000", + ) + + self.assertEqual(response.status_code, 404) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 9a538761b..9554a9737 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -6,6 +6,7 @@ StaffListCreateAPIView, StaffRetrieveUpdateAPIView, ) +from apps.accounts.views.staff_icon_api import StaffIconAPIView from apps.accounts.views.staff_views import ( StaffListAPIView, get_staff_rates, @@ -42,4 +43,9 @@ StaffRetrieveUpdateAPIView.as_view(), name="api_staff_detail", ), + path( + "staff/<uuid:pk>/icon/", + StaffIconAPIView.as_view(), + name="api_staff_icon", + ), ] diff --git a/apps/accounts/views/__init__.py b/apps/accounts/views/__init__.py index 8e2f2e068..1f630bb3a 100644 --- a/apps/accounts/views/__init__.py +++ b/apps/accounts/views/__init__.py @@ -11,6 +11,7 @@ if apps.ready: from .staff_api import StaffListCreateAPIView, StaffRetrieveUpdateAPIView + from .staff_icon_api import StaffIconAPIView except (ImportError, RuntimeError): # Django not ready or circular import, skip conditional imports pass @@ -21,6 +22,7 @@ "GetCurrentUserAPIView", "LogoutUserAPIView", "SecurityPasswordChangeView", + "StaffIconAPIView", "StaffListAPIView", "StaffListCreateAPIView", "StaffRetrieveUpdateAPIView", diff --git a/apps/accounts/views/staff_api.py b/apps/accounts/views/staff_api.py index 6c1b00ea4..fba54e30c 100644 --- a/apps/accounts/views/staff_api.py +++ b/apps/accounts/views/staff_api.py @@ -3,7 +3,7 @@ from drf_spectacular.utils import OpenApiExample, extend_schema from rest_framework import generics, status from rest_framework.exceptions import ValidationError -from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.parsers import JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -17,7 +17,7 @@ @extend_schema( summary="List and create staff members", description="API endpoint for listing all staff members and creating new staff members. " - "Supports multipart/form data for file uploads (e.g., profile pictures).", + "Profile pictures are uploaded separately via the staff icon endpoint.", tags=["Staff Management"], examples=[ OpenApiExample( @@ -46,13 +46,13 @@ class StaffListCreateAPIView(generics.ListCreateAPIView): """API endpoint for listing and creating staff members. Supports both GET (list all staff) and POST (create new staff) operations. - Requires authentication and staff permissions. Handles multipart/form data - for file uploads (e.g., profile pictures). + Requires authentication and staff permissions. Accepts JSON only; profile + pictures are uploaded separately via StaffIconAPIView. """ queryset = Staff.objects.all() permission_classes = [IsAuthenticated, IsStaff] - parser_classes = [MultiPartParser, FormParser] + parser_classes = [JSONParser] def get_queryset(self): return Staff.objects.prefetch_related( @@ -68,7 +68,7 @@ def get_serializer_class(self): @extend_schema( summary="Create a new staff member", description="Create a new staff member with the provided details. " - "Supports multipart/form data for file uploads (e.g., profile pictures).", + "Profile pictures are uploaded separately via the staff icon endpoint.", tags=["Staff Management"], request=StaffCreateSerializer, responses={201: StaffSerializer}, @@ -90,7 +90,8 @@ def post(self, request, *args, **kwargs): summary="Retrieve or update staff member", description="API endpoint for retrieving and updating individual staff members. " "Supports GET (retrieve) and PUT/PATCH (update). " - "Includes comprehensive logging for update operations and handles multipart/form data for file uploads. " + "Includes comprehensive logging for update operations. " + "Profile pictures are uploaded separately via the staff icon endpoint. " "Staff are not deleted; offboarding is done by setting date_left.", tags=["Staff Management"], examples=[ @@ -115,8 +116,8 @@ class StaffRetrieveUpdateAPIView(generics.RetrieveUpdateAPIView[Staff]): """API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update) on specific staff members. - Includes comprehensive logging for update operations and handles - multipart/form data for file uploads. + Includes comprehensive logging for update operations. Accepts JSON only; + profile pictures are uploaded separately via StaffIconAPIView. Staff are never deleted (their time entries are protected); offboarding is done by setting date_left. @@ -125,7 +126,7 @@ class StaffRetrieveUpdateAPIView(generics.RetrieveUpdateAPIView[Staff]): queryset = Staff.objects.all() serializer_class = StaffSerializer permission_classes = [IsAuthenticated, IsStaff] - parser_classes = [MultiPartParser, FormParser] + parser_classes = [JSONParser] def get_queryset(self): return Staff.objects.prefetch_related( diff --git a/apps/accounts/views/staff_icon_api.py b/apps/accounts/views/staff_icon_api.py new file mode 100644 index 000000000..94b6e0222 --- /dev/null +++ b/apps/accounts/views/staff_icon_api.py @@ -0,0 +1,75 @@ +import logging +import os + +from drf_spectacular.utils import extend_schema +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts.models import Staff +from apps.accounts.permissions import IsStaff +from apps.accounts.serializers import StaffSerializer +from apps.workflow.services.error_persistence import persist_app_error +from apps.workflow.views.company_defaults_logo_api import ( + ALLOWED_EXTENSIONS, + MAX_UPLOAD_SIZE, +) + +logger = logging.getLogger(__name__) + + +@extend_schema( + summary="Upload a staff profile picture", + description="Replace a staff member's profile picture. This is a separate " + "endpoint because the staff resource itself is JSON-only — a file cannot " + "ride inside a JSON body.", + tags=["Staff Management"], + request={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + } + }, + responses={200: StaffSerializer}, +) +class StaffIconAPIView(APIView): + """Upload the profile picture for a single staff member.""" + + serializer_class = StaffSerializer + parser_classes = [MultiPartParser, FormParser] + permission_classes = [IsAuthenticated, IsStaff] + + def post(self, request: Request, pk: str) -> Response: + staff = Staff.objects.filter(pk=pk).first() + if staff is None: + return Response({"error": "Staff member not found"}, status=404) + + file = request.data.get("file") + if not file: + return Response({"error": "No file provided"}, status=400) + + if file.size > MAX_UPLOAD_SIZE: + return Response({"error": "File too large (max 5MB)"}, status=400) + + ext = os.path.splitext(file.name)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + return Response({"error": f"Unsupported file type: {ext}"}, status=400) + + try: + # Drop the previous image so replacing an icon does not orphan a file. + # Every staff icon is a user upload under MEDIA_ROOT/staff_icons, so + # unlike company logos there is no shipped asset to protect here. + if staff.icon: + staff.icon.delete(save=False) + + staff.icon = file + staff.save(update_fields=["icon"]) + except Exception as exc: + persist_app_error(exc) + raise + + logger.info(f"[StaffIcon] Updated icon for Staff ID: {pk}") + return Response(StaffSerializer(staff, context={"request": request}).data) diff --git a/apps/job/serializers/kanban_serializer.py b/apps/job/serializers/kanban_serializer.py index 5df3dc9e0..92e114fcb 100644 --- a/apps/job/serializers/kanban_serializer.py +++ b/apps/job/serializers/kanban_serializer.py @@ -85,7 +85,11 @@ class KanbanJobPersonSerializer(serializers.Serializer): id = serializers.UUIDField() display_name = serializers.CharField() - icon_url = serializers.URLField(allow_null=True) + # CharField, not URLField: icon URLs are site-root-relative (/media/...) so + # the browser resolves them against its own origin. URLField would declare + # format: uri, which the generated client turns into a z.string().url() + # check that a relative path fails. + icon_url = serializers.CharField(allow_null=True) class KanbanJobSerializer(serializers.Serializer): diff --git a/docs/urls/accounts.md b/docs/urls/accounts.md index c5a638b42..925b2bfac 100644 --- a/docs/urls/accounts.md +++ b/docs/urls/accounts.md @@ -22,6 +22,7 @@ |-------------|------|------|-------------| | `/staff/` | `staff_api.StaffListCreateAPIView` | `accounts:api_staff_list_create` | API endpoint for listing and creating staff members. | | `/staff/<uuid:pk>/` | `staff_api.StaffRetrieveUpdateAPIView` | `accounts:api_staff_detail` | API endpoint for retrieving and updating individual staff members. | +| `/staff/<uuid:pk>/icon/` | `staff_icon_api.StaffIconAPIView` | `accounts:api_staff_icon` | Upload the profile picture for a single staff member. | | `/staff/all/` | `staff_views.StaffListAPIView` | `accounts:api_staff_all_list` | API endpoint for retrieving list of staff members for Kanban board. | | `/staff/rates/<uuid:staff_id>/` | `staff_views.get_staff_rates` | `accounts:get_staff_rates` | Retrieve wage rates for a specific staff member. | diff --git a/frontend/schema.yml b/frontend/schema.yml index 83ad73b28..7fb415c51 100644 --- a/frontend/schema.yml +++ b/frontend/schema.yml @@ -621,7 +621,7 @@ paths: get: operationId: accounts_staff_list description: API endpoint for listing all staff members and creating new staff - members. Supports multipart/form data for file uploads (e.g., profile pictures). + members. Profile pictures are uploaded separately via the staff icon endpoint. summary: List and create staff members tags: - Staff Management @@ -638,19 +638,34 @@ paths: description: '' post: operationId: accounts_staff_create - description: Create a new staff member with the provided details. Supports multipart/form - data for file uploads (e.g., profile pictures). + description: Create a new staff member with the provided details. Profile pictures + are uploaded separately via the staff icon endpoint. summary: Create a new staff member tags: - Staff Management requestBody: content: - multipart/form-data: - schema: - $ref: '#/components/schemas/StaffCreateRequest' - application/x-www-form-urlencoded: + application/json: schema: $ref: '#/components/schemas/StaffCreateRequest' + examples: + CreateStaffMember: + value: + email: john.doe@example.com + first_name: John + last_name: Doe + preferred_name: Johnny + password: securepassword123 + wage_rate: '25.50' + is_office_staff: true + hours_mon: '8.00' + hours_tue: '8.00' + hours_wed: '8.00' + hours_thu: '8.00' + hours_fri: '8.00' + hours_sat: '0.00' + hours_sun: '0.00' + summary: Create Staff Member required: true security: - cookieAuth: [] @@ -666,8 +681,8 @@ paths: operationId: accounts_staff_retrieve description: API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging - for update operations and handles multipart/form data for file uploads. Staff - are not deleted; offboarding is done by setting date_left. + for update operations. Profile pictures are uploaded separately via the staff + icon endpoint. Staff are not deleted; offboarding is done by setting date_left. summary: Retrieve or update staff member parameters: - in: path @@ -691,8 +706,8 @@ paths: operationId: accounts_staff_update description: API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging - for update operations and handles multipart/form data for file uploads. Staff - are not deleted; offboarding is done by setting date_left. + for update operations. Profile pictures are uploaded separately via the staff + icon endpoint. Staff are not deleted; offboarding is done by setting date_left. summary: Retrieve or update staff member parameters: - in: path @@ -705,12 +720,22 @@ paths: - Staff Management requestBody: content: - multipart/form-data: - schema: - $ref: '#/components/schemas/StaffRequest' - application/x-www-form-urlencoded: + application/json: schema: $ref: '#/components/schemas/StaffRequest' + examples: + UpdateStaffMember: + value: + first_name: Jane + last_name: Smith + preferred_name: Janie + wage_rate: '28.00' + hours_mon: '7.50' + hours_tue: '7.50' + hours_wed: '7.50' + hours_thu: '7.50' + hours_fri: '7.50' + summary: Update Staff Member required: true security: - cookieAuth: [] @@ -725,8 +750,8 @@ paths: operationId: accounts_staff_partial_update description: API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging - for update operations and handles multipart/form data for file uploads. Staff - are not deleted; offboarding is done by setting date_left. + for update operations. Profile pictures are uploaded separately via the staff + icon endpoint. Staff are not deleted; offboarding is done by setting date_left. summary: Retrieve or update staff member parameters: - in: path @@ -739,12 +764,58 @@ paths: - Staff Management requestBody: content: - multipart/form-data: + application/json: schema: $ref: '#/components/schemas/PatchedStaffRequest' - application/x-www-form-urlencoded: + examples: + UpdateStaffMember: + value: + first_name: Jane + last_name: Smith + preferred_name: Janie + wage_rate: '28.00' + hours_mon: '7.50' + hours_tue: '7.50' + hours_wed: '7.50' + hours_thu: '7.50' + hours_fri: '7.50' + summary: Update Staff Member + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Staff' + description: '' + /api/accounts/staff/{id}/icon/: + post: + operationId: accounts_staff_icon_create + description: Replace a staff member's profile picture. This is a separate endpoint + because the staff resource itself is JSON-only — a file cannot ride inside + a JSON body. + summary: Upload a staff profile picture + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Staff Management + requestBody: + content: + multipart/form-data: schema: - $ref: '#/components/schemas/PatchedStaffRequest' + type: object + properties: + file: + type: string + format: binary + required: + - file security: - cookieAuth: [] responses: @@ -16053,7 +16124,6 @@ components: type: string icon_url: type: string - format: uri nullable: true required: - display_name @@ -17690,8 +17760,6 @@ components: $ref: '#/components/schemas/PurchaseOrderLineUpdateRequest' PatchedStaffRequest: type: object - description: Base serializer for Staff model with shared logic for create and - update operations. properties: email: type: string @@ -17811,11 +17879,6 @@ components: writeOnly: true minLength: 1 maxLength: 128 - icon: - type: string - format: binary - writeOnly: true - nullable: true PatchedStockItemRequest: type: object description: Serializer for individual stock items. @@ -21385,8 +21448,6 @@ components: * `quality` - Quality - Prioritize Quality Staff: type: object - description: Base serializer for Staff model with shared logic for create and - update operations. properties: id: type: string @@ -21544,8 +21605,6 @@ components: - wage_rate StaffCreateRequest: type: object - description: Base serializer for Staff model with shared logic for create and - update operations. properties: email: type: string @@ -21675,11 +21734,6 @@ components: writeOnly: true minLength: 1 maxLength: 128 - icon: - type: string - format: binary - writeOnly: true - nullable: true required: - email - first_name @@ -21961,8 +22015,6 @@ components: - wage_rate StaffRequest: type: object - description: Base serializer for Staff model with shared logic for create and - update operations. properties: email: type: string @@ -22082,11 +22134,6 @@ components: writeOnly: true minLength: 1 maxLength: 128 - icon: - type: string - format: binary - writeOnly: true - nullable: true required: - email - first_name diff --git a/frontend/src/api/generated/api.ts b/frontend/src/api/generated/api.ts index 8419775bd..a95fbad03 100644 --- a/frontend/src/api/generated/api.ts +++ b/frontend/src/api/generated/api.ts @@ -495,7 +495,6 @@ const StaffCreateRequest = z.object({ groups: z.array(z.number().int()).optional(), user_permissions: z.array(z.number().int()).optional(), password: z.string().min(1).max(128), - icon: z.instanceof(File).nullish(), }) const StaffRequest = z.object({ email: z.string().min(1).max(254).email(), @@ -519,7 +518,6 @@ const StaffRequest = z.object({ groups: z.array(z.number().int()).optional(), user_permissions: z.array(z.number().int()).optional(), password: z.string().min(1).max(128).optional(), - icon: z.instanceof(File).nullish(), }) const PatchedStaffRequest = z .object({ @@ -544,7 +542,6 @@ const PatchedStaffRequest = z groups: z.array(z.number().int()), user_permissions: z.array(z.number().int()), password: z.string().min(1).max(128), - icon: z.instanceof(File).nullable(), }) .partial() const KanbanStaff = z.object({ @@ -2069,7 +2066,7 @@ const PreviewQuoteResponse = z const KanbanJobPerson = z.object({ id: z.string().uuid(), display_name: z.string(), - icon_url: z.string().url().nullable(), + icon_url: z.string().nullable(), }) const KanbanJob = z.object({ id: z.string().uuid(), @@ -4652,7 +4649,7 @@ Returns: method: 'get', path: '/api/accounts/staff/', alias: 'accounts_staff_list', - description: `API endpoint for listing all staff members and creating new staff members. Supports multipart/form data for file uploads (e.g., profile pictures).`, + description: `API endpoint for listing all staff members and creating new staff members. Profile pictures are uploaded separately via the staff icon endpoint.`, requestFormat: 'json', response: z.array(Staff), }, @@ -4660,8 +4657,8 @@ Returns: method: 'post', path: '/api/accounts/staff/', alias: 'accounts_staff_create', - description: `Create a new staff member with the provided details. Supports multipart/form data for file uploads (e.g., profile pictures).`, - requestFormat: 'form-data', + description: `Create a new staff member with the provided details. Profile pictures are uploaded separately via the staff icon endpoint.`, + requestFormat: 'json', parameters: [ { name: 'body', @@ -4675,7 +4672,7 @@ Returns: method: 'get', path: '/api/accounts/staff/:id/', alias: 'accounts_staff_retrieve', - description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations and handles multipart/form data for file uploads. Staff are not deleted; offboarding is done by setting date_left.`, + description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations. Profile pictures are uploaded separately via the staff icon endpoint. Staff are not deleted; offboarding is done by setting date_left.`, requestFormat: 'json', parameters: [ { @@ -4690,8 +4687,8 @@ Returns: method: 'put', path: '/api/accounts/staff/:id/', alias: 'accounts_staff_update', - description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations and handles multipart/form data for file uploads. Staff are not deleted; offboarding is done by setting date_left.`, - requestFormat: 'form-data', + description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations. Profile pictures are uploaded separately via the staff icon endpoint. Staff are not deleted; offboarding is done by setting date_left.`, + requestFormat: 'json', parameters: [ { name: 'body', @@ -4710,8 +4707,8 @@ Returns: method: 'patch', path: '/api/accounts/staff/:id/', alias: 'accounts_staff_partial_update', - description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations and handles multipart/form data for file uploads. Staff are not deleted; offboarding is done by setting date_left.`, - requestFormat: 'form-data', + description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations. Profile pictures are uploaded separately via the staff icon endpoint. Staff are not deleted; offboarding is done by setting date_left.`, + requestFormat: 'json', parameters: [ { name: 'body', @@ -4726,6 +4723,26 @@ Returns: ], response: Staff, }, + { + method: 'post', + path: '/api/accounts/staff/:id/icon/', + alias: 'accounts_staff_icon_create', + description: `Replace a staff member's profile picture. This is a separate endpoint because the staff resource itself is JSON-only — a file cannot ride inside a JSON body.`, + requestFormat: 'form-data', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ file: z.instanceof(File) }), + }, + { + name: 'id', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: Staff, + }, { method: 'get', path: '/api/accounts/staff/all/', diff --git a/frontend/src/components/StaffFormModal.vue b/frontend/src/components/StaffFormModal.vue index 9ce5e19c7..a074970b5 100644 --- a/frontend/src/components/StaffFormModal.vue +++ b/frontend/src/components/StaffFormModal.vue @@ -61,6 +61,7 @@ id="preferred_name" v-model="form.preferred_name" placeholder="Preferred Name" + data-automation-id="StaffFormModal-preferred-name" /> </div> <div class="w-1/2"> @@ -191,6 +192,7 @@ type="file" accept="image/*" class="hidden" + data-automation-id="StaffFormModal-icon" @change="onFileChange" /> </label> @@ -382,7 +384,7 @@ const emit = defineEmits(['close', 'saved']) const isLoading = ref(false) const avatarInput = ref<HTMLInputElement | null>(null) -const { createStaff, updateStaff } = useStaffApi() +const { createStaff, updateStaff, uploadStaffIcon } = useStaffApi() const form = ref({ first_name: '', last_name: '', @@ -605,22 +607,33 @@ async function submitForm() { } try { // API data is baseData (password already included if provided, no password_confirmation) + // The icon is not part of this payload — it uploads separately below. const apiData = { ...baseData } - // Include icon for create operations - if (!props.staff && form.value.icon) { - apiData.icon = form.value.icon - } - console.log('StaffFormModal - API data being sent:', apiData) + let staffId: string if (props.staff) { await updateStaff(props.staff.id, apiData) + staffId = props.staff.id toast.success('Staff member updated successfully!') } else { - await createStaff(apiData as z.infer<typeof schemas.StaffCreateRequest>) + const created = await createStaff(apiData as z.infer<typeof schemas.StaffCreateRequest>) + staffId = created.id toast.success('Staff member created successfully!') } + + // The staff record is already saved, so a failed photo upload must not be + // reported as a failed save — and must not be swallowed either. + if (form.value.icon) { + try { + await uploadStaffIcon(staffId, form.value.icon) + } catch (iconError) { + console.error('StaffFormModal - Icon upload error:', iconError) + toast.error('Staff member saved, but the photo could not be uploaded.') + } + } + emit('saved') } catch (e) { console.error('StaffFormModal - Save error:', e) diff --git a/frontend/src/composables/useStaffApi.ts b/frontend/src/composables/useStaffApi.ts index eda5c21ea..1335943b2 100644 --- a/frontend/src/composables/useStaffApi.ts +++ b/frontend/src/composables/useStaffApi.ts @@ -65,6 +65,24 @@ export function useStaffApi() { } } + /** + * Upload a profile picture. The staff resource itself is JSON, which cannot + * carry a file, so the icon has its own multipart endpoint. + */ + async function uploadStaffIcon(id: string, file: File): Promise<Staff> { + error.value = null + try { + return await api.accounts_staff_icon_create({ file }, { params: { id: String(id) } }) + } catch (e: unknown) { + if (e instanceof Error) { + error.value = e.message + } else { + error.value = 'Failed to upload staff photo.' + } + throw e + } + } + async function listStaffForKanban(): Promise<KanbanStaff[]> { error.value = null try { @@ -90,6 +108,7 @@ export function useStaffApi() { listStaffForKanban, createStaff, updateStaff, + uploadStaffIcon, error, } } diff --git a/frontend/src/views/AdminStaffView.vue b/frontend/src/views/AdminStaffView.vue index e16b5dfe0..bf4393e74 100644 --- a/frontend/src/views/AdminStaffView.vue +++ b/frontend/src/views/AdminStaffView.vue @@ -82,6 +82,7 @@ <td class="px-4 py-3">{{ formatDateTime(staff.date_joined) }}</td> <td class="px-4 py-3 text-center"> <button + :data-automation-id="`AdminStaffView-edit-staff-${staff.id}`" @click="editStaff(staff)" class="inline-flex items-center p-1 text-indigo-600 hover:text-indigo-900 transition-colors duration-150 hover:scale-110 active:scale-95" aria-label="Edit" diff --git a/frontend/tests/staff/create-staff.spec.ts b/frontend/tests/staff/create-staff.spec.ts index e9e077917..cc30af0d8 100644 --- a/frontend/tests/staff/create-staff.spec.ts +++ b/frontend/tests/staff/create-staff.spec.ts @@ -1,12 +1,41 @@ import { test, expect } from '../fixtures/auth' +import { getStaffList } from '../fixtures/api' import { autoId, dismissToasts } from '../fixtures/helpers' -test.describe('create staff', () => { - test('can create a new staff member', async ({ authenticatedPage: page }) => { - const timestamp = Date.now() - const testEmail = `e2e.test.${timestamp}@example.com` - const testPassword = 'TestPassword123!' +type StaffRow = { + id: string + preferred_name: string | null + date_left: string | null + icon_url: string | null +} + +async function findStaff(page: Parameters<typeof getStaffList>[0], id: string): Promise<StaffRow> { + const staffList: StaffRow[] = await getStaffList(page) + const match = staffList.find((s) => s.id === id) + if (!match) throw new Error(`Staff ${id} not found in the staff list`) + return match +} + +async function openStaffModal(page: Parameters<typeof getStaffList>[0], staffId: string) { + await page.goto('/admin/staff') + await page.waitForLoadState('networkidle') + await autoId(page, `AdminStaffView-edit-staff-${staffId}`).click() + await page.locator('[data-slot="dialog-content"]').waitFor({ timeout: 10000 }) +} + +// A 1x1 PNG — the smallest thing the backend's image validation will accept. +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +) +test.describe.serial('staff administration', () => { + const timestamp = Date.now() + const testEmail = `e2e.test.${timestamp}@example.com` + const testPassword = 'TestPassword123!' + let staffId: string + + test('can create a new staff member', async ({ authenticatedPage: page }) => { await test.step('navigate to staff management page', async () => { await page.goto('/admin/staff') await page.waitForLoadState('networkidle') @@ -25,6 +54,8 @@ test.describe('create staff', () => { await autoId(page, 'StaffFormModal-email').fill(testEmail) await autoId(page, 'StaffFormModal-password').fill(testPassword) await autoId(page, 'StaffFormModal-password-confirm').fill(testPassword) + // A real wage rate, so the numeric fields are exercised rather than left at 0. + await page.locator('#base_wage_rate').fill('32.5') }) await test.step('submit form and verify success', async () => { @@ -40,7 +71,8 @@ test.describe('create staff', () => { ) await autoId(page, 'StaffFormModal-submit').click() - await responsePromise + const response = await responsePromise + staffId = (await response.json()).id // Modal should close await page.locator('[data-slot="dialog-content"]').waitFor({ @@ -53,5 +85,93 @@ test.describe('create staff', () => { timeout: 5000, }) }) + + await test.step('new staff member is active', async () => { + // A blank Date Left must persist as "no leaving date". Regressions here + // hide the person from kanban, timesheets and payroll. + const created = await findStaff(page, staffId) + expect(created.date_left).toBeNull() + }) + }) + + test('can edit an existing staff member', async ({ authenticatedPage: page }) => { + const preferredName = `Preferred ${timestamp}` + + await test.step('open the staff member for editing', async () => { + await openStaffModal(page, staffId) + }) + + await test.step('change preferred name and save', async () => { + await autoId(page, 'StaffFormModal-preferred-name').fill(preferredName) + await dismissToasts(page) + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(`/api/accounts/staff/${staffId}/`) && + response.request().method() === 'PATCH' && + response.status() === 200, + { timeout: 15000 }, + ) + + await autoId(page, 'StaffFormModal-submit').click() + await responsePromise + + await page.locator('[data-slot="dialog-content"]').waitFor({ + state: 'hidden', + timeout: 10000, + }) + }) + + await test.step('the change persists and the staff member stays active', async () => { + const updated = await findStaff(page, staffId) + expect(updated.preferred_name).toBe(preferredName) + // Editing someone with no leaving date must not offboard them. + expect(updated.date_left).toBeNull() + }) + }) + + test('can upload a profile photo for an existing staff member', async ({ + authenticatedPage: page, + }) => { + await test.step('staff member starts with no photo', async () => { + const before = await findStaff(page, staffId) + expect(before.icon_url).toBeNull() + }) + + await test.step('choose a photo and save', async () => { + await openStaffModal(page, staffId) + await autoId(page, 'StaffFormModal-icon').setInputFiles({ + name: 'mugshot.png', + mimeType: 'image/png', + buffer: PNG_1X1, + }) + await dismissToasts(page) + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(`/api/accounts/staff/${staffId}/icon/`) && + response.request().method() === 'POST' && + response.status() === 200, + { timeout: 15000 }, + ) + + await autoId(page, 'StaffFormModal-submit').click() + await responsePromise + }) + + await test.step('the photo is stored and served', async () => { + const after = await findStaff(page, staffId) + expect(after.icon_url).toBeTruthy() + + // Relative on purpose, so the browser resolves it against its own origin. + // An absolute URL built from the request embeds the internal host and the + // browser blocks the image as a cross-origin loopback request. + expect(after.icon_url).toMatch(/^\//) + + // The URL must actually serve an image, not just be recorded in the DB. + const image = await page.request.get(after.icon_url as string) + expect(image.status()).toBe(200) + expect(image.headers()['content-type']).toContain('image') + }) }) }) diff --git a/mypy-baseline.txt b/mypy-baseline.txt index f37dd7267..ef4670fa9 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -2563,14 +2563,9 @@ apps/workflow/authentication.py:0: error: Call to untyped function "mark_used" i apps/workflow/extensions.py:0: error: Call to untyped function "__init_subclass__" in typed context [no-untyped-call] apps/workflow/extensions.py:0: error: Function is missing a type annotation [no-untyped-def] apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/accounts/serializers.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] -apps/accounts/serializers.py:0: error: "to_internal_value" undefined in superclass [misc] apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/accounts/serializers.py:0: error: Returning Any from function declared to return "Staff" [no-any-return] -apps/accounts/serializers.py:0: error: Returning Any from function declared to return "Staff" [no-any-return] apps/accounts/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/job/mixins.py:0: error: Missing type arguments for generic type "GenericAPIView" [type-arg] From cfabdc9024e2eaf1d2f3a5d8845487eb0512cf54 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sun, 26 Jul 2026 10:11:49 +1200 Subject: [PATCH 56/66] refactor(workflow): delete dead /api/enums/ endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get_enum_choices view at /api/enums/<enum_name>/ had no consumers: the frontend reads enums from the generated OpenAPI zod client (ADR 0021), and no backend code or test invoked it. Its defensive machinery never did useful work — the import/hasattr/fallback cascade resolved no real case, and the "helpful 404" branch imported bare job.enums/workflow.enums against an apps.-rooted package, so it silently returned 500 since inception. Removes the view, the URL route, the autogenerated re-export, the stale schema-coverage exemption, the docs category override, and regenerates the URL docs. Per ADR 0032 (less code) and ADR 0017 (no "for safety" retention). Also folds in an unrelated in-progress StaffAvatar.vue edit that was in the worktree (uses staff.icon_url directly instead of the origin-prefixing computed), committed together per the all-or-nothing worktree policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- apps/workflow/api/__init__.py | 3 - apps/workflow/api/enums.py | 93 ------------------- .../tests/test_api_schema_coverage.py | 2 - apps/workflow/urls.py | 2 - docs/urls/workflow.md | 1 - frontend/src/components/StaffAvatar.vue | 13 ++- scripts/generate_url_docs.py | 1 - 7 files changed, 6 insertions(+), 109 deletions(-) delete mode 100644 apps/workflow/api/enums.py diff --git a/apps/workflow/api/__init__.py b/apps/workflow/api/__init__.py index a66c07cdb..8116d61c9 100644 --- a/apps/workflow/api/__init__.py +++ b/apps/workflow/api/__init__.py @@ -1,7 +1,5 @@ # This file is autogenerated by update_init.py script -from .enums import get_enum_choices - # Conditional imports (only when Django is ready) try: from django.apps import apps @@ -15,5 +13,4 @@ __all__ = [ "FiftyPerPagePagination", "PageSizePagination", - "get_enum_choices", ] diff --git a/apps/workflow/api/enums.py b/apps/workflow/api/enums.py deleted file mode 100644 index 3941e5048..000000000 --- a/apps/workflow/api/enums.py +++ /dev/null @@ -1,93 +0,0 @@ -import importlib -import inspect -import logging - -from django.http import HttpRequest, JsonResponse -from django.views.decorators.http import require_http_methods - -logger = logging.getLogger(__name__) - - -@require_http_methods(["GET"]) -def get_enum_choices(request: HttpRequest, enum_name: str) -> JsonResponse: - """ - API endpoint to get enum choices. - Returns the choices for the specified enum as a JSON object. - - Args: - request: The HTTP request - enum_name: The name of the enum to get choices for (e.g., 'MetalType') - - Returns: - JsonResponse with the enum choices - """ - if not request.user.is_authenticated: - return JsonResponse({"error": "Authentication required"}, status=401) - - try: - # First check the job enums module - try: - enums_module = importlib.import_module("apps.job.enums") - - if hasattr(enums_module, enum_name): - enum_class = getattr(enums_module, enum_name) - - if hasattr(enum_class, "choices"): - choices = [ - {"value": value, "display_name": display_name} - for value, display_name in enum_class.choices - ] - return JsonResponse({"choices": choices}) - except (ImportError, AttributeError): - # Fall back to workflow enums - pass - - # Fall back to workflow.enums if not found in job.enums - enums_module = importlib.import_module("apps.workflow.enums") - - if hasattr(enums_module, enum_name): - enum_class = getattr(enums_module, enum_name) - - if hasattr(enum_class, "choices"): - choices = [ - {"value": value, "display_name": display_name} - for value, display_name in enum_class.choices - ] - return JsonResponse({"choices": choices}) - else: - return JsonResponse( - { - "error": f'"{enum_name}" does not appear to be a valid Django Choices enum' - }, - status=400, - ) - else: - # List available enums from both modules - job_enums_module = importlib.import_module("job.enums") - workflow_enums_module = importlib.import_module("workflow.enums") - - job_enums = [ - name - for name, obj in inspect.getmembers(job_enums_module) - if inspect.isclass(obj) and hasattr(obj, "choices") - ] - - workflow_enums = [ - name - for name, obj in inspect.getmembers(workflow_enums_module) - if inspect.isclass(obj) and hasattr(obj, "choices") - ] - - return JsonResponse( - { - "error": f'Enum "{enum_name}" not found', - "available_enums": {"job": job_enums, "workflow": workflow_enums}, - }, - status=404, - ) - - except Exception as e: - logger.exception(f"Unexpected error getting enum choices: {e}") - return JsonResponse( - {"error": "An unexpected server error occurred."}, status=500 - ) diff --git a/apps/workflow/tests/test_api_schema_coverage.py b/apps/workflow/tests/test_api_schema_coverage.py index 301c516f3..9b82dc953 100644 --- a/apps/workflow/tests/test_api_schema_coverage.py +++ b/apps/workflow/tests/test_api_schema_coverage.py @@ -31,8 +31,6 @@ "api/xero/webhook/", # AWS instance management (internal ops, not frontend) "api/aws/", - # Enum endpoint (internal, values embedded in schema) - "api/enums/", # DRF router roots (meta-endpoints listing sub-routes, not real APIs) "api/workflow/", "api/companies/", diff --git a/apps/workflow/urls.py b/apps/workflow/urls.py index 6b123231f..f17f7ba25 100644 --- a/apps/workflow/urls.py +++ b/apps/workflow/urls.py @@ -7,7 +7,6 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from apps.workflow.api.enums import get_enum_choices from apps.workflow.views.ai_provider_viewset import AIProviderViewSet from apps.workflow.views.app_error_grouped_view import ( AppErrorGroupedListView, @@ -85,7 +84,6 @@ SessionReplayFrontendErrorView.as_view(), name="session-replay-frontend-error", ), - path("enums/<str:enum_name>/", get_enum_choices, name="get_enum_choices"), path( "xero/authenticate/", xero_view.xero_authenticate, diff --git a/docs/urls/workflow.md b/docs/urls/workflow.md index 723c652a0..fb4e5185d 100644 --- a/docs/urls/workflow.md +++ b/docs/urls/workflow.md @@ -58,7 +58,6 @@ | URL Pattern | View | Name | Description | |-------------|------|------|-------------| | `/company-defaults/` | `company_defaults_api.CompanyDefaultsAPIView` | `api_company_defaults` | API view for managing company default settings. | -| `/enums/<str:enum_name>/` | `get_enum_choices` | `get_enum_choices` | API endpoint to get enum choices. | | `/xero-errors/` | `xero_view.XeroErrorListAPIView` | `xero-error-list` | API view for listing Xero synchronization errors. | | `/xero-errors/<uuid:pk>/` | `xero_view.XeroErrorDetailAPIView` | `xero-error-detail` | API view for retrieving a single Xero synchronization error. | diff --git a/frontend/src/components/StaffAvatar.vue b/frontend/src/components/StaffAvatar.vue index b2cd7ca5f..2497d7228 100644 --- a/frontend/src/components/StaffAvatar.vue +++ b/frontend/src/components/StaffAvatar.vue @@ -13,7 +13,12 @@ :title="displayName" :data-staff-id="staff.id" > - <img v-if="iconUrl" :src="iconUrl" :alt="displayName" class="w-full h-full object-cover" /> + <img + v-if="staff.icon_url" + :src="staff.icon_url" + :alt="displayName" + class="w-full h-full object-cover" + /> <div v-else class="w-full h-full flex items-center justify-center text-white font-bold" @@ -55,12 +60,6 @@ defineEmits<{ click: [] }>() -const iconUrl = computed(() => { - const icon = props.staff.icon_url - if (!icon) return null - return icon.startsWith('/') ? `${window.location.origin}${icon}` : icon -}) - const displayName = computed((): string => { // For KanbanJobPerson, use display_name directly if ('display_name' in props.staff && (props.staff as KanbanJobPerson).display_name) { diff --git a/scripts/generate_url_docs.py b/scripts/generate_url_docs.py index ae4f39be7..22f10366d 100644 --- a/scripts/generate_url_docs.py +++ b/scripts/generate_url_docs.py @@ -413,7 +413,6 @@ def _categorize_url( "home": "Main Redirect", "api_company_defaults": "System", "get_env_variable": "System", - "get_enum_choices": "System", "login": "Authentication", "logout": "Authentication", "xero_sync_progress": "Xero Integration", From f5e66a1edad2a4587be14cc78e8dcc698eee2a41 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sun, 26 Jul 2026 11:00:11 +1200 Subject: [PATCH 57/66] fix(staff): add icon removal, relative logo URLs, and stop tests writing to mediafiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the staff API split. The icon endpoint gains DELETE, so a profile picture can be removed and not just replaced. This also lets the E2E clean up after itself: teardown restores the database but not MEDIA_ROOT, so a run that uploaded a photo left the file behind for good. _build_logo_url had the same defect already fixed for staff icons — it built an absolute URL from the request, but USE_X_FORWARDED_HOST is only set under PRODUCTION_LIKE, so behind a proxy it emits the internal host and the browser blocks the image. Now site-root-relative. Safe: PDF rendering reads company.logo.path from the filesystem, not this URL. The icon tests were writing real files into the developer's own mediafiles/ tree, because settings_test.py does not override MEDIA_ROOT. That leaked an image per test run, worse than the E2E leak above. MEDIA_ROOT is now redirected to a temporary directory for that test class. Both try/except blocks in the icon view are gone. Neither reshaped the failure nor added business context — a storage error there means nothing the request path doesn't already say — so they only claimed the AppError row without contributing to it. The `if staff.icon:` guard went too: Django's FieldFile.delete() already opens with `if not self: return` and nulls the field, so the branch was duplicated logic wrapped around a no-op else. ADR 0019 gains the rule those removals follow, which it never stated: a `try` needs a strong reason, because `try` is where you handle the failure — reshape it, or persist it from the layer that understands it well enough to add business context. It scopes the existing "every except block persists" rather than contradicting it. ADR 0001's and CLAUDE.md's canonical snippets now pass job_id, so the example shows a handler with a reason to exist; the conversion example stays bare, since conversion is its own justification. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 6 +- apps/accounts/tests/test_staff_api.py | 57 +++++++++++++- apps/accounts/views/staff_icon_api.py | 78 ++++++++++++------- apps/workflow/serializers.py | 24 +++--- .../0001-exception-already-logged-dedup.md | 2 +- docs/adr/0019-mandatory-error-persistence.md | 2 + docs/urls/accounts.md | 2 +- frontend/schema.yml | 24 ++++++ frontend/src/api/generated/api.ts | 15 ++++ frontend/tests/staff/create-staff.spec.ts | 10 +++ mypy-baseline.txt | 1 - 11 files changed, 176 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6c6a1c706..2f4a35543 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,9 @@ ADR 0015 (fix data, not fallback) and ADR 0017 (zero backwards compatibility) ar ### Mandatory error persistence -Every exception handler persists via `persist_app_error(exc)` (ADR 0019) and re-raises. `persist_app_error` is idempotent — it marks the exception and returns the existing row on any later call — so one failure is one `AppError` row no matter how many layers catch it (ADR 0001). No wrapper type, no pass-through arm. +A `try` needs a strong reason: you are going to **handle** the failure — reshape it (domain error, or an HTTP status at the boundary), or persist it from the layer that understands it well enough to add business context. Otherwise let it raise. + +Every handler you do write persists via `persist_app_error(exc)` (ADR 0019) and re-raises. `persist_app_error` is idempotent — it marks the exception and returns the existing row on any later call — so one failure is one `AppError` row no matter how many layers catch it (ADR 0001). No wrapper type, no pass-through arm. ```python from apps.workflow.services.error_persistence import persist_app_error @@ -174,7 +176,7 @@ from apps.workflow.services.error_persistence import persist_app_error try: operation() except Exception as exc: - persist_app_error(exc) # idempotent — one AppError row per failure + persist_app_error(exc, job_id=job.id) # the context is why this handler exists raise ``` diff --git a/apps/accounts/tests/test_staff_api.py b/apps/accounts/tests/test_staff_api.py index 46ab1c879..e143d9ebf 100644 --- a/apps/accounts/tests/test_staff_api.py +++ b/apps/accounts/tests/test_staff_api.py @@ -1,11 +1,13 @@ import datetime import io import os +import tempfile +from typing import Any, ClassVar from django.contrib.auth.models import Group from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection -from django.test.utils import CaptureQueriesContext +from django.test.utils import CaptureQueriesContext, override_settings from PIL import Image from rest_framework.response import Response from rest_framework.test import APIClient @@ -224,8 +226,29 @@ class StaffIconAPIViewTests(BaseTestCase): The staff resource is JSON, which cannot carry a file, so the icon has a dedicated multipart endpoint — the same split already used for company logos and job files. + + MEDIA_ROOT is redirected to a temporary directory: these tests write real + files, and the default root is the developer's own mediafiles/ tree. """ + _media: ClassVar[tempfile.TemporaryDirectory] # type: ignore[type-arg] # py3.12 stub is ungeneric + _media_override: ClassVar[Any] + + @classmethod + def setUpClass(cls) -> None: + cls._media = tempfile.TemporaryDirectory(prefix="staff-icons-test-") + cls._media_override = override_settings(MEDIA_ROOT=cls._media.name) + # Enabled before super() so the base fixture copying also lands in the + # temporary tree rather than the real one. + cls._media_override.enable() + super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + super().tearDownClass() + cls._media_override.disable() + cls._media.cleanup() + def setUp(self) -> None: super().setUp() self.admin = Staff.objects.create_user( @@ -309,6 +332,38 @@ def test_upload_requires_a_file(self) -> None: self.assertEqual(response.status_code, 400) + def test_removing_a_picture_clears_it_and_deletes_the_file(self) -> None: + """Removing a photo must not leave the image behind on disk.""" + self._upload( + SimpleUploadedFile("face.png", _png_bytes(), content_type="image/png") + ) + self.target.refresh_from_db() + path = self.target.icon.path + self.assertTrue(os.path.exists(path)) + + response = self.client_api.delete(f"/api/accounts/staff/{self.target.id}/icon/") + + self.assertEqual(response.status_code, 200, response.content) + self.target.refresh_from_db() + self.assertFalse(self.target.icon) + self.assertFalse(os.path.exists(path)) + self.assertIsNone(response.data["icon_url"]) + + def test_removing_an_absent_picture_succeeds(self) -> None: + """Idempotent: the requested end state (no photo) already holds.""" + response = self.client_api.delete(f"/api/accounts/staff/{self.target.id}/icon/") + + self.assertEqual(response.status_code, 200, response.content) + self.target.refresh_from_db() + self.assertFalse(self.target.icon) + + def test_removing_a_picture_for_an_unknown_staff_member_is_not_found(self) -> None: + response = self.client_api.delete( + "/api/accounts/staff/00000000-0000-0000-0000-000000000000/icon/" + ) + + self.assertEqual(response.status_code, 404) + def test_upload_to_an_unknown_staff_member_is_not_found(self) -> None: response = self._upload( SimpleUploadedFile("face.png", _png_bytes(), content_type="image/png"), diff --git a/apps/accounts/views/staff_icon_api.py b/apps/accounts/views/staff_icon_api.py index 94b6e0222..891c0bb1c 100644 --- a/apps/accounts/views/staff_icon_api.py +++ b/apps/accounts/views/staff_icon_api.py @@ -11,7 +11,6 @@ from apps.accounts.models import Staff from apps.accounts.permissions import IsStaff from apps.accounts.serializers import StaffSerializer -from apps.workflow.services.error_persistence import persist_app_error from apps.workflow.views.company_defaults_logo_api import ( ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, @@ -20,28 +19,34 @@ logger = logging.getLogger(__name__) -@extend_schema( - summary="Upload a staff profile picture", - description="Replace a staff member's profile picture. This is a separate " - "endpoint because the staff resource itself is JSON-only — a file cannot " - "ride inside a JSON body.", - tags=["Staff Management"], - request={ - "multipart/form-data": { - "type": "object", - "properties": {"file": {"type": "string", "format": "binary"}}, - "required": ["file"], - } - }, - responses={200: StaffSerializer}, -) class StaffIconAPIView(APIView): - """Upload the profile picture for a single staff member.""" + """Upload or remove the profile picture for a single staff member. + + Errors are persisted by the project-wide DRF exception handler, which has + the request context (user, session replay id, path). Persisting here first + would win the "first write wins" race in persist_app_error and record the + failure without any of it, so this view deliberately has no try/except. + """ serializer_class = StaffSerializer parser_classes = [MultiPartParser, FormParser] permission_classes = [IsAuthenticated, IsStaff] + @extend_schema( + summary="Upload a staff profile picture", + description="Replace a staff member's profile picture. This is a " + "separate endpoint because the staff resource itself is JSON-only — a " + "file cannot ride inside a JSON body.", + tags=["Staff Management"], + request={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + } + }, + responses={200: StaffSerializer}, + ) def post(self, request: Request, pk: str) -> Response: staff = Staff.objects.filter(pk=pk).first() if staff is None: @@ -58,18 +63,33 @@ def post(self, request: Request, pk: str) -> Response: if ext not in ALLOWED_EXTENSIONS: return Response({"error": f"Unsupported file type: {ext}"}, status=400) - try: - # Drop the previous image so replacing an icon does not orphan a file. - # Every staff icon is a user upload under MEDIA_ROOT/staff_icons, so - # unlike company logos there is no shipped asset to protect here. - if staff.icon: - staff.icon.delete(save=False) - - staff.icon = file - staff.save(update_fields=["icon"]) - except Exception as exc: - persist_app_error(exc) - raise + # Drop the previous image so replacing a picture does not orphan a file. + # Every staff icon is a user upload under MEDIA_ROOT/staff_icons, so + # unlike company logos there is no shipped baseline asset to protect. + staff.icon.delete(save=False) + staff.icon = file + staff.save(update_fields=["icon"]) logger.info(f"[StaffIcon] Updated icon for Staff ID: {pk}") return Response(StaffSerializer(staff, context={"request": request}).data) + + @extend_schema( + summary="Remove a staff profile picture", + description="Clear a staff member's profile picture and delete the " + "image from disk. Idempotent: removing an absent picture succeeds, " + "because the requested end state already holds.", + tags=["Staff Management"], + responses={200: StaffSerializer}, + ) + def delete(self, request: Request, pk: str) -> Response: + staff = Staff.objects.filter(pk=pk).first() + if staff is None: + return Response({"error": "Staff member not found"}, status=404) + + # FieldFile.delete() returns early when there is no file and clears the + # field itself, so this needs no guard and is idempotent. + staff.icon.delete(save=False) + staff.save(update_fields=["icon"]) + + logger.info(f"[StaffIcon] Removed icon for Staff ID: {pk}") + return Response(StaffSerializer(staff, context={"request": request}).data) diff --git a/apps/workflow/serializers.py b/apps/workflow/serializers.py index ea12bc8d4..dee7a5053 100644 --- a/apps/workflow/serializers.py +++ b/apps/workflow/serializers.py @@ -19,17 +19,21 @@ from .models.settings_metadata import COMPANY_DEFAULTS_READ_ONLY_FIELDS -def _build_logo_url( - instance: CompanyDefaults, field_name: str, context: dict[str, Any] | None -) -> str | None: - """Build an absolute logo URL when possible, fall back to relative URL without request.""" +def _build_logo_url(instance: CompanyDefaults, field_name: str) -> str | None: + """Return the logo path relative to the site root. + + Deliberately relative, for the same reason as the staff icon URL: the + browser resolves it against its own origin, so one value is correct behind + ngrok in dev and behind the proxy in UAT/production. Building an absolute + URL from the request leaks the internal host wherever the forwarded-host + headers aren't trusted, and the browser then blocks the image. + + PDF rendering does not use this — it reads company.logo.path directly. + """ field_file = getattr(instance, field_name, None) if not field_file: return None - request = (context or {}).get("request") - if not request: - return field_file.url - return request.build_absolute_uri(field_file.url) + return field_file.url class NotebookLmLinkSerializer(serializers.ModelSerializer[NotebookLmLink]): @@ -81,10 +85,10 @@ class CompanyDefaultsSerializer(serializers.ModelSerializer): ) def get_logo_url(self, obj: CompanyDefaults) -> str | None: - return _build_logo_url(obj, "logo", self.context) + return _build_logo_url(obj, "logo") def get_logo_wide_url(self, obj: CompanyDefaults) -> str | None: - return _build_logo_url(obj, "logo_wide", self.context) + return _build_logo_url(obj, "logo_wide") def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: for field_name in self.optional_url_fields: diff --git a/docs/adr/0001-exception-already-logged-dedup.md b/docs/adr/0001-exception-already-logged-dedup.md index ed887ce84..6cf69eb57 100644 --- a/docs/adr/0001-exception-already-logged-dedup.md +++ b/docs/adr/0001-exception-already-logged-dedup.md @@ -14,7 +14,7 @@ Exceptions travel integration → service → view → scheduler. Every layer ha try: operation() except Exception as exc: - persist_app_error(exc) # idempotent — one AppError row per failure + persist_app_error(exc, job_id=job.id) # the context is why this handler exists raise ``` diff --git a/docs/adr/0019-mandatory-error-persistence.md b/docs/adr/0019-mandatory-error-persistence.md index 644a58a1d..f626903ed 100644 --- a/docs/adr/0019-mandatory-error-persistence.md +++ b/docs/adr/0019-mandatory-error-persistence.md @@ -10,6 +10,8 @@ Errors logged to stdout/stderr survive only as long as log retention. A schedule Every `except` block calls `persist_app_error(exc)`, which stores the message, traceback, request context, and a UUID id in the `AppError` table. The handler then re-raises directly. `persist_app_error` is idempotent — it marks the exception it persists and returns the existing row on any later call (ADR 0001) — so the same failure isn't persisted twice as it travels up the stack, even though every layer calls it. Continuation without re-raise is allowed only when business logic explicitly requires it. +This governs `except` blocks that exist; it is not an instruction to introduce them. A `try` needs a strong reason: you are going to **handle** the failure. Converting its shape is handling — into a domain error, or into an HTTP status at the boundary. Being the layer that understands the failure well enough to persist it with real business context is handling. Absent one of those, let it raise to a layer that has one. + ## Why Database-backed errors survive log rotation, are SQL-queryable, and join with everything else in the schema — a job's `AppError`s alongside its `JobEvent`s, a staff member's failures alongside their actions, a Xero sync failure alongside the invoice that triggered it. Support flips from "grep recent logs and hope" to "look up `AppError` by id." The error record is part of the system's permanent state, treated the same as any other domain row. diff --git a/docs/urls/accounts.md b/docs/urls/accounts.md index 925b2bfac..c14279202 100644 --- a/docs/urls/accounts.md +++ b/docs/urls/accounts.md @@ -22,7 +22,7 @@ |-------------|------|------|-------------| | `/staff/` | `staff_api.StaffListCreateAPIView` | `accounts:api_staff_list_create` | API endpoint for listing and creating staff members. | | `/staff/<uuid:pk>/` | `staff_api.StaffRetrieveUpdateAPIView` | `accounts:api_staff_detail` | API endpoint for retrieving and updating individual staff members. | -| `/staff/<uuid:pk>/icon/` | `staff_icon_api.StaffIconAPIView` | `accounts:api_staff_icon` | Upload the profile picture for a single staff member. | +| `/staff/<uuid:pk>/icon/` | `staff_icon_api.StaffIconAPIView` | `accounts:api_staff_icon` | Upload or remove the profile picture for a single staff member. | | `/staff/all/` | `staff_views.StaffListAPIView` | `accounts:api_staff_all_list` | API endpoint for retrieving list of staff members for Kanban board. | | `/staff/rates/<uuid:staff_id>/` | `staff_views.get_staff_rates` | `accounts:get_staff_rates` | Retrieve wage rates for a specific staff member. | diff --git a/frontend/schema.yml b/frontend/schema.yml index 7fb415c51..6bf59f665 100644 --- a/frontend/schema.yml +++ b/frontend/schema.yml @@ -825,6 +825,30 @@ paths: schema: $ref: '#/components/schemas/Staff' description: '' + delete: + operationId: accounts_staff_icon_destroy + description: "Clear a staff member's profile picture and delete the image from + disk. Idempotent: removing an absent picture succeeds, because the requested + end state already holds." + summary: Remove a staff profile picture + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Staff Management + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Staff' + description: '' /api/accounts/staff/all/: get: operationId: accounts_staff_all_list diff --git a/frontend/src/api/generated/api.ts b/frontend/src/api/generated/api.ts index a95fbad03..3a4c76662 100644 --- a/frontend/src/api/generated/api.ts +++ b/frontend/src/api/generated/api.ts @@ -4743,6 +4743,21 @@ Returns: ], response: Staff, }, + { + method: 'delete', + path: '/api/accounts/staff/:id/icon/', + alias: 'accounts_staff_icon_destroy', + description: `Clear a staff member's profile picture and delete the image from disk. Idempotent: removing an absent picture succeeds, because the requested end state already holds.`, + requestFormat: 'json', + parameters: [ + { + name: 'id', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: Staff, + }, { method: 'get', path: '/api/accounts/staff/all/', diff --git a/frontend/tests/staff/create-staff.spec.ts b/frontend/tests/staff/create-staff.spec.ts index cc30af0d8..7d1ac1fe9 100644 --- a/frontend/tests/staff/create-staff.spec.ts +++ b/frontend/tests/staff/create-staff.spec.ts @@ -173,5 +173,15 @@ test.describe.serial('staff administration', () => { expect(image.status()).toBe(200) expect(image.headers()['content-type']).toContain('image') }) + + await test.step('the photo can be removed again', async () => { + // Also stops the run leaving the uploaded file behind: teardown restores + // the database but not MEDIA_ROOT, so a photo kept here would orphan. + const removal = await page.request.delete(`/api/accounts/staff/${staffId}/icon/`) + expect(removal.status()).toBe(200) + + const after = await findStaff(page, staffId) + expect(after.icon_url).toBeNull() + }) }) }) diff --git a/mypy-baseline.txt b/mypy-baseline.txt index ef4670fa9..64d5cc445 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -1016,7 +1016,6 @@ apps/testing.py:0: note: Use "-> None" if function does not return a value apps/testing.py:0: error: Call to untyped function "_ensure_test_media_files" in typed context [no-untyped-call] apps/testing.py:0: error: Call to untyped function "_create_test_staff" in typed context [no-untyped-call] apps/workflow/serializers.py:0: error: Returning Any from function declared to return "str | None" [no-any-return] -apps/workflow/serializers.py:0: error: Returning Any from function declared to return "str | None" [no-any-return] apps/workflow/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] apps/workflow/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] apps/workflow/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] From fcbacd9360f7e3d94311b624c522af3b9060322e Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sun, 26 Jul 2026 12:36:55 +1200 Subject: [PATCH 58/66] fix(test): represent unconfigured Xero quote terms as blank, not NULL test_quote_creation_stops_when_terms_are_unconfigured set xero_quote_terms to None to simulate "unconfigured", but the column is NOT NULL with a default, so the raw UPDATE hit a NotNullViolation before the guard ran. Production represents unconfigured terms as a blank string (get_xero_quote_terms strips and returns None), so use "" to exercise the configuration_error path. ADR 0015: match the data model rather than injecting a state it forbids. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- apps/workflow/tests/test_xero_branding_themes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/workflow/tests/test_xero_branding_themes.py b/apps/workflow/tests/test_xero_branding_themes.py index eecb49b3f..2b73efaba 100644 --- a/apps/workflow/tests/test_xero_branding_themes.py +++ b/apps/workflow/tests/test_xero_branding_themes.py @@ -226,7 +226,7 @@ def test_quote_creation_stops_when_terms_are_unconfigured(self) -> None: defaults = CompanyDefaults.get_solo() CompanyDefaults.objects.filter(pk=defaults.pk).update( xero_sales_branding_theme_id=uuid.UUID(THEME_ID), - xero_quote_terms=None, + xero_quote_terms="", ) CompanyDefaults.clear_cache() manager = XeroQuoteManager( From fa25e02c0763fa44f186b7f9cc849315b6e13d39 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Sun, 26 Jul 2026 13:26:42 +1200 Subject: [PATCH 59/66] fix(staff): stop the staff form logging passwords to the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staff form narrated its own progress with console.log, and three of those lines printed user-typed secrets: form.value carries password and password_confirmation, the zod safeParse result carries both, and apiData carries password. Moving them behind the debug gate would not have fixed that — enabling the namespace would still print the secrets — so the success-path narration is deleted outright. That also satisfies frontend rule 31 by removal rather than ceremony. The three console.error calls stay: each sets error.value or raises a toast, which is what rule 30 asks for. Separately, closeSyncWindow() raised when a run had no recorded window. That is the wrong reaction to a benign state: the requested end state, no window left open for this run, already holds. Since it runs inside E2E teardown, the throw skipped the post-restore safety check, the backup and token cleanup, and the caller's lock removal — wedging the next run behind a stale lock. It now warns and returns, and the warning says the run's Xero artifacts were never covered so the poll may replay them. Deliberately not hardened further. The remaining ways that call can throw are disk-level, where the surrounding unlinkSync calls would fail anyway, and replayed test data is already caught by checkSafeToTest at the start of every run with a documented one-command fix. Both from the Copilot review on PR #498. The other two comments are answered in their threads and need no change: readBackendAppDomain() must keep returning '' because the frontend is built from a release checkout with no backend .env, and datetime.fromisoformat has accepted a trailing Z since Python 3.11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- frontend/src/components/StaffFormModal.vue | 24 ++-------------------- frontend/tests/scripts/e2e-sync-windows.ts | 13 +++++++++++- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/StaffFormModal.vue b/frontend/src/components/StaffFormModal.vue index a074970b5..89fa11562 100644 --- a/frontend/src/components/StaffFormModal.vue +++ b/frontend/src/components/StaffFormModal.vue @@ -438,13 +438,7 @@ const passwordMismatch = computed(() => { watch( () => props.staff, (staff) => { - console.log('StaffFormModal - Props staff changed:', staff) if (staff) { - console.log( - 'StaffFormModal - Staff base_wage_rate from props:', - staff.base_wage_rate, - typeof staff.base_wage_rate, - ) form.value = { first_name: staff.first_name, last_name: staff.last_name, @@ -475,12 +469,6 @@ watch( date_joined: staff.date_joined || '', date_left: staff.date_left || '', } - console.log('StaffFormModal - Form populated with:', form.value) - console.log( - 'StaffFormModal - Form base_wage_rate after parsing:', - form.value.base_wage_rate, - typeof form.value.base_wage_rate, - ) } else { form.value = { first_name: '', @@ -526,12 +514,8 @@ async function submitForm() { error.value = '' isLoading.value = true - console.log('StaffFormModal - Submitting form with data:', form.value) - console.log( - 'StaffFormModal - Form base_wage_rate before validation:', - form.value.base_wage_rate, - typeof form.value.base_wage_rate, - ) + // Nothing here narrates the form contents: they carry the new staff member's + // password and its confirmation, which must never reach the browser console. // Prepare base data - shared between validation and API call const lastLogin = normalizeOptionalString(form.value.last_login) @@ -597,8 +581,6 @@ async function submitForm() { const schema = props.staff ? updateStaffSchema : createStaffSchema const parsed = schema.safeParse(validationData) - console.log('StaffFormModal - Schema validation result:', parsed) - if (!parsed.success) { error.value = parsed.error.errors[0].message console.error('StaffFormModal - Validation failed:', parsed.error.errors) @@ -610,8 +592,6 @@ async function submitForm() { // The icon is not part of this payload — it uploads separately below. const apiData = { ...baseData } - console.log('StaffFormModal - API data being sent:', apiData) - let staffId: string if (props.staff) { await updateStaff(props.staff.id, apiData) diff --git a/frontend/tests/scripts/e2e-sync-windows.ts b/frontend/tests/scripts/e2e-sync-windows.ts index ee54d20e9..e96326e5d 100644 --- a/frontend/tests/scripts/e2e-sync-windows.ts +++ b/frontend/tests/scripts/e2e-sync-windows.ts @@ -51,12 +51,23 @@ export function openSyncWindow(runId: string): void { /** * Close this run's window, making everything it created in Xero inert. + * + * A run with no recorded window is reported, not raised: the requested end + * state — no window left open for this run — already holds, and this runs + * inside teardown, where throwing would strand the lock file and block every + * later run. The warning is the useful part, because it means that run's Xero + * artifacts were never covered by a window and can still be replayed. */ export function closeSyncWindow(runId: string): void { const windows = read() const window = windows.find((w) => w.run_id === runId) if (!window) { - throw new Error(`No E2E sync window recorded for run ${runId} in ${WINDOWS_FILE}`) + console.warn( + `[e2e] No sync window recorded for run ${runId} in ${WINDOWS_FILE}. ` + + `Nothing to close, but this run's Xero artifacts are unprotected and ` + + `the hourly poll may replay them into the restored database.`, + ) + return } window.ended_at = new Date().toISOString() write(windows) From 54917d2a2a8157a9750c09afa6c1c3e9cfdda908 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 12:34:19 +1200 Subject: [PATCH 60/66] fix(cost-entry): close draft lifecycle data-loss regressions (KAN-296) Cell renderers capture `line` at render time, but drafts are replaced immutably by useCostLineDrafts.updateDraft. Writes still landed (they are keyed by __localId); reads off the captured object saw pre-update values. - Stock selection now threads the replacement draft through readiness, persistence, and actual-tab consumption, and branches on the selected kind instead of the render-time kind. Description-first entry on the actual tab previously consumed no stock and left a draft stranded with id: '' that reached no server record. - Consumption is the create for actual material lines, so the local row is now discarded once the parent holds the server row. Without this the now-firing consume would leave the operator seeing the row twice. - Creates serialize per draft session, so rows append in the order the operator entered them rather than the order the server answers in. This also removes the create-vs-refresh clobber by construction: only one create, and therefore one parent refresh, is ever outstanding. - Ctrl/Cmd+Backspace routes owned drafts through deleteDraft, matching the Delete button. A draft with a committed create stays protected. - Enter falls back to the next enabled cell when the same-column target in the row below is disabled, instead of blurring and focusing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- .../components/shared/SmartCostLinesTable.vue | 83 +++-- ...SmartCostLinesTable.draftLifecycle.test.ts | 323 ++++++++++++++++++ .../__tests__/useCostLineDrafts.test.ts | 124 ++++++- .../__tests__/useGridKeyboardNav.test.ts | 36 ++ frontend/src/composables/useCostLineDrafts.ts | 15 +- .../src/composables/useGridKeyboardNav.ts | 11 +- .../tests/job/job-cost-entry-data.spec.ts | 55 +++ 7 files changed, 598 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts diff --git a/frontend/src/components/shared/SmartCostLinesTable.vue b/frontend/src/components/shared/SmartCostLinesTable.vue index 2b8d05df8..a7b038d36 100644 --- a/frontend/src/components/shared/SmartCostLinesTable.vue +++ b/frontend/src/components/shared/SmartCostLinesTable.vue @@ -599,7 +599,13 @@ const { onKeydown } = useGridKeyboardNav({ isLocalLine: !line.id, }) - if (line.id) { + if (isOwnedDraft(line)) { + // Same ownership contract as the Delete button: the session owns the row, + // so the parent must not be asked to delete by id or index. + log('Keyboard delete for owned draft:', line.__localId) + autosave.cancel(line) + props.draftSession.deleteDraft(line) + } else if (line.id) { log('Keyboard emitting delete-line with line.id:', line.id) autosave.cancel(line) emit('delete-line', line.id as string) @@ -732,7 +738,6 @@ const columns = computed(() => { : selectedItem?.id || ((line.ext_refs as Record<string, unknown>)?.stock_id as string) || null - const isMaterial = kind === 'material' const isNewLine = !line.id const isActualTab = props.tabKind === 'actual' @@ -900,17 +905,23 @@ const columns = computed(() => { // Infer kind based on selection const newKind: KindOption = val ? 'material' : 'adjust' + // Drafts are replaced immutably on every update, so the + // captured `line` goes stale as soon as we write to it. Every + // read below comes from `current` instead. + let current = line + // Update kind if it changed - if (String(line.kind) !== newKind) { - updateLineKind(line, newKind) + if (String(current.kind) !== newKind) { + updateLineKind(current, newKind) + current = currentLine(current) } - onItemSelected(line) + onItemSelected(current) if ( isActualTab && isNewLine && - isMaterial && + newKind === 'material' && val && props.consumeStockFn && props.jobId @@ -921,7 +932,7 @@ const columns = computed(() => { if (!stock) { throw new Error('Stock item not found in store') } - const qty = requiredNumber(line.quantity, 'cost line quantity') + const qty = requiredNumber(current.quantity, 'cost line quantity') const unitCost = requiredNumber( stock.unit_cost, `unit_cost for stock ${stock.id}`, @@ -929,18 +940,25 @@ const columns = computed(() => { const markup = companyMaterialsMarkup() const unitRev = roundToDecimalPlaces(unitCost * (1 + markup), 2) await props.consumeStockFn({ - line, + line: current, stockId: val, quantity: qty, unitCost, unitRev, }) + // Consumption is the create for actual material lines: the + // parent now holds the server row, so the local row must go + // or the operator sees it twice. + if (isOwnedDraft(current)) props.draftSession.deleteDraft(current) + else if (current === emptyLine.value) resetEmptyLine() + // to leave the active mode and show the chip/label instead of "Select Item" selectedRowIndex.value = -1 + return } catch { toast.error('Failed to consume stock. Line not created.') - selectedItemMap.set(line, null) + selectedItemMap.set(current, null) return } } else { @@ -964,14 +982,14 @@ const columns = computed(() => { found.unit_revenue === null || found.unit_revenue === undefined ? null : requiredNumber(found.unit_revenue, `unit_revenue for stock ${found.id}`) - updateLine(line, { desc: found.description || '' }) - updateLine(line, { unit_cost: stockUnitCost }) + current = updateLine(current, { desc: found.description || '' }) + current = updateLine(current, { unit_cost: stockUnitCost }) // Update ext_refs.stock_id to reference the selected item - updateLine(line, { - ext_refs: { ...((line.ext_refs as object) || {}), stock_id: val }, + current = updateLine(current, { + ext_refs: { ...((current.ext_refs as object) || {}), stock_id: val }, }) // Update selectedItemMap with full data - selectedItemMap.set(line, { + selectedItemMap.set(current, { id: val as string, description: found.description || '', item_code: found.item_code || '', @@ -982,38 +1000,39 @@ const columns = computed(() => { item_code: found.item_code || '', }) // Ensure quantity is set for new lines - if (line.quantity == null) updateLine(line, { quantity: 1 }) - if (kind !== 'time') { - if (stockUnitRevenue !== null) { - const updatedLine = updateLine(line, { unit_rev: stockUnitRevenue }) - onUnitRevenueManuallyEdited(updatedLine) - } else { - const updatedLine = updateLine(line, { unit_cost: stockUnitCost }) - updateLine(updatedLine, { unit_rev: apply(updatedLine).derived.unit_rev }) - } + if (current.quantity == null) current = updateLine(current, { quantity: 1 }) + // Picking a stock item always lands on material (or adjust + // when cleared), never time, so there is no time-line rate to + // protect here. + if (stockUnitRevenue !== null) { + current = updateLine(current, { unit_rev: stockUnitRevenue }) + onUnitRevenueManuallyEdited(current) + } else { + current = updateLine(current, { unit_cost: stockUnitCost }) + current = updateLine(current, { unit_rev: apply(current).derived.unit_rev }) } // Persist phantom rows once item selection supplies a complete line. - if (!line.id && isLineReadyForSave(line)) { + if (!current.id && isLineReadyForSave(current)) { // Use guarded persistence so the phantom row resets and does not duplicate. - maybePersistNewLine(line) + maybePersistNewLine(current) } } else if (val) { log('Stock item not found in store for id:', val) - updateLine(line, { desc: '', unit_cost: 0 }) - selectedItemMap.set(line, null) + current = updateLine(current, { desc: '', unit_cost: 0 }) + selectedItemMap.set(current, null) } // Explicit item replacement should be durable before the UI moves on. - if (line.id && isLineReadyForSave(line)) { + if (current.id && isLineReadyForSave(current)) { const patch: PatchedCostLineCreateUpdate = { - desc: line.desc || '', - unit_cost: requiredNumber(line.unit_cost, 'cost line unit_cost'), - unit_rev: requiredNumber(line.unit_rev, 'cost line unit_rev'), + desc: current.desc || '', + unit_cost: requiredNumber(current.unit_cost, 'cost line unit_cost'), + unit_rev: requiredNumber(current.unit_rev, 'cost line unit_rev'), ext_refs: { stock_id: val }, } const optimistic: Partial<CostLine> = { ...patch } - await autosave.saveNow(line, patch, optimistic) + await autosave.saveNow(current, patch, optimistic) } }, // Labour picks manage desc in handleLabourPicked (which can diff --git a/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts b/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts new file mode 100644 index 000000000..c8988de5c --- /dev/null +++ b/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts @@ -0,0 +1,323 @@ +/** + * Draft lifecycle coverage for the cost-entry grid (KAN-296). + * + * Business risk: a row the operator has filled in must reach the server exactly + * once. The failure mode these guard against is a row that looks entered, is + * present on no server record, and consumed no stock. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, h, ref } from 'vue' +import type { z } from 'zod' +import { schemas } from '@/api/generated/api' + +vi.mock('@/composables/useCostLineAutosave', () => ({ + useCostLineAutosave: () => ({ + scheduleSave: vi.fn(), + saveNow: vi.fn(), + onBlurSave: vi.fn(), + cancel: vi.fn(), + clearStatus: vi.fn(), + }), +})) + +vi.mock('@/stores/companyDefaults', () => ({ + useCompanyDefaultsStore: () => ({ + companyDefaults: { wage_rate: 40, materials_markup: 0.2 }, + isLoaded: true, + isLoading: false, + loadCompanyDefaults: vi.fn(), + }), +})) + +vi.mock('@/stores/stockStore', () => ({ + useStockStore: () => ({ + items: [ + { + id: 'stock-1', + item_code: 'SS-304', + description: 'Stainless sheet 304', + unit_cost: 25, + unit_revenue: null, + quantity: 100, + }, + ], + loading: false, + fetchStock: vi.fn(), + }), +})) + +vi.mock('@/services/job.service', () => ({ + jobService: { getJobLabourRates: vi.fn().mockResolvedValue([]) }, +})) + +vi.mock('@/services/costline.service', () => ({ + costlineService: { updateCostLine: vi.fn() }, +})) + +vi.mock('@/composables/useDataFreshness', () => ({ + dataFreshness: { checkFreshness: vi.fn().mockResolvedValue(undefined) }, +})) + +// Stub the stock picker with a button that emits the selection the real +// ItemSelect emits when the user picks a stock item. +vi.mock('@/views/purchasing/ItemSelect.vue', () => ({ + default: defineComponent({ + name: 'ItemSelect', + props: { modelValue: { type: String, default: null } }, + emits: ['update:modelValue'], + setup(_props, { emit }) { + return () => + h('button', { + 'data-testid': 'pick-stock', + onClick: () => emit('update:modelValue', 'stock-1'), + }) + }, + }), +})) + +const { capturedRows } = vi.hoisted(() => ({ capturedRows: [] as unknown[] })) + +vi.mock('@/components/DataTable.vue', () => ({ + default: defineComponent({ + name: 'DataTable', + props: { columns: { type: Array, required: true }, data: { type: Array, required: true } }, + setup(props) { + return () => { + capturedRows.length = 0 + capturedRows.push(...(props.data as unknown[])) + const columns = props.columns as Array<{ + id: string + cell: (ctx: { row: { index: number } }) => unknown + }> + const tested = columns.filter((c) => + ['desc', 'item', 'unit_cost', 'unit_rev'].includes(c.id), + ) + return h( + 'div', + tested.map((c) => c.cell({ row: { index: 0 } })), + ) + } + }, + }), +})) + +vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } })) + +import SmartCostLinesTable from '../SmartCostLinesTable.vue' +import { useCostLineDrafts, type CostLineDraft } from '@/composables/useCostLineDrafts' + +type CostLine = z.infer<typeof schemas.CostLine> + +const stubs = { + Button: { template: '<button><slot /></button>' }, + Badge: { template: '<span><slot /></span>' }, + Dialog: { template: '<div><slot /></div>' }, + DialogContent: { template: '<div><slot /></div>' }, + DialogHeader: { template: '<div><slot /></div>' }, + DialogTitle: { template: '<div><slot /></div>' }, + DialogDescription: { template: '<div><slot /></div>' }, + DialogFooter: { template: '<div><slot /></div>' }, + HelpCircle: { template: '<span />' }, + Trash2: { template: '<span />' }, + AlertTriangle: { template: '<span />' }, + Check: { template: '<span />' }, +} + +/** Mounts the table over a real draft session, as the job tabs do. */ +function mountWithRealDrafts(options: { + tabKind: 'estimate' | 'quote' | 'actual' + createLine?: (draft: CostLineDraft) => Promise<CostLine> + consumeStockFn?: (payload: unknown) => Promise<void> +}) { + const costLines = ref<CostLine[]>([]) + const createLine = + options.createLine ?? (async (draft: CostLineDraft) => ({ ...draft, id: 'server-1' })) + const createLineSpy = vi.fn(createLine) + const session = useCostLineDrafts({ costLines, createLine: createLineSpy }) + const deleteDraftSpy = vi.fn(session.deleteDraft) + + const Host = defineComponent({ + setup() { + return () => + h(SmartCostLinesTable, { + lines: [], + tabKind: options.tabKind, + showItemColumn: true, + jobId: 'job-1', + consumeStockFn: options.consumeStockFn, + draftSession: { ...session, deleteDraft: deleteDraftSpy }, + }) + }, + }) + const wrapper = mount(Host, { attachTo: document.body, global: { stubs } }) + // createLineSpy captures the payload actually sent to the server. Asserting on + // the object handed to persistDraft would be meaningless: persistDraft re-reads + // the live draft internally. + return { wrapper, session, createLineSpy, deleteDraftSpy, costLines } +} + +/** Description-first entry: type a description, then activate the row. */ +async function typeDescriptionThenActivateRow(wrapper: ReturnType<typeof mount>) { + await wrapper.get('textarea').setValue('Custom bracket') + // Select row 0 so the Item column leaves its lazy-mounted read-only state. + await wrapper.get('[tabindex="0"]').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() +} + +describe('description-first stock selection', () => { + beforeEach(() => vi.clearAllMocks()) + + it('persists an estimate row exactly once, at selection time', async () => { + const { wrapper, createLineSpy } = mountWithRealDrafts({ tabKind: 'estimate' }) + await typeDescriptionThenActivateRow(wrapper) + + await wrapper.get('[data-testid="pick-stock"]').trigger('click') + await flushPromises() + + // The row saves on selection -- it must not depend on the operator blurring + // out of the row to be rescued. + expect(createLineSpy).toHaveBeenCalledOnce() + const posted = createLineSpy.mock.calls[0][0] + expect({ desc: posted.desc, unit_cost: posted.unit_cost, unit_rev: posted.unit_rev }).toEqual({ + desc: 'Stainless sheet 304', + unit_cost: 25, + unit_rev: 30, + }) + + // Leaving the row afterwards must not create a second server row. + await wrapper.get('textarea').trigger('blur', { relatedTarget: null }) + await flushPromises() + expect(createLineSpy).toHaveBeenCalledOnce() + wrapper.unmount() + }) + + it('consumes stock exactly once on the actual tab and strands no draft', async () => { + const consumeStockFn = vi.fn().mockResolvedValue(undefined) + const { wrapper, session, createLineSpy } = mountWithRealDrafts({ + tabKind: 'actual', + consumeStockFn, + }) + await typeDescriptionThenActivateRow(wrapper) + + await wrapper.get('[data-testid="pick-stock"]').trigger('click') + await flushPromises() + + // Actual material lines are created by stock consumption, never by a plain + // create -- so the consume must fire off the selected kind. + expect(consumeStockFn).toHaveBeenCalledOnce() + expect(consumeStockFn.mock.calls[0][0]).toMatchObject({ + stockId: 'stock-1', + quantity: 1, + unitCost: 25, + unitRev: 30, + }) + expect(createLineSpy).not.toHaveBeenCalled() + + // The consumed row now exists on the server; the local draft must be gone or + // the operator sees the row twice. + expect(session.drafts.value).toEqual([]) + + // Leaving the row must not consume a second time. + await wrapper.get('textarea').trigger('blur', { relatedTarget: null }) + await flushPromises() + expect(consumeStockFn).toHaveBeenCalledOnce() + wrapper.unmount() + }) + + it('keeps the draft when stock consumption fails, so the work is not lost', async () => { + const consumeStockFn = vi.fn().mockRejectedValue(new Error('consume failed')) + const { wrapper, session } = mountWithRealDrafts({ tabKind: 'actual', consumeStockFn }) + await typeDescriptionThenActivateRow(wrapper) + + await wrapper.get('[data-testid="pick-stock"]').trigger('click') + await flushPromises() + + expect(consumeStockFn).toHaveBeenCalledOnce() + expect(session.drafts.value).toHaveLength(1) + wrapper.unmount() + }) +}) + +describe('manual unit revenue override', () => { + beforeEach(() => vi.clearAllMocks()) + + it('survives a later unit cost change', async () => { + const { wrapper } = mountWithRealDrafts({ tabKind: 'estimate' }) + await wrapper.get('textarea').setValue('Custom bracket') + await wrapper.get('[data-automation-id="SmartCostLinesTable-unit-cost-0"]').setValue('10') + await wrapper.get('[data-automation-id="SmartCostLinesTable-unit-rev-0"]').setValue('77') + await wrapper.get('[data-automation-id="SmartCostLinesTable-unit-cost-0"]').setValue('20') + await flushPromises() + + // If the override marker were lost, unit_rev would be recalculated to 24. + expect((capturedRows[0] as CostLineDraft).unit_rev).toBe(77) + wrapper.unmount() + }) + + it('is replaced by the stock unit revenue when an item is picked', async () => { + const { wrapper, createLineSpy } = mountWithRealDrafts({ tabKind: 'estimate' }) + await wrapper.get('textarea').setValue('Custom bracket') + await wrapper.get('[data-automation-id="SmartCostLinesTable-unit-cost-0"]').setValue('10') + await wrapper.get('[data-automation-id="SmartCostLinesTable-unit-rev-0"]').setValue('77') + await wrapper.get('[tabindex="0"]').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + + await wrapper.get('[data-testid="pick-stock"]').trigger('click') + await flushPromises() + + expect(createLineSpy).toHaveBeenCalledOnce() + const posted = createLineSpy.mock.calls[0][0] + expect({ desc: posted.desc, unit_cost: posted.unit_cost, unit_rev: posted.unit_rev }).toEqual({ + desc: 'Stainless sheet 304', + unit_cost: 25, + unit_rev: 30, + }) + wrapper.unmount() + }) +}) + +describe('keyboard delete of an owned draft', () => { + beforeEach(() => vi.clearAllMocks()) + + it('removes an unlocked draft, matching the Delete button', async () => { + const { wrapper, deleteDraftSpy, session } = mountWithRealDrafts({ tabKind: 'estimate' }) + await wrapper.get('textarea').setValue('Custom bracket') + await flushPromises() + expect(session.drafts.value).toHaveLength(1) + + const container = wrapper.get('[tabindex="0"]') + await container.trigger('keydown', { key: 'ArrowDown' }) // select row 0 + await container.trigger('keydown', { key: 'Backspace', ctrlKey: true }) + await flushPromises() + + expect(deleteDraftSpy).toHaveBeenCalledOnce() + expect(session.drafts.value).toEqual([]) + wrapper.unmount() + }) + + it('protects a draft that is mid-save', async () => { + // Never resolves: the draft stays in the 'saving' state for the whole test. + const { wrapper, session } = mountWithRealDrafts({ + tabKind: 'estimate', + createLine: () => new Promise<CostLine>(() => {}), + }) + await wrapper.get('textarea').setValue('Custom bracket') + await wrapper.get('[data-automation-id="SmartCostLinesTable-unit-cost-0"]').setValue('10') + await flushPromises() + // Blur out of the row to start the save. + await wrapper.get('textarea').trigger('blur', { relatedTarget: null }) + await flushPromises() + expect(session.drafts.value[0].__status).toBe('saving') + + const container = wrapper.get('[tabindex="0"]') + await container.trigger('keydown', { key: 'ArrowDown' }) + await container.trigger('keydown', { key: 'Backspace', ctrlKey: true }) + await flushPromises() + + // A POST is in flight for this row; discarding it locally would lose track of + // a line the server is about to create. + expect(session.drafts.value).toHaveLength(1) + wrapper.unmount() + }) +}) diff --git a/frontend/src/composables/__tests__/useCostLineDrafts.test.ts b/frontend/src/composables/__tests__/useCostLineDrafts.test.ts index 17b9fd9ef..89e2bc335 100644 --- a/frontend/src/composables/__tests__/useCostLineDrafts.test.ts +++ b/frontend/src/composables/__tests__/useCostLineDrafts.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' import { ref } from 'vue' import type { z } from 'zod' import { schemas } from '@/api/generated/api' -import { useCostLineDrafts } from '@/composables/useCostLineDrafts' +import { useCostLineDrafts, type CostLineDraft } from '@/composables/useCostLineDrafts' type CostLine = z.infer<typeof schemas.CostLine> @@ -36,28 +37,124 @@ function deferred<T>() { describe('useCostLineDrafts', () => { beforeEach(() => vi.clearAllMocks()) - it('preserves a phantom local ID and completes drafts out of order', async () => { - const firstCreate = deferred<CostLine>() - const secondCreate = deferred<CostLine>() - const createLine = vi - .fn() - .mockReturnValueOnce(firstCreate.promise) - .mockReturnValueOnce(secondCreate.promise) + /** Captures each createLine call so the test controls when it settles. */ + function queueingSession() { + const pending: Array<{ + draft: CostLineDraft + resolve: (created: CostLine) => void + reject: (reason: unknown) => void + }> = [] + const createLine = vi.fn( + (draft: CostLineDraft) => + new Promise<CostLine>((resolve, reject) => pending.push({ draft, resolve, reject })), + ) const costLines = ref<CostLine[]>([]) - const controller = useCostLineDrafts({ costLines, createLine }) + return { + pending, + createLine, + costLines, + controller: useCostLineDrafts({ costLines, createLine }), + } + } + + it('preserves a phantom local ID and appends creates in entry order', async () => { + // Business risk: the operator types rows top-to-bottom. Appending in response + // order lets one slow POST silently reorder the sheet. + const { pending, createLine, costLines, controller } = queueingSession() const first = controller.addDraft({ ...line('First'), __localId: 'phantom-first' } as CostLine) const second = controller.addDraft(line('Second')) expect(first.__localId).toBe('phantom-first') expect(first.__localId).not.toBe(second.__localId) + const firstSave = controller.persistDraft(first) const secondSave = controller.persistDraft(second) - secondCreate.resolve({ ...second, id: 'server-second' }) + await flushPromises() + + // Only the head of the queue is in flight. + expect(createLine).toHaveBeenCalledOnce() + // A queued draft is still editable -- it has not been locked yet. + expect(controller.updateDraft(second.__localId, { unit_rev: 99 }).unit_rev).toBe(99) + + pending[0].resolve({ ...first, id: 'server-first' }) + await firstSave + await flushPromises() + expect(createLine).toHaveBeenCalledTimes(2) + // The queued edit is carried into the POST body. + expect(pending[1].draft.unit_rev).toBe(99) + + pending[1].resolve({ ...second, id: 'server-second' }) await secondSave - expect(controller.drafts.value.map((draft) => draft.__localId)).toEqual(['phantom-first']) - firstCreate.resolve({ ...first, id: 'server-first' }) + + expect(costLines.value.map((saved) => saved.id)).toEqual(['server-first', 'server-second']) + expect(controller.drafts.value).toEqual([]) + }) + + it('keeps every created row when each create awaits a parent refresh', async () => { + // Mirrors the Quote tab: handleCreateFromEmpty awaits the tab refresh before + // returning, and that refresh assigns a server snapshot over costLines. A + // snapshot read before a later row was created must never be able to land + // after it -- that would drop a row the server has already saved. + const serverRows: CostLine[] = [] + const costLines = ref<CostLine[]>([]) + const refreshes: Array<() => void> = [] + const createLine = async (draft: CostLineDraft): Promise<CostLine> => { + const created = { ...draft, id: `server-${draft.desc}` } as CostLine + serverRows.push(created) + const snapshot = [...serverRows] + await new Promise<void>((resolve) => + refreshes.push(() => { + costLines.value = snapshot + resolve() + }), + ) + return created + } + + const controller = useCostLineDrafts({ costLines, createLine }) + const first = controller.addDraft(line('A')) + const second = controller.addDraft(line('B')) + const firstSave = controller.persistDraft(first) + const secondSave = controller.persistDraft(second) + await flushPromises() + + // Only one refresh can ever be outstanding, so no stale snapshot exists to + // land out of order. + expect(refreshes).toHaveLength(1) + refreshes[0]() await firstSave - expect(costLines.value.map((saved) => saved.id)).toEqual(['server-second', 'server-first']) + await flushPromises() + + expect(refreshes).toHaveLength(2) + refreshes[1]() + await secondSave + + expect(costLines.value.map((saved) => saved.id)).toEqual(['server-A', 'server-B']) + expect(controller.drafts.value).toEqual([]) + }) + + it('continues the queue after a failed create', async () => { + const { pending, createLine, costLines, controller } = queueingSession() + const first = controller.addDraft(line('Fails')) + const second = controller.addDraft(line('Succeeds')) + + const firstSave = controller.persistDraft(first) + const secondSave = controller.persistDraft(second) + await flushPromises() + expect(createLine).toHaveBeenCalledOnce() + + pending[0].reject(new Error('POST failed')) + await expect(firstSave).rejects.toThrow('POST failed') + await flushPromises() + + // One bad row must not strand every row behind it. + expect(createLine).toHaveBeenCalledTimes(2) + pending[1].resolve({ ...second, id: 'server-succeeds' }) + await secondSave + + expect(costLines.value.map((saved) => saved.id)).toEqual(['server-succeeds']) + // The failed draft stays put, unlocked for retry. + expect(controller.drafts.value.map((draft) => draft.__status)).toEqual(['error']) }) it('locks and deduplicates one POST, then unlocks a failed draft for retry', async () => { @@ -71,6 +168,7 @@ describe('useCostLineDrafts', () => { const firstAttempt = controller.persistDraft(draft) const duplicateAttempt = controller.persistDraft(draft) + await flushPromises() expect(controller.drafts.value[0].__status).toBe('saving') expect(controller.updateDraft(draft.__localId, { unit_rev: 99 }).unit_rev).toBe(12) expect(createLine).toHaveBeenCalledOnce() diff --git a/frontend/src/composables/__tests__/useGridKeyboardNav.test.ts b/frontend/src/composables/__tests__/useGridKeyboardNav.test.ts index 7bb03dfbc..30ad49df0 100644 --- a/frontend/src/composables/__tests__/useGridKeyboardNav.test.ts +++ b/frontend/src/composables/__tests__/useGridKeyboardNav.test.ts @@ -393,4 +393,40 @@ describe('useGridKeyboardNav cell navigation', () => { container.remove() vi.useRealTimers() }) + + it('Enter falls back to the next enabled cell when the row below is disabled', async () => { + // Business risk: Enter blurs the current editor before choosing a + // destination. With no fallback the operator is dropped out of the grid + // mid-entry and the next keystrokes go nowhere. + vi.useFakeTimers() + const container = document.createElement('div') + const source = makeInput(0, 'desc') + const sameRowNext = makeInput(0, 'quantity') + const disabledBelow = makeInput(1, 'desc') + disabledBelow.disabled = true + const enabledBelow = makeInput(1, 'quantity') + container.append(source, sameRowNext, disabledBelow, enabledBelow) + document.body.append(container) + source.focus() + + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, 'currentTarget', { value: source }) + + const handled = handleGridCellKeydown(event, { + container, + rowIndex: 0, + columnId: 'desc', + }) + + await vi.runOnlyPendingTimersAsync() + + expect(handled).toBe(true) + expect(document.activeElement).toBe(sameRowNext) + container.remove() + vi.useRealTimers() + }) }) diff --git a/frontend/src/composables/useCostLineDrafts.ts b/frontend/src/composables/useCostLineDrafts.ts index 7d58e71ff..1528359e4 100644 --- a/frontend/src/composables/useCostLineDrafts.ts +++ b/frontend/src/composables/useCostLineDrafts.ts @@ -33,6 +33,9 @@ function withoutDraftState(line: CostLine): CostLine { export function useCostLineDrafts({ costLines, createLine }: Options) { const drafts = ref<CostLineDraft[]>([]) const inFlight = new Map<string, Promise<CostLine>>() + // Creates run one at a time per session so rows append in the order the + // operator entered them rather than the order the server happens to answer in. + let queueTail: Promise<unknown> = Promise.resolve() function addDraft(line: CostLine): CostLineDraft { const localId = '__localId' in line ? String(line.__localId) : createLocalRowId() @@ -72,8 +75,10 @@ export function useCostLineDrafts({ costLines, createLine }: Options) { const existingRequest = inFlight.get(localId) if (existingRequest) return existingRequest - const submitted = setDraftState(localId, 'saving') - const request = createLine(submitted) + // The draft is read when its turn arrives, so edits made while it sits in the + // queue are carried into the POST body. + const request = queueTail + .then(() => createLine(setDraftState(localId, 'saving'))) .then((created) => { const serverLine = withoutDraftState(created) const exists = costLines.value.some((line) => line.id === serverLine.id) @@ -91,12 +96,16 @@ export function useCostLineDrafts({ costLines, createLine }: Options) { .finally(() => { inFlight.delete(localId) }) + // One failed row must not stall the rows queued behind it. + queueTail = request.catch(() => undefined) inFlight.set(localId, request) return request } function deleteDraft(draft: CostLineDraft): void { - if (draft.__status === 'saving') return + // Queued as well as in-flight: once a create is committed for this draft the + // row belongs to the server, and discarding it locally would lose track of it. + if (inFlight.has(draft.__localId)) return drafts.value = drafts.value.filter((candidate) => candidate.__localId !== draft.__localId) } diff --git a/frontend/src/composables/useGridKeyboardNav.ts b/frontend/src/composables/useGridKeyboardNav.ts index d10400061..11cc083ae 100644 --- a/frontend/src/composables/useGridKeyboardNav.ts +++ b/frontend/src/composables/useGridKeyboardNav.ts @@ -134,7 +134,16 @@ export function handleGridCellKeydown(e: KeyboardEvent, opts: GridCellKeydownOpt const selector = `[data-grid-nav-cell="true"][data-grid-row="${rowIndex}"][data-grid-col="${columnId}"]:not([disabled]):not([aria-disabled="true"])` window.setTimeout(() => { const next = container.querySelector(selector) - if (next instanceof HTMLElement && isNavigableCell(next)) focusGridDestination(next) + if (next instanceof HTMLElement && isNavigableCell(next)) { + focusGridDestination(next) + return + } + // The nominal destination is disabled or absent. The source cell has + // already been blurred, so fall back to the next cell in grid order + // instead of dropping the user out of the grid mid-entry. + const cells = getNavigableCells(container) + const fallbackIndex = cells.indexOf(target) + 1 + if (fallbackIndex > 0) focusGridDestination(cells[fallbackIndex] ?? null) }, 0) } diff --git a/frontend/tests/job/job-cost-entry-data.spec.ts b/frontend/tests/job/job-cost-entry-data.spec.ts index 2ef634383..42a0da3d5 100644 --- a/frontend/tests/job/job-cost-entry-data.spec.ts +++ b/frontend/tests/job/job-cost-entry-data.spec.ts @@ -646,4 +646,59 @@ test.describe('job cost entry data-first scenarios', () => { await expect(autoId(page, `SmartCostLinesTable-quantity-${materialIndex}`)).toHaveValue('2') await expect(autoId(page, `SmartCostLinesTable-unit-rev-${adjustmentIndex}`)).toHaveValue('-18') }) + + test('actual description-first stock selection consumes once and strands no row', async ({ + authenticatedPage: page, + }) => { + // Business risk: description-first entry on the actual tab consumed no stock + // and reached no server record, leaving the operator looking at a row that + // existed only in the browser. Item-first entry (covered above) always worked, + // which is why this went unnoticed. + const jobUrl = await createTestJob(page, 'ActualDescFirst') + const jobId = getJobIdFromUrl(jobUrl) + const defaults = await getCompanyDefaults(page) + const stock = await fetchStock(page, 'M8 ZINC WING NUT', (item) => + item.description.includes('M8 ZINC WING NUT'), + ) + const expectedUnitRev = roundMoney( + money(stock.unit_cost) * (1 + money(defaults.materials_markup)), + ) + + await navigateToCostTab(page, jobUrl, 'actual') + + const rows = page.locator('[data-automation-id^="DataTable-row-"]') + const trailingRow = rows.last() + const draftRowId = await trailingRow.getAttribute('data-row-id') + if (!draftRowId) throw new Error('Trailing cost row has no stable row ID') + + // Type a description first, promoting the phantom into a local draft. + await trailingRow.locator('[data-grid-col="desc"]').click() + await page.keyboard.type(`E2E desc first ${Date.now()}`) + const promotedRow = page.locator(`[data-row-id="${draftRowId}"]`) + + // Then pick the stock item for that same row. + const consumeResponse = waitForStockConsume(page) + await promotedRow.locator('[data-automation-id^="SmartCostLinesTable-item-"]').click() + const search = page.getByPlaceholder('Search items by description, code, or type...') + await search.waitFor({ timeout: 10000 }) + await search.fill('M8 ZINC') + const option = page.locator('[data-automation-id^="ItemSelect-option-"]').filter({ + hasText: 'M8 ZINC WING NUT', + }) + await option.first().waitFor({ timeout: 10000 }) + await option.first().click() + await consumeResponse + + // A second row would mean the local draft outlived the row the consume created. + await navigateToCostTab(page, jobUrl, 'actual') + await expect(rows.filter({ hasText: stock.description })).toHaveCount(1) + + const lines = (await fetchCostSet(page, jobId, 'actual')).cost_lines + const material = findLine(lines, stock.description, 'material') + expect(material.ext_refs).toEqual(expect.objectContaining({ stock_id: stock.id })) + expect(money(material.quantity)).toBeCloseTo(1, 2) + expect(money(material.unit_cost)).toBeCloseTo(money(stock.unit_cost), 2) + expect(money(material.unit_rev)).toBeCloseTo(expectedUnitRev, 2) + expect(lines.filter((line) => line.desc === stock.description)).toHaveLength(1) + }) }) From 2d5322c3832a5a721836cf59673913dc86257086 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 12:43:52 +1200 Subject: [PATCH 61/66] test(cost-entry): match row descriptions by input value, not row text Cost line descriptions render in a textarea, so the value is a DOM property rather than text content and a hasText filter never matches it. The new description-first assertion silently found zero rows regardless of what the app did. Count rows by textarea value, as findRowIndexByDescription does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- .../tests/job/job-cost-entry-data.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/tests/job/job-cost-entry-data.spec.ts b/frontend/tests/job/job-cost-entry-data.spec.ts index 42a0da3d5..6db38166f 100644 --- a/frontend/tests/job/job-cost-entry-data.spec.ts +++ b/frontend/tests/job/job-cost-entry-data.spec.ts @@ -167,6 +167,21 @@ async function expectRowAbsent(page: Page, description: string): Promise<void> { expect(await findRowIndexByDescription(page, description)).toBe(-1) } +/** Descriptions live in textareas, so their value is not matchable as row text. */ +async function countRowsByDescription(page: Page, description: string): Promise<number> { + const rows = page.locator('[data-automation-id^="DataTable-row-"]') + const count = await rows.count() + let matches = 0 + + for (let i = 0; i < count; i += 1) { + const textarea = rows.nth(i).locator('textarea').first() + const value = await textarea.inputValue().catch(() => '') + if (value === description) matches += 1 + } + + return matches +} + function waitForCostLineCreate(page: Page): Promise<Response> { return page.waitForResponse( (res) => @@ -691,7 +706,9 @@ test.describe('job cost entry data-first scenarios', () => { // A second row would mean the local draft outlived the row the consume created. await navigateToCostTab(page, jobUrl, 'actual') - await expect(rows.filter({ hasText: stock.description })).toHaveCount(1) + await expect + .poll(() => countRowsByDescription(page, stock.description), { timeout: 10000 }) + .toBe(1) const lines = (await fetchCostSet(page, jobId, 'actual')).cost_lines const material = findLine(lines, stock.description, 'material') From 1283c365d3cacd154d91823218d5d9dfd9583933 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 13:47:08 +1200 Subject: [PATCH 62/66] perf(crm): stop transferring call recordings nobody asked for The calls page rendered an <audio preload="metadata"> per row, so the browser downloaded every recording on load, and the download endpoint returned no validator, so each play re-downloaded in full. One run of the CRM E2E test fetched 19 recordings 39 times for 2,034 KB -- 91% of the test's total bytes, for audio nobody played. - preload="none": nothing is fetched until the operator presses play. No UI change; duration already comes from duration_seconds in its own column. - ETag from the recording's stored sha256, and 304 on a matching If-None-Match, so replaying a recording revalidates instead of re-downloading. sha256 is cleared by delete_local_recording, so the header is only set when present -- such a recording has no file and 404s regardless. Measured on the CRM spec: recording downloads 39 -> 0, total bytes 2,237 KB -> 202 KB. The 'linked job persists after reload' step went from a 5.1s median (3.5s budget, 290s worst case) to 2.4s, and 'open CRM calls page' from 5.0s to 2.5s. That step waited on every audio fetch via waitForLoadState('networkidle'), which is why this test was the least reliable in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- apps/crm/tests/test_phone_call_service.py | 31 +++++++++++++++++++ apps/crm/views/phone_call_views.py | 14 +++++++-- .../src/components/crm/PhoneCallTable.vue | 7 ++++- .../tests/crm/phone-call-job-link.spec.ts | 13 ++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/crm/tests/test_phone_call_service.py b/apps/crm/tests/test_phone_call_service.py index 5f962d779..4bdd71c82 100644 --- a/apps/crm/tests/test_phone_call_service.py +++ b/apps/crm/tests/test_phone_call_service.py @@ -1,3 +1,4 @@ +import hashlib import tempfile import uuid from pathlib import Path @@ -883,6 +884,36 @@ def test_download_streams_archived_recording_without_provider_settings( body = b"".join(response.streaming_content) self.assertEqual(body, payload) + def test_download_revalidates_with_etag_instead_of_resending(self) -> None: + """Replaying a recording must not transfer the audio a second time.""" + storage_path = "2026/06/02/etag-playback.mp3" + payload = b"\xff\xe3\x28\xc4recorded audio" + full_path = Path(self.storage_root.name) / storage_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_bytes(payload) + recording = PhoneCallRecording.objects.create( + call=self.call, + provider_recording_id="etag-playback", + account_code="account", + filename="etag-playback.mp3", + storage_path=storage_path, + content_type="audio/mpeg", + byte_size=len(payload), + sha256=hashlib.sha256(payload).hexdigest(), + archived_at=timezone.now(), + ) + url = f"/api/crm/phone-call-recordings/{recording.id}/download/" + + first = self.api.get(url) + + self.assertEqual(first.status_code, 200) + etag = first["ETag"] + + second = self.api.get(url, headers={"if-none-match": etag}) + + self.assertEqual(second.status_code, 304) + self.assertEqual(second.content, b"") + def test_only_office_staff_can_read_recording_downloads(self) -> None: self.api.force_authenticate(user=self.workshop_staff) recording = PhoneCallRecording.objects.create( diff --git a/apps/crm/views/phone_call_views.py b/apps/crm/views/phone_call_views.py index cb18f59c1..33402e000 100644 --- a/apps/crm/views/phone_call_views.py +++ b/apps/crm/views/phone_call_views.py @@ -4,7 +4,7 @@ from uuid import UUID from django.db.models import Q, QuerySet -from django.http import FileResponse +from django.http import FileResponse, HttpResponseNotModified from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_date from drf_spectacular.types import OpenApiTypes @@ -411,8 +411,16 @@ def download( self, request: Request, pk: str | None = None, - ) -> FileResponse | Response: + ) -> FileResponse | HttpResponseNotModified | Response: recording = get_object_or_404(self.get_queryset(), pk=pk) + # Recordings are content-addressed, so the stored digest is a strong + # ETag. Without it the browser re-downloads the whole file every play. + # delete_local_recording clears sha256, and such a recording has no file + # to serve, so the 404 below is what answers it. + etag = f'"{recording.sha256}"' if recording.sha256 else None + if etag and request.headers.get("If-None-Match") == etag: + return HttpResponseNotModified() + try: full_path = recording_file_path(recording) response = FileResponse(open(full_path, "rb")) @@ -420,6 +428,8 @@ def download( if content_type: response["Content-Type"] = content_type response["Content-Disposition"] = f'inline; filename="{recording.filename}"' + if etag: + response["ETag"] = etag return response except FileNotFoundError: return Response( diff --git a/frontend/src/components/crm/PhoneCallTable.vue b/frontend/src/components/crm/PhoneCallTable.vue index c338a327f..729780465 100644 --- a/frontend/src/components/crm/PhoneCallTable.vue +++ b/frontend/src/components/crm/PhoneCallTable.vue @@ -85,11 +85,16 @@ <span v-else class="text-xs text-gray-500">Assign company first</span> </td> <td class="p-3 min-w-64"> + <!-- + preload="none": one <audio> per row means "metadata" makes the + browser fetch every recording on page load. Duration is already + shown in its own column from duration_seconds. + --> <audio v-if="recordingDownloadUrl(call)" :src="recordingDownloadUrl(call) || undefined" controls - preload="metadata" + preload="none" class="h-9 w-full max-w-sm" /> <span v-else class="text-xs text-gray-500">No recording</span> diff --git a/frontend/tests/crm/phone-call-job-link.spec.ts b/frontend/tests/crm/phone-call-job-link.spec.ts index 582c4e191..80f7e2cc7 100644 --- a/frontend/tests/crm/phone-call-job-link.spec.ts +++ b/frontend/tests/crm/phone-call-job-link.spec.ts @@ -73,11 +73,24 @@ test('office staff links a CRM phone call to a job', async ({ authenticatedPage: const jobId = jobIdFromUrl(jobUrl) const callId = seedPhoneCallForJob(jobId) + // Business risk: an <audio> per row with preload="metadata" makes the browser + // download every recording on load. That was ~2 MB of audio nobody played on + // each page load, and it is what made this test's reload steps slow enough to + // time out. + const recordingFetches: string[] = [] + page.on('request', (request) => { + if (/\/api\/crm\/phone-call-recordings\/[^/]+\/download\//.test(request.url())) { + recordingFetches.push(request.url()) + } + }) + await expectStepUnder('open CRM calls page', 3000, async () => { await page.goto('/crm/calls') await page.waitForLoadState('networkidle') }) + expect(recordingFetches, 'no recording may be fetched until it is played').toEqual([]) + await expectStepUnder('open link job dialog', 2000, async () => { await autoId(page, `PhoneCallTable-link-job-${callId}`).click() await autoId(page, 'PhoneCallTable-job-select').waitFor({ timeout: 10000 }) From 482a92a1bff89205b89f467e7faa47090bd72773 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 14:18:01 +1200 Subject: [PATCH 63/66] test(cost-entry): pin labour to material conversion pricing Converting an existing time line to a stock item had no test anywhere: timeGuard covers material to time, kindInference covers adjust to material. That path is easy to get wrong because the row still holds the staff wage in unit_cost when the kind flips, so revenue derived before the stock cost lands charges markup on a labour rate that no longer applies -- 40 * 1.2 = 48 instead of 25 * 1.2 = 30, and both look plausible on screen. Verified as a real guard: restoring the old render-time `kind !== 'time'` condition makes the new test fail with unit_rev 48. Also updates the PhoneCallTable recording assertion to preload="none", which the previous commit changed without running that suite. Its intent -- a relative, same-origin src -- is unchanged; the preload line now states why eager preloading is wrong rather than recording whatever the attribute was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- .../PhoneCallTable.recording.test.ts | 5 +- ...SmartCostLinesTable.draftLifecycle.test.ts | 82 +++++++++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/crm/__tests__/PhoneCallTable.recording.test.ts b/frontend/src/components/crm/__tests__/PhoneCallTable.recording.test.ts index 1b12da6d3..657bffe00 100644 --- a/frontend/src/components/crm/__tests__/PhoneCallTable.recording.test.ts +++ b/frontend/src/components/crm/__tests__/PhoneCallTable.recording.test.ts @@ -54,7 +54,10 @@ describe('PhoneCallTable recording playback', () => { const audio = wrapper.find('audio') expect(audio.exists()).toBe(true) expect(audio.attributes('src')).toBe(protectedUrl) - expect(audio.attributes('preload')).toBe('metadata') + // Business risk: one <audio> per row means any eager preload downloads every + // recording on the page as soon as it loads. Duration is served by its own + // column from duration_seconds, so there is nothing to preload for. + expect(audio.attributes('preload')).toBe('none') }) it('does not promote a separate recording download action', () => { diff --git a/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts b/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts index c8988de5c..339bc1a0c 100644 --- a/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts +++ b/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts @@ -11,14 +11,19 @@ import { defineComponent, h, ref } from 'vue' import type { z } from 'zod' import { schemas } from '@/api/generated/api' -vi.mock('@/composables/useCostLineAutosave', () => ({ - useCostLineAutosave: () => ({ +// Shared across mounts so tests can inspect what the table asked to persist. +const { autosave } = vi.hoisted(() => ({ + autosave: { scheduleSave: vi.fn(), saveNow: vi.fn(), onBlurSave: vi.fn(), cancel: vi.fn(), clearStatus: vi.fn(), - }), + }, +})) + +vi.mock('@/composables/useCostLineAutosave', () => ({ + useCostLineAutosave: () => autosave, })) vi.mock('@/stores/companyDefaults', () => ({ @@ -47,8 +52,20 @@ vi.mock('@/stores/stockStore', () => ({ }), })) +const WORKSHOP_SUBTYPE_ID = '22222222-2222-4222-8222-222222222222' + vi.mock('@/services/job.service', () => ({ - jobService: { getJobLabourRates: vi.fn().mockResolvedValue([]) }, + jobService: { + getJobLabourRates: vi.fn().mockResolvedValue([ + { + id: '11111111-1111-4111-8111-111111111111', + labour_subtype: '22222222-2222-4222-8222-222222222222', + labour_subtype_name: 'Workshop', + is_workshop: true, + charge_out_rate: 65, + }, + ]), + }, })) vi.mock('@/services/costline.service', () => ({ @@ -129,6 +146,7 @@ function mountWithRealDrafts(options: { tabKind: 'estimate' | 'quote' | 'actual' createLine?: (draft: CostLineDraft) => Promise<CostLine> consumeStockFn?: (payload: unknown) => Promise<void> + lines?: CostLine[] }) { const costLines = ref<CostLine[]>([]) const createLine = @@ -141,7 +159,7 @@ function mountWithRealDrafts(options: { setup() { return () => h(SmartCostLinesTable, { - lines: [], + lines: options.lines ?? [], tabKind: options.tabKind, showItemColumn: true, jobId: 'job-1', @@ -239,6 +257,60 @@ describe('description-first stock selection', () => { }) }) +describe('converting a labour row to a stock item', () => { + beforeEach(() => vi.clearAllMocks()) + + it('prices the converted row off the part, not the staff wage', async () => { + // Business risk: the row keeps the wage in unit_cost until the stock cost + // replaces it. Deriving revenue before that swap charges the customer + // markup on a labour rate that no longer applies to the line, and the + // number looks plausible on screen either way. + const timeLine: CostLine = { + id: 'server-time-1', + kind: 'time', + desc: 'Workshop labour', + quantity: 2, + unit_cost: 40, // company wage rate + unit_rev: 65, + total_cost: 80, + total_rev: 130, + accounting_date: '2026-07-27', + ext_refs: {}, + meta: {}, + labour_subtype: WORKSHOP_SUBTYPE_ID, + } + const { wrapper } = mountWithRealDrafts({ tabKind: 'estimate', lines: [timeLine] }) + + // Select the row so the Item cell leaves its lazy-mounted read-only state. + await wrapper.get('[tabindex="0"]').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + + await wrapper.get('[data-testid="pick-stock"]').trigger('click') + await flushPromises() + + expect(autosave.saveNow).toHaveBeenCalled() + // saveNow merges into any pending patch and cancels the debounce, so this + // payload is what actually reaches the server. + const [savedLine, patch] = autosave.saveNow.mock.calls.at(-1) as [ + CostLine, + Record<string, unknown>, + ] + expect(savedLine.id).toBe('server-time-1') + expect(patch).toMatchObject({ + desc: 'Stainless sheet 304', + unit_cost: 25, + // 25 * 1.2. Deriving from the wage still in unit_cost would give 48. + unit_rev: 30, + ext_refs: { stock_id: 'stock-1' }, + }) + + const converted = capturedRows[0] as CostLine + expect(converted.kind).toBe('material') + expect(converted.labour_subtype).toBeNull() + wrapper.unmount() + }) +}) + describe('manual unit revenue override', () => { beforeEach(() => vi.clearAllMocks()) From 30f83248891e9c6fc21fd63dbb7de8de8046a6f9 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 14:56:47 +1200 Subject: [PATCH 64/66] fix(crm,cost-entry): address review findings on the download and item paths Three CodeRabbit findings, each verified against the code before acting: - The 304 was returned before the file was opened, so a row whose digest survived but whose bytes did not answered 304 to any client holding a cached copy -- hiding a data problem from exactly the clients affected. The file is now opened first, and a vanished file 404s even on a conditional request. - The 304 dropped the validator it was matched on. RFC 9110 expects a conditional response to carry its ETag; it now does. - The item-replacement patch sent `ext_refs: { stock_id }` while the local update merged. CostLineCreateUpdateSerializer patches a DictField, which replaces wholesale, so re-picking an item on a line carrying delivery-receipt or PO references dropped them server-side while the row still showed them. The patch now merges, matching the local update. Each is covered: the 304 asserts its ETag, a deleted-on-disk recording asserts 404 through revalidation, and the merge is verified to fail (dropping po_number) when reverted. Skipped: making useGridKeyboardNav test teardown failure-safe. Every test in that file uses the same inline cleanup, so fixing one is inconsistent and fixing all is unrelated to this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- apps/crm/tests/test_phone_call_service.py | 33 +++++++++++++++++ apps/crm/views/phone_call_views.py | 16 +++++++-- .../components/shared/SmartCostLinesTable.vue | 5 ++- ...SmartCostLinesTable.draftLifecycle.test.ts | 36 +++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/apps/crm/tests/test_phone_call_service.py b/apps/crm/tests/test_phone_call_service.py index 4bdd71c82..e7b9cdd63 100644 --- a/apps/crm/tests/test_phone_call_service.py +++ b/apps/crm/tests/test_phone_call_service.py @@ -913,6 +913,39 @@ def test_download_revalidates_with_etag_instead_of_resending(self) -> None: self.assertEqual(second.status_code, 304) self.assertEqual(second.content, b"") + # The conditional response carries the validator it was matched on, so + # the client can revalidate again without re-reading the body. + self.assertEqual(second["ETag"], etag) + + def test_download_404s_a_missing_file_even_when_the_client_revalidates( + self, + ) -> None: + """A vanished file must surface, not hide behind a 304.""" + storage_path = "2026/06/02/vanished.mp3" + payload = b"\xff\xe3\x28\xc4recorded audio" + full_path = Path(self.storage_root.name) / storage_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_bytes(payload) + recording = PhoneCallRecording.objects.create( + call=self.call, + provider_recording_id="vanished", + account_code="account", + filename="vanished.mp3", + storage_path=storage_path, + content_type="audio/mpeg", + byte_size=len(payload), + sha256=hashlib.sha256(payload).hexdigest(), + archived_at=timezone.now(), + ) + url = f"/api/crm/phone-call-recordings/{recording.id}/download/" + etag = self.api.get(url)["ETag"] + + # Lost out of band: the row keeps its digest, the bytes are gone. + full_path.unlink() + + response = self.api.get(url, headers={"if-none-match": etag}) + + self.assertEqual(response.status_code, 404) def test_only_office_staff_can_read_recording_downloads(self) -> None: self.api.force_authenticate(user=self.workshop_staff) diff --git a/apps/crm/views/phone_call_views.py b/apps/crm/views/phone_call_views.py index 33402e000..6b33c084e 100644 --- a/apps/crm/views/phone_call_views.py +++ b/apps/crm/views/phone_call_views.py @@ -418,12 +418,22 @@ def download( # delete_local_recording clears sha256, and such a recording has no file # to serve, so the 404 below is what answers it. etag = f'"{recording.sha256}"' if recording.sha256 else None - if etag and request.headers.get("If-None-Match") == etag: - return HttpResponseNotModified() try: full_path = recording_file_path(recording) - response = FileResponse(open(full_path, "rb")) + # Open before honouring If-None-Match. A row whose digest survives but + # whose file does not is a data problem, and answering 304 would hide + # it from exactly the clients holding a stale copy. + handle = open(full_path, "rb") + + if etag and request.headers.get("If-None-Match") == etag: + handle.close() + not_modified = HttpResponseNotModified() + # A conditional response carries the validator it was matched on. + not_modified["ETag"] = etag + return not_modified + + response = FileResponse(handle) content_type, _ = mimetypes.guess_type(full_path) if content_type: response["Content-Type"] = content_type diff --git a/frontend/src/components/shared/SmartCostLinesTable.vue b/frontend/src/components/shared/SmartCostLinesTable.vue index a7b038d36..e2e3968b4 100644 --- a/frontend/src/components/shared/SmartCostLinesTable.vue +++ b/frontend/src/components/shared/SmartCostLinesTable.vue @@ -1029,7 +1029,10 @@ const columns = computed(() => { desc: current.desc || '', unit_cost: requiredNumber(current.unit_cost, 'cost line unit_cost'), unit_rev: requiredNumber(current.unit_rev, 'cost line unit_rev'), - ext_refs: { stock_id: val }, + // Merge, matching the local update above: the backend + // replaces ext_refs wholesale, so sending only stock_id + // would drop delivery-receipt and PO references. + ext_refs: { ...((current.ext_refs as object) || {}), stock_id: val }, } const optimistic: Partial<CostLine> = { ...patch } await autosave.saveNow(current, patch, optimistic) diff --git a/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts b/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts index 339bc1a0c..533549479 100644 --- a/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts +++ b/frontend/src/components/shared/__tests__/SmartCostLinesTable.draftLifecycle.test.ts @@ -311,6 +311,42 @@ describe('converting a labour row to a stock item', () => { }) }) +describe('replacing the item on a saved row', () => { + beforeEach(() => vi.clearAllMocks()) + + it('preserves unrelated ext_refs', async () => { + // Business risk: the backend replaces ext_refs wholesale, so a patch sending + // only stock_id drops delivery-receipt and PO references. The local row + // merges, so the two diverge silently until the next reload. + const materialLine: CostLine = { + id: 'server-material-1', + kind: 'material', + desc: 'Superseded part', + quantity: 1, + unit_cost: 10, + unit_rev: 12, + total_cost: 10, + total_rev: 12, + accounting_date: '2026-07-27', + ext_refs: { po_number: 'PO-1234', stock_id: 'stock-old' }, + meta: {}, + labour_subtype: null, + } + const { wrapper } = mountWithRealDrafts({ tabKind: 'estimate', lines: [materialLine] }) + + await wrapper.get('[tabindex="0"]').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + + await wrapper.get('[data-testid="pick-stock"]').trigger('click') + await flushPromises() + + expect(autosave.saveNow).toHaveBeenCalled() + const [, patch] = autosave.saveNow.mock.calls.at(-1) as [CostLine, Record<string, unknown>] + expect(patch.ext_refs).toEqual({ po_number: 'PO-1234', stock_id: 'stock-1' }) + wrapper.unmount() + }) +}) + describe('manual unit revenue override', () => { beforeEach(() => vi.clearAllMocks()) From 680f746af549b5a3dc8bb3346804e3c9a1136c01 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 15:05:35 +1200 Subject: [PATCH 65/66] fix(cost-entry): reflect queued creates in the delete control Two further review findings. deleteDraft guards on inFlight, which becomes true synchronously, but isDraftLocked reads __status, which only flips when the draft's queued turn arrives. A draft sitting behind another create therefore rendered as deletable and the delete silently did nothing. Fixed by exposing isPersisting from the session and driving the delete control off it. Deliberately not applied to isDraftLocked as the review suggested: that gates description, quantity and rate editing, and KAN-296 requires queued drafts to stay editable. The row stays editable; only the delete is withheld, and visibly so. Also asserts the CRM page renders at least one player before asserting no recording was fetched. The restored database does carry recordings -- the pre-fix run measured 39 downloads on that test -- but without the precondition the assertion would pass vacuously on a page with none, which is worse than no test at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- .../components/shared/SmartCostLinesTable.vue | 14 +++++++++++++- .../__tests__/useCostLineDrafts.test.ts | 7 +++++++ frontend/src/composables/useCostLineDrafts.ts | 17 +++++++++++++---- frontend/tests/crm/phone-call-job-link.spec.ts | 3 +++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/shared/SmartCostLinesTable.vue b/frontend/src/components/shared/SmartCostLinesTable.vue index e2e3968b4..5f425ea8c 100644 --- a/frontend/src/components/shared/SmartCostLinesTable.vue +++ b/frontend/src/components/shared/SmartCostLinesTable.vue @@ -200,6 +200,16 @@ function isDraftLocked(line: CostLine): boolean { return isOwnedDraft(line) && line.__status === 'saving' } +/** + * Wider than isDraftLocked: also covers a draft queued behind another create. + * Such a row stays editable, but deleting it would lose track of a create the + * session has already committed to, so the delete control must reflect that + * rather than accepting the click and silently doing nothing. + */ +function isDraftPersisting(line: CostLine): boolean { + return isOwnedDraft(line) && props.draftSession.isPersisting(line) +} + function currentLine(line: CostLine): CostLine { if (!isDraft(line)) return line return props.draftSession.drafts.value.find((draft) => draft.__localId === line.__localId) ?? line @@ -1553,7 +1563,9 @@ const columns = computed(() => { cell: ({ row }: RowCtx) => { const line = displayLines.value[row.index] const approving = approvingId.value === line.id - const disabled = !!props.readOnly || approving || isDraftLocked(line) + // isDraftPersisting, not isDraftLocked: a queued draft is still editable + // but is no longer discardable. + const disabled = !!props.readOnly || approving || isDraftPersisting(line) const draftStatus = isOwnedDraft(line) ? line.__status : undefined const draftError = isOwnedDraft(line) ? line.__error : undefined const canApprove = diff --git a/frontend/src/composables/__tests__/useCostLineDrafts.test.ts b/frontend/src/composables/__tests__/useCostLineDrafts.test.ts index 89e2bc335..a62fc5895 100644 --- a/frontend/src/composables/__tests__/useCostLineDrafts.test.ts +++ b/frontend/src/composables/__tests__/useCostLineDrafts.test.ts @@ -75,6 +75,13 @@ describe('useCostLineDrafts', () => { expect(createLine).toHaveBeenCalledOnce() // A queued draft is still editable -- it has not been locked yet. expect(controller.updateDraft(second.__localId, { unit_rev: 99 }).unit_rev).toBe(99) + // ...but its create is already committed, so it is no longer discardable. + // The delete control reads this, rather than __status, so the operator is + // not offered a delete that would silently do nothing. + expect(controller.drafts.value[1].__status).toBe('idle') + expect(controller.isPersisting(controller.drafts.value[1])).toBe(true) + controller.deleteDraft(controller.drafts.value[1]) + expect(controller.drafts.value).toHaveLength(2) pending[0].resolve({ ...first, id: 'server-first' }) await firstSave diff --git a/frontend/src/composables/useCostLineDrafts.ts b/frontend/src/composables/useCostLineDrafts.ts index 1528359e4..44afaeff2 100644 --- a/frontend/src/composables/useCostLineDrafts.ts +++ b/frontend/src/composables/useCostLineDrafts.ts @@ -102,14 +102,23 @@ export function useCostLineDrafts({ costLines, createLine }: Options) { return request } + /** + * A create is committed for this draft: queued behind another, or in flight. + * Broader than `__status === 'saving'`, which only flips once the draft's turn + * arrives — a queued draft stays editable but can no longer be discarded. + */ + function isPersisting(draft: CostLineDraft): boolean { + return inFlight.has(draft.__localId) + } + function deleteDraft(draft: CostLineDraft): void { - // Queued as well as in-flight: once a create is committed for this draft the - // row belongs to the server, and discarding it locally would lose track of it. - if (inFlight.has(draft.__localId)) return + // Once a create is committed for this draft the row belongs to the server, + // and discarding it locally would lose track of it. + if (isPersisting(draft)) return drafts.value = drafts.value.filter((candidate) => candidate.__localId !== draft.__localId) } - return { drafts, addDraft, updateDraft, persistDraft, deleteDraft } + return { drafts, addDraft, updateDraft, persistDraft, deleteDraft, isPersisting } } export type CostLineDraftSession = ReturnType<typeof useCostLineDrafts> diff --git a/frontend/tests/crm/phone-call-job-link.spec.ts b/frontend/tests/crm/phone-call-job-link.spec.ts index 80f7e2cc7..7be68a2aa 100644 --- a/frontend/tests/crm/phone-call-job-link.spec.ts +++ b/frontend/tests/crm/phone-call-job-link.spec.ts @@ -89,6 +89,9 @@ test('office staff links a CRM phone call to a job', async ({ authenticatedPage: await page.waitForLoadState('networkidle') }) + // Prove there was something to preload, or the assertion below passes vacuously + // on a page whose calls happen to carry no recordings. + await expect(page.locator('audio').first()).toBeAttached() expect(recordingFetches, 'no recording may be fetched until it is played').toEqual([]) await expectStepUnder('open link job dialog', 2000, async () => { From aebe88f517a1c4495dca9570e75f49fd6bbe8a9f Mon Sep 17 00:00:00 2001 From: Corrin Lakeland <corrin.lakeland@cmeconnect.com> Date: Mon, 27 Jul 2026 15:13:26 +1200 Subject: [PATCH 66/66] docs(cost-entry): cut comments that explain history rather than code Several comments described what the code used to do, which reads as noise to anyone who never saw the earlier version: - The download ETag comment still pointed at "the 404 below" as the answer for a cleared digest, which stopped being true when the file open moved ahead of the conditional. - The stock revenue comment justified the absence of a guard that no longer exists, raising a question the code does not pose. - isDraftPersisting carried the same rationale as the composable's isPersisting, and again inline at the call site -- three statements of one idea. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bwLHUKBG5PNnWov73CUZE --- apps/crm/views/phone_call_views.py | 13 ++++--------- .../src/components/shared/SmartCostLinesTable.vue | 14 +++----------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/crm/views/phone_call_views.py b/apps/crm/views/phone_call_views.py index 6b33c084e..0e8edfe85 100644 --- a/apps/crm/views/phone_call_views.py +++ b/apps/crm/views/phone_call_views.py @@ -413,24 +413,19 @@ def download( pk: str | None = None, ) -> FileResponse | HttpResponseNotModified | Response: recording = get_object_or_404(self.get_queryset(), pk=pk) - # Recordings are content-addressed, so the stored digest is a strong - # ETag. Without it the browser re-downloads the whole file every play. - # delete_local_recording clears sha256, and such a recording has no file - # to serve, so the 404 below is what answers it. + # Recordings are content-addressed, so the stored digest is a strong ETag. etag = f'"{recording.sha256}"' if recording.sha256 else None try: full_path = recording_file_path(recording) - # Open before honouring If-None-Match. A row whose digest survives but - # whose file does not is a data problem, and answering 304 would hide - # it from exactly the clients holding a stale copy. + # Opened before the conditional is answered: a row whose digest + # outlives its file must 404, not 304 the clients holding a stale copy. handle = open(full_path, "rb") if etag and request.headers.get("If-None-Match") == etag: handle.close() not_modified = HttpResponseNotModified() - # A conditional response carries the validator it was matched on. - not_modified["ETag"] = etag + not_modified["ETag"] = etag # RFC 9110: a 304 repeats the validator return not_modified response = FileResponse(handle) diff --git a/frontend/src/components/shared/SmartCostLinesTable.vue b/frontend/src/components/shared/SmartCostLinesTable.vue index 5f425ea8c..de9650a72 100644 --- a/frontend/src/components/shared/SmartCostLinesTable.vue +++ b/frontend/src/components/shared/SmartCostLinesTable.vue @@ -200,12 +200,7 @@ function isDraftLocked(line: CostLine): boolean { return isOwnedDraft(line) && line.__status === 'saving' } -/** - * Wider than isDraftLocked: also covers a draft queued behind another create. - * Such a row stays editable, but deleting it would lose track of a create the - * session has already committed to, so the delete control must reflect that - * rather than accepting the click and silently doing nothing. - */ +/** Editing stays open while a create is queued; discarding the row does not. */ function isDraftPersisting(line: CostLine): boolean { return isOwnedDraft(line) && props.draftSession.isPersisting(line) } @@ -1011,9 +1006,8 @@ const columns = computed(() => { }) // Ensure quantity is set for new lines if (current.quantity == null) current = updateLine(current, { quantity: 1 }) - // Picking a stock item always lands on material (or adjust - // when cleared), never time, so there is no time-line rate to - // protect here. + // The stock item's own revenue wins; otherwise derive it from + // the stock cost just set above, never the prior kind's rate. if (stockUnitRevenue !== null) { current = updateLine(current, { unit_rev: stockUnitRevenue }) onUnitRevenueManuallyEdited(current) @@ -1563,8 +1557,6 @@ const columns = computed(() => { cell: ({ row }: RowCtx) => { const line = displayLines.value[row.index] const approving = approvingId.value === line.id - // isDraftPersisting, not isDraftLocked: a queued draft is still editable - // but is no longer discardable. const disabled = !!props.readOnly || approving || isDraftPersisting(line) const draftStatus = isOwnedDraft(line) ? line.__status : undefined const draftError = isOwnedDraft(line) ? line.__error : undefined