diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index d9942a9..4377fc8 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -980,6 +980,32 @@
+
Integration
@@ -2220,6 +2246,7 @@
{% endblock %}
{% block extra_js %}
+
diff --git a/app/workflows/customer_activation.py b/app/workflows/customer_activation.py
new file mode 100644
index 0000000..91037b8
--- /dev/null
+++ b/app/workflows/customer_activation.py
@@ -0,0 +1,61 @@
+"""Customer-card opt-in; global configuration remains the master switch."""
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field, field_validator
+from app.core.auth_dependencies import get_current_user, require_permission
+from app.core.auth_service import AuthService
+from app.core.config import settings
+from .service import transaction
+
+router=APIRouter(prefix='/workflows/customers')
+
+async def read_access(user=Depends(get_current_user)):
+ if not user.get('is_superadmin') and not any(AuthService.user_has_permission(user['id'],permission) for permission in ('customers.view','workflows.view')):
+ raise HTTPException(403,'Du mangler rettighed til at se kunden eller workflows')
+ return user
+
+def can_manage(user):
+ return bool(user.get('is_superadmin') or all(AuthService.user_has_permission(user['id'],permission) for permission in ('customers.edit','workflows.activate')))
+
+def activation_status(customer_id,cursor,user,lock=False):
+ cursor.execute('SELECT id FROM customers WHERE id=%s'+(' FOR UPDATE' if lock else ''),(customer_id,))
+ if not cursor.fetchone(): raise HTTPException(404,'Kunden findes ikke')
+ cursor.execute("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
+ present=cursor.fetchone()
+ row=None
+ if present and present.get('name'):
+ cursor.execute('SELECT * FROM bmc_workflow_customer_activation WHERE customer_id=%s'+(' FOR UPDATE' if lock else ''),(customer_id,))
+ row=cursor.fetchone()
+ inherited_enabled=customer_id in settings.WORKFLOWS_CUSTOMER_IDS
+ enabled=bool(row['enabled']) if row else inherited_enabled
+ return {'customer_id':customer_id,'enabled':enabled,'explicit_override':bool(row['enabled']) if row else None,
+ 'inherited':row is None,'effective_enabled':bool(settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE=='active' and enabled),
+ 'global_enabled':settings.WORKFLOWS_ENABLED,'mode':settings.WORKFLOWS_MODE,
+ 'can_manage':can_manage(user),'can_view':True,
+ 'updated_at':row.get('updated_at') if row else None,'updated_by':row.get('updated_by') if row else None,
+ 'schema_ready':bool(present and present.get('name'))}
+
+class ActivationUpdate(BaseModel):
+ enabled:bool
+ reason:str=Field(min_length=3,max_length=1000)
+ @field_validator('reason')
+ @classmethod
+ def reason_is_meaningful(cls,value):
+ value=value.strip()
+ if len(value)<3: raise ValueError('Begrundelsen skal være mindst tre tegn')
+ return value
+
+@router.get('/{customer_id}/activation')
+def get_activation(customer_id:int,user=Depends(read_access)):
+ with transaction() as cursor: return activation_status(customer_id,cursor,user)
+
+@router.put('/{customer_id}/activation')
+def set_activation(customer_id:int,payload:ActivationUpdate,user=Depends(require_permission('customers.edit')),workflow_access=Depends(require_permission('workflows.activate'))):
+ with transaction() as cursor:
+ # Parent-row locking serializes first-ever overrides and their audit records.
+ previous=activation_status(customer_id,cursor,user,lock=True)
+ if not previous['schema_ready']: raise HTTPException(503,'Kundeaktivering kræver workflowmigration 1067')
+ cursor.execute('INSERT INTO bmc_workflow_customer_activation(customer_id,enabled,updated_by) VALUES(%s,%s,%s) ON CONFLICT(customer_id) DO UPDATE SET enabled=EXCLUDED.enabled,updated_by=EXCLUDED.updated_by,updated_at=now()',
+ (customer_id,payload.enabled,user['id']))
+ cursor.execute('INSERT INTO bmc_workflow_customer_activation_history(customer_id,before_enabled,after_enabled,inherited_enabled,actor_id,reason) VALUES(%s,%s,%s,%s,%s,%s)',
+ (customer_id,previous['explicit_override'],payload.enabled,customer_id in settings.WORKFLOWS_CUSTOMER_IDS,user['id'],payload.reason))
+ return activation_status(customer_id,cursor,user)
diff --git a/app/workflows/integration.py b/app/workflows/integration.py
index 182d521..e4338ac 100644
--- a/app/workflows/integration.py
+++ b/app/workflows/integration.py
@@ -2,17 +2,50 @@
import json
import logging
from app.core.config import settings
-from app.core.database import execute_query_single, execute_update
+from app.core.database import execute_query_single, execute_query, execute_update
logger = logging.getLogger(__name__)
+def customer_enabled(customer_id,cursor=None,fallback_ids=None):
+ """Saved true/false overrides env preference; missing table preserves legacy config."""
+ if customer_id is None: return bool(settings.WORKFLOWS_INTERNAL_ENABLED)
+ fallback=settings.WORKFLOWS_CUSTOMER_IDS if fallback_ids is None else fallback_ids
+ inherited=str(customer_id) in {str(value) for value in fallback}
+ if cursor is None:
+ present=execute_query_single("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
+ if not present or not present.get('name'): return inherited
+ row=execute_query_single('SELECT enabled FROM bmc_workflow_customer_activation WHERE customer_id=%s FOR SHARE',(int(customer_id),))
+ else:
+ cursor.execute("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
+ present=cursor.fetchone()
+ if not present or not present.get('name'): return inherited
+ cursor.execute('SELECT enabled FROM bmc_workflow_customer_activation WHERE customer_id=%s FOR SHARE',(int(customer_id),))
+ row=cursor.fetchone()
+ return bool(row['enabled']) if row else inherited
+
+def effective_customer_ids(cursor=None):
+ ids={int(value) for value in settings.WORKFLOWS_CUSTOMER_IDS}
+ if cursor is None:
+ present=execute_query_single("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
+ rows=execute_query('SELECT customer_id,enabled FROM bmc_workflow_customer_activation') if present and present.get('name') else []
+ else:
+ cursor.execute("SELECT to_regclass('public.bmc_workflow_customer_activation') AS name")
+ present=cursor.fetchone()
+ if present and present.get('name'):
+ cursor.execute('SELECT customer_id,enabled FROM bmc_workflow_customer_activation')
+ rows=cursor.fetchall()
+ else: rows=[]
+ for row in rows:
+ if row['enabled']: ids.add(int(row['customer_id']))
+ else: ids.discard(int(row['customer_id']))
+ return sorted(ids)
def active_for_customer(customer_id):
if not settings.WORKFLOWS_ENABLED or settings.WORKFLOWS_MODE != 'active':
return False
if customer_id is None:
return settings.WORKFLOWS_INTERNAL_ENABLED
- return int(customer_id) in settings.WORKFLOWS_CUSTOMER_IDS
+ return customer_enabled(customer_id)
def configure_runtime():
@@ -25,8 +58,6 @@ def configure_runtime():
return
if settings.WORKFLOWS_MODE not in {'shadow', 'active'}:
raise RuntimeError('WORKFLOWS_MODE must be shadow or active')
- if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active' and not settings.WORKFLOWS_CUSTOMER_IDS and not settings.WORKFLOWS_INTERNAL_ENABLED:
- raise RuntimeError('Active workflows require an explicit customer allowlist')
execute_update("UPDATE bmc_workflow_runtime SET enabled=%s, mode=%s, customer_ids=%s::jsonb, internal_enabled=%s, updated_at=now() WHERE id=1",
(settings.WORKFLOWS_ENABLED, settings.WORKFLOWS_MODE, json.dumps(settings.WORKFLOWS_CUSTOMER_IDS), settings.WORKFLOWS_INTERNAL_ENABLED))
@@ -36,7 +67,6 @@ def run_workflow_jobs():
return
from .worker import process_pending_events
try:
- return process_pending_events(limit=20, mode=settings.WORKFLOWS_MODE,
- customer_ids=settings.WORKFLOWS_CUSTOMER_IDS)
+ return process_pending_events(limit=20, mode=settings.WORKFLOWS_MODE)
except Exception:
logger.exception('Workflow queue processing failed')
diff --git a/app/workflows/monitoring.py b/app/workflows/monitoring.py
index 70b4971..e5ec8e7 100644
--- a/app/workflows/monitoring.py
+++ b/app/workflows/monitoring.py
@@ -5,6 +5,7 @@ from psycopg2.extras import Json
from app.core.auth_dependencies import require_permission
from app.core.config import settings
from .service import transaction
+from .integration import effective_customer_ids
router=APIRouter(prefix='/workflows/runtime')
@@ -15,8 +16,9 @@ def status(user=Depends(require_permission('workflows.view'))):
queues=cursor.fetchall()
cursor.execute("SELECT id,trigger,entity_type,entity_id,status,attempts,error,created_at FROM bmc_workflow_events WHERE status='failed' ORDER BY created_at DESC LIMIT 50")
failed=cursor.fetchall()
+ customer_ids=effective_customer_ids(cursor)
return {'enabled':settings.WORKFLOWS_ENABLED,'mode':settings.WORKFLOWS_MODE,
- 'customer_ids':settings.WORKFLOWS_CUSTOMER_IDS,'internal_enabled':settings.WORKFLOWS_INTERNAL_ENABLED,
+ 'customer_ids':customer_ids,'internal_enabled':settings.WORKFLOWS_INTERNAL_ENABLED,
'ai_enabled':settings.WORKFLOW_AI_ENABLED,'queues':queues,'failed':failed}
@router.post('/events/{event_id}/retry')
diff --git a/app/workflows/worker.py b/app/workflows/worker.py
index fb58299..39d168b 100644
--- a/app/workflows/worker.py
+++ b/app/workflows/worker.py
@@ -65,7 +65,9 @@ def process_pending_events(limit=20, mode='shadow', customer_ids=None):
effective_mode = mode
allowed = runtime['customer_ids'] if customer_ids is None else customer_ids
internal_allowed=event['entity_type'] in ('time','time_entry') and context.get('customer_id') is None and not context.get('customer_mapping_missing') and runtime.get('internal_enabled',False)
- if mode == 'active' and (runtime['mode'] != 'active' or (not internal_allowed and str(context.get('customer_id')) not in [str(value) for value in allowed])):
+ from .integration import customer_enabled
+ customer_allowed=context.get('customer_id') is not None and customer_enabled(context['customer_id'],cursor,fallback_ids=allowed)
+ if mode == 'active' and (runtime['mode'] != 'active' or (not internal_allowed and not customer_allowed)):
effective_mode = 'shadow'
result['mode'] = effective_mode
result['billing'] = result.get('decision',{})
diff --git a/docs/workflow-manuel-testplan.md b/docs/workflow-manuel-testplan.md
index eef78a1..539e6c5 100644
--- a/docs/workflow-manuel-testplan.md
+++ b/docs/workflow-manuel-testplan.md
@@ -51,17 +51,17 @@ Notér den udskrevne mappe. `/private/tmp` er midlertidig opbevaring; bevar en p
### Skift testtilstand
-Ret kun de nedenstående nøgler i den **lokale `.env`**; bevar resten af filen og eventuelle eksisterende værdier til senere gendannelse. Kundelisten er JSON med testkundens **Hub-kunde-ID**, ikke et vTiger-ID.
+Ret kun de nedenstående nøgler i den **lokale `.env`**; bevar resten af filen og eventuelle eksisterende værdier til senere gendannelse. Kundelisten er en bagudkompatibel JSON-standard med **Hub-kunde-ID’er**, ikke vTiger-ID’er. Nye kundetilvalg styres fra kundekortet.
| Nøgle | Slukket | Skyggetilstand | Aktiv test |
|---|---|---|---|
| `WORKFLOWS_ENABLED` | `false` | `true` | `true` |
| `WORKFLOWS_MODE` | `shadow` | `shadow` | `active` |
-| `WORKFLOWS_CUSTOMER_IDS` | `[]` | `[TESTKUNDE_ID]` | `[TESTKUNDE_ID]` |
+| `WORKFLOWS_CUSTOMER_IDS` | `[]` | `[]` | `[]` |
| `WORKFLOWS_INTERNAL_ENABLED` | `false` | `false` | `false` |
| `WORKFLOW_AI_ENABLED` | `false` | `false` | `false` |
-Erstat `TESTKUNDE_ID` med et tal, fx `[123]`. En aktiveret workflowversion og en aktiveret motor er to forskellige ting; begge skal være sat korrekt.
+Aktivér testkunde A på CRM-kundekortet under Oversigt → Workflows → Brug workflows for kunden; skriv en begrundelse og klik Gem. Kundelisten kan nu stå tom. En eksisterende miljøkundeliste bruges kun som standard, indtil kunden har et gemt valg. Et gemt Fra gælder også for en kunde på miljølisten. En aktiveret workflowversion og en aktiveret motor er fortsat to forskellige ting; begge skal være sat korrekt.
Efter ændringer til `.env` skal Docker-servicen genskabes, fordi `env_file` indlæses ved oprettelse. En almindelig restart er ikke tilstrækkelig til at erstatte containerens gamle miljøvariabler.
@@ -313,6 +313,18 @@ Spring denne prøve over, hvis der ikke er en verificeret særskilt e-conomic-te
Permanent afinstallation og fuld databasegendannelse indgår **ikke** i den almindelige manuelle test. Afprøv kun dette i en separat disposable database efter [rollbackvejledningen](workflow-rollback.md). En fuld restore fjerner også senere data.
+### T21 – Aktivér og deaktivér fra CRM-kundekortet
+
+1. Åbn `/customers/KUNDE_A_ID` → Oversigt → Workflows som administrator eller bruger med både `customers.edit` og `workflows.activate` samt læseadgang til kunder/workflows.
+2. Slå Brug workflows for kunden til, skriv `Manuel CRM-test`, og klik Gem. Genindlæs: valget skal stadig være Til. Med global motor slukket viser kortet, at kundens valg er gemt, men ikke aktivt.
+3. Start global skyggetilstand og derefter aktiv tilstand som beskrevet ovenfor. I aktiv tilstand skal kortet vise Aktiv for kunden. Registrér ny tid, og kontrollér workflowafregning.
+4. Slå kunden fra, begrund og gem. Den globale motor skal forblive aktiv, men kortet vise Ikke aktiv. En ny tid følger den eksisterende arbejdsgang; eksisterende beslutninger og afregninger bevares.
+5. Genstart den lokale API: kundens valg skal stadig være Fra. En eventuel gammel miljøkundeliste må ikke tilsidesætte det gemte Fra.
+6. Log ind med kun læseadgang. Status må kunne ses, men aktivering/deaktivering må ikke kunne gemmes. Mangler begge læserettigheder, skjules sektionen.
+7. Prøv tom eller for kort begrundelse. Valget må ikke blive gemt. Kundekortets øvrige sektioner skal stadig fungere.
+
+**Forventet:** valget gælder uden miljøændring eller API-genstart pr. kunde; motorens globale tilstand er fortsat en hovedkontrol. Kun fremtidig behandling bruger valget; gamle tider genbehandles ikke automatisk. Tilvalg/fravalg registreres med bruger, tidspunkt og begrundelse. Slå testkunden fra efter prøven.
+
## 8. Fejlrapport og accept
Hvis der er uventet korttræk, dobbelt kladde, forkert kunde, manglende historik eller ændringer i skyggetilstand: stop den aktive test, slå motor/AI fra og bevar data til fejlsøgning. Fortsæt ikke med flere afregninger.
@@ -341,7 +353,8 @@ Notér ved fejl: testnummer, lokal URL, tidspunkt, brugerrolle, kunde-/sag-/tids
| T18 AI (valgfri) | | |
| T19 e-conomic (valgfri) | | |
| T20 Deaktivering | | |
+| T21 Kundekortaktivering | | |
-Grundforløbet er manuelt accepteret, når T01–T17 og T20 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion.
+Grundforløbet er manuelt accepteret, når T01–T17, T20 og T21 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion.
Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `notification.created` er endnu ikke tilkoblet og kan ikke accepteres med denne plan. Registrér dem som **Ikke implementeret**, ikke Bestået. Sagoprettelse via workflow er heller ikke implementeret. Se [status](workflow-status.md).
diff --git a/docs/workflow-rollback.md b/docs/workflow-rollback.md
index a054a28..20f9368 100644
--- a/docs/workflow-rollback.md
+++ b/docs/workflow-rollback.md
@@ -40,7 +40,7 @@ Kør den tidligere kode fra den worktree med særskilt lokal konfiguration efter
## Permanent afinstallation af workflowdata
-Dette fjerner workflowdefinitioner, beslutninger, audit, AI-forslag, nyt taghierarki og nye metadatakolonner. Eksisterende kunder, sager, tidsregistreringer, konkrete tagtildelinger, ordrekladder og korttransaktioner bevares. Catch words fra det oprindelige tag-API bevares; avancerede keywordregler fjernes. Nye workflowspecifikke rettigheder fjernes.
+Dette fjerner workflowdefinitioner, beslutninger, audit, AI-forslag, nyt taghierarki og nye metadatakolonner. Eksisterende kunder, sager, tidsregistreringer, konkrete tagtildelinger, ordrekladder og korttransaktioner bevares. Catch words fra det oprindelige tag-API bevares; avancerede keywordregler fjernes. Nye workflowspecifikke rettigheder samt kundernes workflowtilvalg og deres historik fjernes.
1. Stop API og scheduler. Verificér featureflags er slået fra. Bevar backupmappe og kontrolsum-manifest.
2. Gennemgå SQL og den planlagte handling:
@@ -55,7 +55,7 @@ Dette fjerner workflowdefinitioner, beslutninger, audit, AI-forslag, nyt taghier
./.venv/bin/python scripts/workflow_backup.py uninstall --apply --confirm UNINSTALL-LOCAL-BMC --backup-dir /private/tmp/bmc-workflow-backup-DIT-CHECKPOINT
```
-Værktøjet tager en ny fuld backup og eksporterer audit før fjernelse. SQL er én transaktion uden `CASCADE`; ukendte afhængigheder stopper fjernelsen. Bevar også den nye backup. Fjern kodefunktionerne eller start ældre kode inden API startes; ny kode må ikke bruges mod den afinstallerede schema. Kontrollér migrationsmekanismen: migrationer 1062–1066 må ikke automatisk genanvendes, mens funktionaliteten ønskes fjernet.
+Værktøjet tager en ny fuld backup og eksporterer audit før fjernelse. SQL er én transaktion uden `CASCADE`; ukendte afhængigheder stopper fjernelsen. Bevar også den nye backup. Fjern kodefunktionerne eller start ældre kode inden API startes; ny kode må ikke bruges mod den afinstallerede schema. Kontrollér migrationsmekanismen: migrationer 1062–1067 må ikke automatisk genanvendes, mens funktionaliteten ønskes fjernet.
## Fuld databasegendannelse
diff --git a/docs/workflow-status.md b/docs/workflow-status.md
index b95c7a3..3598717 100644
--- a/docs/workflow-status.md
+++ b/docs/workflow-status.md
@@ -41,3 +41,13 @@ De fem nye sider bruger Hub’ens fælles layout, kort og temafarver med kompakt
Workflows/Workflowdrift kræver `workflows.view`; Aftaledækning kræver `workflows.view` og `workflows.manage`; Tagklassifikation kræver `system.admin`. Hvorfor kræver `workflows.decisions.view` og åbnes via tidsregistreringen. Skriveknapper følger de tilsvarende API-rettigheder. Klassifikations- og ressourceeditoren har også adgangskontrol ved direkte sideåbning.
Render-, syntaks- og rettighedstests er udført lokalt. Visuel kontrol i en browser er ikke udført i denne session. Se [manuel testplan](workflow-manuel-testplan.md) for menu-, rolle- og temakontrol.
+
+## Kundekortaktivering – 8. oktober 2026
+
+CRM → Kunde → Oversigt har en Workflows-sektion. Brug workflows for kunden gemmes med bruger, tidspunkt og begrundelse og gælder uden genstart pr. kunde. Et gemt Til eller Fra overstyrer den gamle `WORKFLOWS_CUSTOMER_IDS`-standard, også efter genstart. En tom miljøliste er nu tilladt i aktiv tilstand; kun tilvalgte kunder får aktiv behandling.
+
+Global `WORKFLOWS_ENABLED` og shadow/active er fortsat hovedkontroller. Kundekortet viser både tilvalget og den faktiske tilstand. Ændring kræver `customers.edit` og `workflows.activate`; læsning kræver `customers.view` eller `workflows.view`. Eksisterende tider genbehandles ikke automatisk. Afregningskontrol, worker og driftsstatus læser samme kundevalg.
+
+Migration 1067 er installeret lokalt. Motoren er fortsat slukket, og ingen kundetilvalg er foretaget som del af installationen. Backup før ændringen ligger i `/private/tmp/bmc-workflow-backup-_tf0w3xd`. Afinstallation og auditbackup omfatter de to nye kundetilvalgstabeller.
+
+139 workflow-/kundekorttests består med 17 databasecases sprunget over i standardkørslen. Databasepakken består særskilt med 27 tests (heraf 10 overlappende mailtests), inklusive persistens, samtidige kundetilvalg og afinstallation. Kundekortets seks JavaScript-adfærdscases indgår i standardkørslen. Manuel browserkontrol og produktionsinstallation er ikke udført.
diff --git a/main.py b/main.py
index 0bddff4..929d2f2 100644
--- a/main.py
+++ b/main.py
@@ -525,6 +525,8 @@ from app.workflows import export_guard as workflow_exports
app.include_router(workflow_exports.router, prefix="/api/v1", tags=["Workflow Exports"])
from app.workflows import monitoring as workflow_monitoring
app.include_router(workflow_monitoring.router, prefix="/api/v1", tags=["Workflow Monitoring"])
+from app.workflows import customer_activation as workflow_customer_activation
+app.include_router(workflow_customer_activation.router, prefix="/api/v1", tags=["Customer Workflows"])
app.include_router(workflows_api.router, prefix="/api/v1", tags=["Workflows"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
diff --git a/migrations/1067_workflow_customer_activation.sql b/migrations/1067_workflow_customer_activation.sql
new file mode 100644
index 0000000..f252627
--- /dev/null
+++ b/migrations/1067_workflow_customer_activation.sql
@@ -0,0 +1,12 @@
+-- Per-customer preferences never enable the global workflow master switch.
+CREATE TABLE IF NOT EXISTS bmc_workflow_customer_activation (
+ customer_id INTEGER PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL,updated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS bmc_workflow_customer_activation_history (
+ id BIGSERIAL PRIMARY KEY,customer_id INTEGER NOT NULL,before_enabled BOOLEAN,
+ after_enabled BOOLEAN NOT NULL,inherited_enabled BOOLEAN NOT NULL,
+ actor_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,reason TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/scripts/workflow_backup.py b/scripts/workflow_backup.py
index 3dcfe31..eed1d1f 100644
--- a/scripts/workflow_backup.py
+++ b/scripts/workflow_backup.py
@@ -90,7 +90,7 @@ def main():
verify_backup(args.backup_dir.resolve())
# Take fresh full checkpoint and standalone audit export immediately before removal.
fresh=backup()
- audit_tables=['bmc_workflow_exports','bmc_workflow_resource_history','bmc_workflow_activation_history','bmc_workflow_versions','bmc_workflows','bmc_workflow_events','bmc_workflow_effects','bmc_workflow_executions','bmc_billing_decisions','bmc_workflow_settlements','workflow_tag_history','workflow_tag_proposals','workflow_tag_keywords','workflow_tag_ai_jobs','bmc_workflow_runtime']
+ audit_tables=['bmc_workflow_exports','bmc_workflow_customer_activation','bmc_workflow_customer_activation_history','bmc_workflow_resource_history','bmc_workflow_activation_history','bmc_workflow_versions','bmc_workflows','bmc_workflow_events','bmc_workflow_effects','bmc_workflow_executions','bmc_billing_decisions','bmc_workflow_settlements','workflow_tag_history','workflow_tag_proposals','workflow_tag_keywords','workflow_tag_ai_jobs','bmc_workflow_runtime']
with (fresh/'workflow-audit.sql').open('wb') as stream:
command(['docker','exec',CONTAINER,'pg_dump','-U',USER,'-d',DB,'--data-only','--no-owner','--no-acl',*[f'--table={table}' for table in audit_tables]],stdout=stream)
metadata=json.loads((fresh/'manifest.json').read_text())
diff --git a/scripts/workflow_uninstall.sql b/scripts/workflow_uninstall.sql
index ddfb1e4..55c74f7 100644
--- a/scripts/workflow_uninstall.sql
+++ b/scripts/workflow_uninstall.sql
@@ -24,6 +24,8 @@ DROP FUNCTION IF EXISTS bmc_invalidate_tag_times();
DROP FUNCTION IF EXISTS workflow_entity_tag_audit();
DROP FUNCTION IF EXISTS bmc_workflow_version_immutable();
DROP TABLE IF EXISTS bmc_workflow_exports;
+DROP TABLE IF EXISTS bmc_workflow_customer_activation_history;
+DROP TABLE IF EXISTS bmc_workflow_customer_activation;
DROP TABLE IF EXISTS bmc_workflow_resource_history;
DROP TABLE IF EXISTS bmc_workflow_settlements;
DROP TABLE IF EXISTS bmc_workflow_effects;
diff --git a/static/js/customer-workflow.js b/static/js/customer-workflow.js
new file mode 100644
index 0000000..920f54c
--- /dev/null
+++ b/static/js/customer-workflow.js
@@ -0,0 +1,91 @@
+/* Isolated CRM preference panel; global runtime settings are read-only here. */
+(() => {
+ 'use strict';
+ function initialise() {
+ const card = document.getElementById('customerWorkflowCard');
+ if (!card) return;
+ const customerId = Number(card.dataset.customerId);
+ if (!Number.isInteger(customerId) || customerId <= 0) return;
+ const field = name => document.getElementById('customerWorkflow' + name);
+ const endpoint = '/api/v1/workflows/customers/' + customerId + '/activation';
+ let saved = null, busy = false;
+
+ function message(text, error = false) {
+ field('Message').textContent = text;
+ field('Message').className = 'small mt-2 ' + (error ? 'text-danger' : 'text-success');
+ }
+ function render(data) {
+ saved = data;
+ const canManage = !!data.can_manage && data.schema_ready !== false;
+ card.hidden = false;
+ field('Enabled').checked = data.enabled === true;
+ field('Enabled').disabled = !canManage || busy;
+ field('Write').hidden = !canManage;
+ field('Write').classList.toggle('d-flex', !!canManage);
+ field('Reason').disabled = !canManage || busy;
+ field('Save').disabled = !canManage || busy;
+ field('Effective').textContent = data.effective_enabled ? 'Aktiv for kunden' : 'Ikke aktiv';
+ field('Effective').className = 'badge ' + (data.effective_enabled ? 'text-bg-success' : 'text-bg-secondary');
+ field('Global').textContent = !data.global_enabled
+ ? 'Workflowmotoren er slukket. Kundens valg er gemt og bruges, når motoren aktiveres.'
+ : data.mode === 'shadow'
+ ? 'Motoren kører i skyggetilstand. Kundens valg er gemt; afregningen ændres ikke.'
+ : data.effective_enabled
+ ? 'Automatisk afregning er slået til. Bogholderiet godkender tiden.'
+ : 'Motoren er aktiv, men workflows er slået fra for denne kunde.';
+ field('Preference').textContent = 'Kundens gemte valg: ' + (data.enabled ? 'Til' : 'Fra')
+ + (data.inherited ? ' · tidligere opsætning' : '');
+ if (data.schema_ready === false) message('Workflowindstillingen kan først ændres, når databasens workflowopdatering er installeret.', true);
+ }
+ async function request(body) {
+ const response = await fetch(endpoint, {
+ method: body ? 'PUT' : 'GET', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const error = new Error(typeof data.detail === 'string' ? data.detail : 'Workflowindstillingen kunne ikke hentes.');
+ error.status = response.status;
+ throw error;
+ }
+ return data;
+ }
+ field('Save').addEventListener('click', async () => {
+ if (!saved?.can_manage || saved.schema_ready === false || busy) return;
+ const reason = field('Reason').value.trim();
+ if (reason.length < 3 || reason.length > 1000) {
+ message('Angiv en kort begrundelse, før du gemmer.', true);
+ field('Reason').focus();
+ return;
+ }
+ const enabled = field('Enabled').checked;
+ busy = true;
+ field('Save').disabled = field('Enabled').disabled = field('Reason').disabled = true;
+ field('Save').textContent = 'Gemmer…';
+ try {
+ const updated = await request({ enabled, reason });
+ busy = false;
+ render(updated);
+ field('Reason').value = '';
+ message('Kundens workflowvalg er gemt. Eksisterende tidsregistreringer genbehandles ikke.');
+ } catch (error) {
+ busy = false;
+ render(saved);
+ message(error.message, true);
+ } finally {
+ field('Save').textContent = 'Gem';
+ }
+ });
+ request().then(render).catch(error => {
+ // Lack of workflow access does not interrupt the existing customer page.
+ if (error.status === 401 || error.status === 403) return;
+ card.hidden = false;
+ field('Effective').textContent = 'Status ukendt';
+ field('Global').textContent = 'Kundens workflowindstilling kunne ikke hentes.';
+ message(error.message, true);
+ });
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialise, { once: true });
+ else initialise();
+})();
diff --git a/tests/test_customer_workflow_ui.py b/tests/test_customer_workflow_ui.py
new file mode 100644
index 0000000..bc43034
--- /dev/null
+++ b/tests/test_customer_workflow_ui.py
@@ -0,0 +1,46 @@
+import shutil
+import subprocess
+import pytest
+
+
+@pytest.mark.skipif(not shutil.which('node'), reason='Node required for CRM panel behavior')
+@pytest.mark.parametrize('scenario', ['readonly', 'shadow', 'save', 'forbidden', 'read_failure', 'missing_schema'])
+def test_customer_workflow_panel_isolated_permissions_and_effective_status(scenario):
+ script = r'''
+const assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
+const scenario=SCENARIO,elements={},calls=[];
+for(const name of ['Card','Enabled','Write','Reason','Save','Effective','Global','Preference','Message']){
+ elements['customerWorkflow'+name]={hidden:name==='Card'||name==='Write',disabled:true,checked:false,value:'',textContent:'',dataset:{customerId:'24'},classList:{toggle(){}},listeners:{},addEventListener(type,fn){this.listeners[type]=fn;},focus(){}};
+}
+const document={readyState:'complete',getElementById:id=>elements[id]};
+let data={customer_id:24,enabled:true,inherited:false,effective_enabled:false,global_enabled:false,mode:'active',can_manage:scenario==='save'||scenario==='shadow'};
+if(scenario==='shadow')data={...data,global_enabled:true,mode:'shadow'};
+if(scenario==='save')data={...data,enabled:false,global_enabled:true};
+if(scenario==='missing_schema')data={...data,can_manage:true,schema_ready:false};
+const fetch=async(url,options)=>{calls.push({url,options});if(scenario==='read_failure')throw Error('Offline');if(scenario==='forbidden')return{ok:false,status:403,json:async()=>({detail:'No access'})};if(options.method==='PUT')data={...data,...JSON.parse(options.body),effective_enabled:true};return{ok:true,json:async()=>data};};
+vm.runInNewContext(fs.readFileSync('static/js/customer-workflow.js','utf8'),{document,fetch,console});
+(async()=>{
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(calls[0].url,'/api/v1/workflows/customers/24/activation');
+ const card=elements.customerWorkflowCard;
+ if(scenario==='forbidden'){assert.equal(card.hidden,true);return;}
+ assert.equal(card.hidden,false);
+ if(scenario==='read_failure'){assert.equal(elements.customerWorkflowEnabled.disabled,true);assert.match(elements.customerWorkflowMessage.textContent,/Offline/);return;}
+ assert.equal(elements.customerWorkflowEffective.textContent,'Ikke aktiv');
+ if(scenario==='readonly'||scenario==='missing_schema'){
+ assert.equal(elements.customerWorkflowWrite.hidden,true);assert.equal(elements.customerWorkflowEnabled.disabled,true);
+ await elements.customerWorkflowSave.listeners.click();assert.equal(calls.length,1);
+ assert.match(elements.customerWorkflowGlobal.textContent,/slukket/);
+ if(scenario==='missing_schema')assert.match(elements.customerWorkflowMessage.textContent,/workflowopdatering/);
+ }else if(scenario==='shadow'){
+ assert.match(elements.customerWorkflowGlobal.textContent,/skyggetilstand/);assert.equal(elements.customerWorkflowEnabled.checked,true);
+ }else{
+ elements.customerWorkflowEnabled.checked=true;elements.customerWorkflowReason.value='Nye regler';
+ await elements.customerWorkflowSave.listeners.click();
+ assert.deepEqual(JSON.parse(calls[1].options.body),{enabled:true,reason:'Nye regler'});
+ assert.equal(calls[1].options.method,'PUT');assert.equal(elements.customerWorkflowEffective.textContent,'Aktiv for kunden');
+ }
+})().catch(error=>{console.error(error);process.exitCode=1;});
+'''.replace('SCENARIO', repr(scenario))
+ result = subprocess.run(['node', '-e', script], text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
diff --git a/tests/test_workflow_customer_activation.py b/tests/test_workflow_customer_activation.py
new file mode 100644
index 0000000..fbcd27d
--- /dev/null
+++ b/tests/test_workflow_customer_activation.py
@@ -0,0 +1,122 @@
+from contextlib import contextmanager
+from unittest.mock import MagicMock
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from app.workflows import customer_activation as api, integration, worker
+
+@pytest.fixture
+def client(monkeypatch):
+ state={'exists':True,'schema':True,'override':None,'history':[],'rights':{'customers.view','customers.edit','workflows.activate'}}
+ class Cursor:
+ def execute(self,sql,params=None):
+ self.sql=sql
+ if sql.startswith('INSERT INTO bmc_workflow_customer_activation('):
+ state['override']={'customer_id':params[0],'enabled':params[1],'updated_by':params[2],'updated_at':None}
+ elif sql.startswith('INSERT INTO bmc_workflow_customer_activation_history'):
+ state['history'].append(params)
+ def fetchone(self):
+ if 'FROM customers' in self.sql: return {'id':24} if state['exists'] else None
+ if 'to_regclass' in self.sql: return {'name':'bmc_workflow_customer_activation' if state['schema'] else None}
+ return state['override']
+ @contextmanager
+ def transaction(): yield Cursor()
+ monkeypatch.setattr(api,'transaction',transaction)
+ monkeypatch.setattr(api.AuthService,'user_has_permission',lambda actor,permission:permission in state['rights'])
+ monkeypatch.setattr(api.settings,'WORKFLOWS_ENABLED',False)
+ monkeypatch.setattr(api.settings,'WORKFLOWS_MODE','active')
+ monkeypatch.setattr(api.settings,'WORKFLOWS_CUSTOMER_IDS',[24])
+ app=FastAPI()
+ app.include_router(api.router,prefix='/api/v1')
+ app.dependency_overrides[api.get_current_user]=lambda:{'id':4,'username':'tester','is_superadmin':False}
+ return TestClient(app),state
+
+def test_inherited_env_preference_does_not_enable_global_master(client):
+ browser,state=client
+ response=browser.get('/api/v1/workflows/customers/24/activation')
+ assert response.status_code==200
+ body=response.json()
+ assert body['enabled'] is True and body['inherited'] is True
+ assert body['explicit_override'] is None and body['effective_enabled'] is False
+ assert state['history']==[]
+
+def test_false_override_wins_and_records_reason(client):
+ browser,state=client
+ response=browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':False,'reason':'Stop automatik'})
+ assert response.status_code==200
+ body=response.json()
+ assert body['enabled'] is False and body['inherited'] is False and body['explicit_override'] is False
+ assert state['history']==[(24,None,False,True,4,'Stop automatik')]
+
+def test_saved_true_preference_remains_inactive_in_shadow(client,monkeypatch):
+ browser,state=client
+ monkeypatch.setattr(api.settings,'WORKFLOWS_ENABLED',True)
+ monkeypatch.setattr(api.settings,'WORKFLOWS_MODE','shadow')
+ result=browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':'Kunde har godkendt'})
+ assert result.status_code==200
+ assert result.json()['enabled'] is True and result.json()['effective_enabled'] is False
+
+@pytest.mark.parametrize('missing',['customers.edit','workflows.activate'])
+def test_write_requires_both_permissions(client,missing):
+ browser,state=client
+ state['rights'].remove(missing)
+ response=browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':'Test årsag'})
+ assert response.status_code==403 and state['history']==[]
+
+def test_read_accepts_workflow_view_without_customer_view(client):
+ browser,state=client
+ state['rights']={'workflows.view'}
+ response=browser.get('/api/v1/workflows/customers/24/activation')
+ assert response.status_code==200 and response.json()['can_manage'] is False
+
+def test_nonexistent_customer_and_short_reason_rejected(client):
+ browser,state=client
+ state['exists']=False
+ assert browser.get('/api/v1/workflows/customers/24/activation').status_code==404
+ state['exists']=True
+ assert browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':' '}).status_code==422
+ assert state['history']==[]
+
+def test_missing_schema_reads_env_but_refuses_write(client):
+ browser,state=client
+ state['schema']=False
+ assert browser.get('/api/v1/workflows/customers/24/activation').json()['enabled'] is True
+ assert browser.put('/api/v1/workflows/customers/24/activation',json={'enabled':True,'reason':'Test årsag'}).status_code==503
+
+def test_helper_false_database_override_wins_even_explicit_fallback(monkeypatch):
+ cursor=MagicMock()
+ cursor.fetchone.side_effect=[{'name':'activation'},{'enabled':False}]
+ assert integration.customer_enabled(24,cursor,fallback_ids=[24]) is False
+ assert 'FOR SHARE' in cursor.execute.call_args.args[0]
+
+def test_effective_ids_merge_true_and_false_overrides(monkeypatch):
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[24,25])
+ cursor=MagicMock()
+ cursor.fetchone.return_value={'name':'activation'}
+ cursor.fetchall.return_value=[{'customer_id':24,'enabled':False},{'customer_id':26,'enabled':True}]
+ assert integration.effective_customer_ids(cursor)==[25,26]
+
+def test_active_global_empty_env_allows_saved_customer(monkeypatch):
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_ENABLED',True)
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_MODE','active')
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[])
+ monkeypatch.setattr(integration,'execute_query_single',lambda sql,params=None:{'name':'activation'} if 'to_regclass' in sql else {'enabled':True})
+ assert integration.active_for_customer(24) is True
+
+def test_active_empty_environment_does_not_prevent_customer_card_activation(monkeypatch):
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_ENABLED',True)
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_MODE','active')
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[])
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_INTERNAL_ENABLED',False)
+ monkeypatch.setattr(integration,'execute_query_single',lambda sql:{'name':'runtime'})
+ writes=MagicMock()
+ monkeypatch.setattr(integration,'execute_update',writes)
+ integration.configure_runtime()
+ writes.assert_called_once()
+ assert 'bmc_workflow_customer_activation' not in writes.call_args.args[0]
+
+def test_real_database_error_does_not_fall_back_to_env_enablement(monkeypatch):
+ cursor=MagicMock()
+ cursor.execute.side_effect=RuntimeError('Database unavailable')
+ with pytest.raises(RuntimeError,match='unavailable'):
+ integration.customer_enabled(24,cursor,fallback_ids=[24])
diff --git a/tests/test_workflow_customer_activation_db.py b/tests/test_workflow_customer_activation_db.py
new file mode 100644
index 0000000..01bc21b
--- /dev/null
+++ b/tests/test_workflow_customer_activation_db.py
@@ -0,0 +1,60 @@
+"""Opt-in persistence/concurrency checks on the disposable BMC clone only."""
+from concurrent.futures import ThreadPoolExecutor
+from threading import Barrier
+import pytest
+from psycopg2.extras import RealDictCursor
+from test_workflow_db import db
+from app.core.database import atomic_transaction
+from app.workflows import customer_activation as api,integration
+
+@pytest.fixture
+def activation_customer(db):
+ with db.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute('SELECT id FROM customers ORDER BY id LIMIT 1')
+ customer=cursor.fetchone()['id']
+ cursor.execute('SELECT user_id FROM users ORDER BY user_id LIMIT 1')
+ actor=cursor.fetchone()['user_id']
+ cursor.execute('SELECT * FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
+ previous=cursor.fetchone()
+ cursor.execute('SELECT COALESCE(MAX(id),0) AS id FROM bmc_workflow_customer_activation_history')
+ history_start=cursor.fetchone()['id']
+ cursor.execute('DELETE FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
+ yield customer,{'id':actor,'username':'integration-test','is_superadmin':True},history_start
+ with db.cursor() as cursor:
+ cursor.execute('DELETE FROM bmc_workflow_customer_activation_history WHERE customer_id=%s AND id>%s',(customer,history_start))
+ cursor.execute('DELETE FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
+ if previous:
+ cursor.execute('INSERT INTO bmc_workflow_customer_activation(customer_id,enabled,updated_by,updated_at) VALUES(%s,%s,%s,%s)',(customer,previous['enabled'],previous['updated_by'],previous['updated_at']))
+
+def test_saved_override_is_persistent_and_master_off_stays_off(db,activation_customer,monkeypatch):
+ customer,actor,_=activation_customer
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_ENABLED',False)
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_MODE','active')
+ monkeypatch.setattr(integration.settings,'WORKFLOWS_CUSTOMER_IDS',[])
+ result=api.set_activation(customer,api.ActivationUpdate(enabled=True,reason='Disposable persistence test'),user=actor,workflow_access=actor)
+ assert result['enabled'] is True and result['effective_enabled'] is False
+ assert integration.customer_enabled(customer) is True
+ assert integration.active_for_customer(customer) is False
+ disabled=api.set_activation(customer,api.ActivationUpdate(enabled=False,reason='Disabled override test'),user=actor,workflow_access=actor)
+ assert disabled['explicit_override'] is False
+ with atomic_transaction() as connection:
+ with connection.cursor(cursor_factory=RealDictCursor) as cursor:
+ assert integration.customer_enabled(customer,cursor,fallback_ids=[customer]) is False
+
+def test_concurrent_first_overrides_preserve_correct_audit_chain(db,activation_customer):
+ customer,actor,history_start=activation_customer
+ barrier=Barrier(2)
+ def write(enabled):
+ barrier.wait(timeout=10)
+ return api.set_activation(customer,api.ActivationUpdate(enabled=enabled,reason='Concurrent disposable test'),user=actor,workflow_access=actor)
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results=list(pool.map(write,[True,False]))
+ assert len(results)==2
+ with db.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute('SELECT before_enabled,after_enabled FROM bmc_workflow_customer_activation_history WHERE customer_id=%s AND id>%s ORDER BY id',(customer,history_start))
+ history=cursor.fetchall()
+ assert len(history)==2
+ assert history[0]['before_enabled'] is None
+ assert history[1]['before_enabled']==history[0]['after_enabled']
+ cursor.execute('SELECT enabled FROM bmc_workflow_customer_activation WHERE customer_id=%s',(customer,))
+ assert cursor.fetchone()['enabled']==history[1]['after_enabled']