diff --git a/.gitignore b/.gitignore index 56fb693..cc26589 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,6 @@ htmlcov/ .coverage .pytest_cache/ .mypy_cache/ +# Generated Graphify graphs and caches (keep local only) +graphify-out/ RELEASE_NOTES_v2.2.38.md diff --git a/MDfile/RELEASE_NOTES_v2.8.22.md b/MDfile/RELEASE_NOTES_v2.8.22.md new file mode 100644 index 0000000..9d69b98 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.22.md @@ -0,0 +1,22 @@ +# BMC Hub v2.8.22 + +## Arbejdsgange, sager og tid + +- Tilføjer den reversible workflow-motor med kontrolleret aktivering, preview og revisionsspor for afregning. +- Udvider sagsarbejdsrummet, tidsregistrering og planlægning med tydeligere kapacitet, opgaver og opfølgning. +- Tilføjer sikker fjernstyring og forbedrede sagskommandoer uden ændring af eksisterende API-kontrakter. + +## Kunder, e-mail og drift + +- Forbedrer kunde- og kontaktarbejdsgange, e-mailidentitet og routing samt Drift-filtersikkerhed. +- Tilføjer planlagt onboarding/offboarding på kontaktkortet med eksplicit sagsoprettelse og audit-spor. +- Udvider hardware-intake, leverandørdata og økonomiske integrationsvisninger. + +## Database + +Kør migrationerne `1053` til `1076` i numerisk rækkefølge før eller sammen med opstart af versionen. Migration `1076_contact_lifecycle.sql` opretter kun nye tabeller og indeks. + +## Validering + +- Python: `986 passed, 103 skipped` med workflow- og remote-feature-flags slået fra i unit-testmiljøet. +- JavaScript: `86 passed`. diff --git a/VERSION b/VERSION index 6c4bbe4..36b69a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.21 +2.8.22 diff --git a/app/contacts/backend/lifecycle.py b/app/contacts/backend/lifecycle.py new file mode 100644 index 0000000..b4beaff --- /dev/null +++ b/app/contacts/backend/lifecycle.py @@ -0,0 +1,213 @@ +"""Additive, customer-scoped contact onboarding and offboarding API.""" +from datetime import date +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.core.auth_dependencies import require_any_permission +from app.core.case_visibility import hidden_case_ids, require_case +from app.core.database import atomic_transaction, execute_query, execute_query_single + +router = APIRouter() +read_access = require_any_permission('cases.view', 'tickets.view') +edit_access = require_any_permission('cases.edit', 'tickets.edit') +EventType = Literal['onboarding', 'offboarding'] + + +class LifecycleCreate(BaseModel): + customer_id: int = Field(gt=0) + event_type: EventType + event_date: date + lead_days: int = Field(default=14, ge=0, le=3650) + auto_create: bool = False + template_id: Optional[int] = Field(default=None, gt=0) + responsible_user_id: Optional[int] = Field(default=None, gt=0) + parent_case_id: Optional[int] = Field(default=None, gt=0) + + +class LifecycleUpdate(BaseModel): + customer_id: Optional[int] = Field(default=None, gt=0) + event_date: Optional[date] = None + lead_days: Optional[int] = Field(default=None, ge=0, le=3650) + auto_create: Optional[bool] = None + template_id: Optional[int] = Field(default=None, gt=0) + responsible_user_id: Optional[int] = Field(default=None, gt=0) + parent_case_id: Optional[int] = Field(default=None, gt=0) + + +def _ready(): + if not (execute_query_single("SELECT to_regclass('public.contact_lifecycle_events') AS relation") or {}).get('relation'): + raise HTTPException(503, detail='Onboarding/offboarding afventer databasemigration 1076') + + +def _contact(contact_id): + row = execute_query_single('SELECT id FROM contacts WHERE id=%s', (contact_id,)) + if not row: + raise HTTPException(404, detail='Kontaktpersonen findes ikke') + + +def _membership(contact_id, customer_id): + if not execute_query_single('SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s', + (contact_id, customer_id)): + raise HTTPException(400, detail='Kunden er ikke tilknyttet kontaktpersonen') + + +def _has(user, *permissions): + return bool(user.get('is_superadmin') or set(permissions).intersection(user.get('permissions') or [])) + + +def _validate(row, user): + _membership(row['contact_id'], row['customer_id']) + if row['auto_create'] and not _has(user, 'cases.create', 'tickets.create'): + raise HTTPException(403, detail='Du mangler rettighed til at oprette sager') + if row.get('responsible_user_id') and not execute_query_single( + 'SELECT user_id FROM users WHERE user_id=%s AND is_active=TRUE', (row['responsible_user_id'],) + ): + raise HTTPException(400, detail='Den ansvarlige bruger er ikke aktiv') + if row.get('template_id'): + if not _has(user, 'templates.run'): + raise HTTPException(403, detail='Du mangler rettighed til at anvende skabeloner') + if not execute_query_single('''SELECT id FROM task_templates WHERE id=%s + AND is_active=TRUE AND deleted_at IS NULL AND category=%s + AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=%s))''', + (row['template_id'], row['event_type'], row['customer_id'])): + raise HTTPException(400, detail='Skabelonen er ikke tilgængelig for kunden og forløbstypen') + from app.contacts.backend.lifecycle_service import find_parent_candidates + candidates = find_parent_candidates(row['contact_id'], row['customer_id'], row['event_type']) + hidden = hidden_case_ids(user) + candidates = [c for c in candidates if c['id'] not in hidden] + # A generated case must never become its own parent during later edits. + candidates = [c for c in candidates if c['id'] != row.get('case_id')] + if row.get('parent_case_id') and row['parent_case_id'] not in [c['id'] for c in candidates]: + # Parent can have been closed after creation: retaining it is valid; changing it is not. + if not row.get('case_id'): + raise HTTPException(400, detail='Hovedsagen skal være en åben sag for samme kontakt, kunde og type') + return candidates + + +def _serialize(row): + from app.contacts.backend.lifecycle_service import scheduled_at + result = dict(row) + result['scheduled_at'] = scheduled_at(row['event_date'], row['lead_days']).isoformat() + return result + + +def _find_event(contact_id, event_id, lock=False): + row = execute_query_single('SELECT * FROM contact_lifecycle_events WHERE contact_id=%s AND id=%s' + + (' FOR UPDATE' if lock else ''), (contact_id, event_id)) + if not row: + raise HTTPException(404, detail='Forløbet findes ikke') + return row + + +@router.get('/contacts/{contact_id}/lifecycle') +def list_lifecycle(contact_id: int, current_user: dict = Depends(read_access)): + _ready() + _contact(contact_id) + companies = execute_query('''SELECT c.id,c.name FROM customers c JOIN contact_companies cc + ON cc.customer_id=c.id WHERE cc.contact_id=%s ORDER BY c.name''', (contact_id,)) or [] + templates = [] + if _has(current_user, 'templates.view', 'templates.run'): + templates = execute_query('''SELECT id,name,category,customer_id,template_type FROM task_templates + WHERE deleted_at IS NULL AND is_active=TRUE AND category IN ('onboarding','offboarding') + AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=ANY(%s))) + ORDER BY name''', ([c['id'] for c in companies],)) or [] + hidden = hidden_case_ids(current_user) + items = [] + for row in execute_query( + 'SELECT * FROM contact_lifecycle_events WHERE contact_id=%s ORDER BY event_date DESC,id DESC', + (contact_id,)) or []: + item = _serialize(row) + for key in ('case_id', 'parent_case_id'): + if item.get(key) in hidden: + item[key] = None + items.append(item) + return {'items': items, 'companies': companies, 'templates': templates, + 'can_edit': _has(current_user, 'cases.edit', 'tickets.edit'), + 'users': execute_query('''SELECT user_id AS id,COALESCE(NULLIF(full_name,''),username) AS name + FROM users WHERE is_active=TRUE ORDER BY name''') or []} + + +@router.get('/contacts/{contact_id}/lifecycle/preview') +def preview_lifecycle(contact_id: int, customer_id: int = Query(gt=0), + event_type: EventType = Query(), event_date: date = Query(), + lead_days: int = Query(default=14, ge=0, le=3650), + current_user: dict = Depends(read_access)): + _ready() + _contact(contact_id) + _membership(contact_id, customer_id) + from app.contacts.backend.lifecycle_service import find_parent_candidates, scheduled_at + candidates = find_parent_candidates(contact_id, customer_id, event_type) + hidden = hidden_case_ids(current_user) + candidates = [c for c in candidates if c['id'] not in hidden] + return {'scheduled_at': scheduled_at(event_date, lead_days).isoformat(), 'candidates': candidates, + 'parent_case_id': candidates[0]['id'] if len(candidates) == 1 else None, + 'needs_parent': len(candidates) > 1} + + +@router.post('/contacts/{contact_id}/lifecycle', status_code=201) +def create_lifecycle(contact_id: int, payload: LifecycleCreate, current_user: dict = Depends(edit_access)): + _ready() + _contact(contact_id) + row = dict(payload.model_dump(), contact_id=contact_id) + candidates = _validate(row, current_user) + status = 'draft' if not row['auto_create'] else ( + 'needs_parent' if len(candidates) > 1 and not row['parent_case_id'] else 'scheduled') + columns = list(row) + result = execute_query_single(f'''INSERT INTO contact_lifecycle_events + ({','.join(columns)},status,created_by) VALUES ({','.join(['%s'] * len(columns))},%s,%s) RETURNING *''', + tuple(row.values()) + (status, current_user['id'])) + return _serialize(result) + + +@router.patch('/contacts/{contact_id}/lifecycle/{event_id}') +def update_lifecycle(contact_id: int, event_id: int, payload: LifecycleUpdate, + current_user: dict = Depends(edit_access)): + _ready() + updates = payload.model_dump(exclude_unset=True) + for required in ('customer_id', 'event_date', 'lead_days', 'auto_create'): + if required in updates and updates[required] is None: + raise HTTPException(422, detail=f'{required} må ikke være tom') + with atomic_transaction(): + previous = _find_event(contact_id, event_id, lock=True) + if previous.get('case_id'): + require_case(current_user, previous['case_id']) + if previous['status'] == 'cancelled': + raise HTTPException(409, detail='Et annulleret forløb kan ikke ændres; opret et nyt') + row = dict(previous, **updates) + if row.get('case_id') and any(row.get(k) != previous.get(k) for k in + ('customer_id', 'template_id', 'parent_case_id', 'responsible_user_id')): + raise HTTPException(409, detail='Kunde, skabelon, hovedsag og ansvarlig ændres på den oprettede sag') + status = previous['status'] + last_error = previous.get('last_error') + if row.get('case_id'): + if row['event_date'] != previous['event_date']: + from app.contacts.backend.lifecycle_service import update_created_case_date + result = update_created_case_date(row, previous['event_date'], current_user['id']) + status, last_error = result['status'], result.get('last_error') + else: + candidates = _validate(row, current_user) + status = 'draft' if not row['auto_create'] else ( + 'needs_parent' if len(candidates) > 1 and not row.get('parent_case_id') else 'scheduled') + last_error = None + if row['auto_create']: + # The last user authorizing automation is the actor whose rights + # are checked by the worker, including when a disabled user is replaced. + updates['created_by'] = current_user['id'] + updates.update(status=status, last_error=last_error) + columns = list(updates) + result = execute_query_single(f'''UPDATE contact_lifecycle_events SET + {','.join(c + '=%s' for c in columns)},updated_at=NOW() WHERE id=%s RETURNING *''', + tuple(updates.values()) + (event_id,)) + return _serialize(result) + + +@router.post('/contacts/{contact_id}/lifecycle/{event_id}/cancel') +def cancel_lifecycle(contact_id: int, event_id: int, current_user: dict = Depends(edit_access)): + _ready() + with atomic_transaction(): + _find_event(contact_id, event_id, lock=True) + row = execute_query_single('''UPDATE contact_lifecycle_events SET status='cancelled',auto_create=FALSE, + last_error=NULL,updated_at=NOW() WHERE id=%s RETURNING *''', (event_id,)) + return _serialize(row) diff --git a/app/contacts/backend/lifecycle_service.py b/app/contacts/backend/lifecycle_service.py new file mode 100644 index 0000000..5f0d9c1 --- /dev/null +++ b/app/contacts/backend/lifecycle_service.py @@ -0,0 +1,199 @@ +"""Transactional contact lifecycle scheduling using Hub's existing case contracts.""" +import html +import json +import logging +from datetime import date, datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo + +from fastapi import HTTPException + +from app.core.database import atomic_transaction, execute_query, execute_query_single + +logger = logging.getLogger(__name__) +TZ = ZoneInfo("Europe/Copenhagen") +LABELS = {"onboarding": "Onboarding", "offboarding": "Offboarding"} + + +def scheduled_at(event_date, lead_days=14): + if isinstance(event_date, str): + event_date = date.fromisoformat(event_date) + return datetime.combine(event_date - timedelta(days=lead_days), time(8), TZ) + + +def _deadline(event_date): + """Existing case timestamps store UTC without timezone information.""" + if isinstance(event_date, str): + event_date = date.fromisoformat(event_date) + return datetime.combine(event_date, time(17), TZ).astimezone(timezone.utc).replace(tzinfo=None) + + +def closed_statuses(): + values = {"lukket", "løst", "afsluttet", "closed", "resolved", "done", "annulleret", "cancelled", "canceled"} + row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'") + if row and row.get("value"): + try: + options = row["value"] if isinstance(row["value"], list) else json.loads(row["value"]) + values.update(str(item["value"]).strip().lower() for item in options + if isinstance(item, dict) and item.get("is_closed") and item.get("value")) + except (TypeError, ValueError): + logger.warning("Ignoring invalid case_statuses configuration") + return sorted(values) + + +def find_parent_candidates(contact_id, customer_id, event_type): + """Only explicit type/tag classification and direct person linkage qualify.""" + return execute_query("""SELECT s.id,s.titel,s.status,s.customer_id + FROM sag_sager s + WHERE s.customer_id=%s AND s.deleted_at IS NULL + AND LOWER(TRIM(s.status)) <> ALL(%s) + AND EXISTS(SELECT 1 FROM sag_kontakter k WHERE k.sag_id=s.id + AND k.contact_id=%s AND k.deleted_at IS NULL) + AND (LOWER(s.type)=%s OR LOWER(s.template_key)=%s + OR EXISTS(SELECT 1 FROM sag_tags t WHERE t.sag_id=s.id + AND t.deleted_at IS NULL AND LOWER(TRIM(t.tag_navn))=%s)) + AND NOT EXISTS(SELECT 1 FROM sag_relationer r WHERE r."målsag_id"=s.id + AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL) + ORDER BY s.id DESC""", + (customer_id, closed_statuses(), contact_id, event_type, event_type, event_type)) or [] + + +def update_created_case_date(lifecycle, previous_event_date, actor_id): + """Called inside the API transaction; never changes the lifecycle row itself.""" + if not lifecycle.get("case_id") or lifecycle["event_date"] == previous_event_date: + return {"status": lifecycle.get("status", "created"), "last_error": lifecycle.get("last_error")} + from app.core.case_visibility import require_case + from app.core.auth_service import AuthService + actor = {"id": actor_id, "permissions": AuthService.get_user_permissions(actor_id)} + require_case(actor, lifecycle["case_id"]) + case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE", + (lifecycle["case_id"],)) + if not case or case.get("deleted_at") or str(case["status"]).strip().lower() in closed_statuses(): + return {"status": "manual_review", "last_error": "Datoen er ændret. Sagen er afsluttet eller fjernet; kontrollér deadline manuelt."} + execute_query("UPDATE sag_sager SET deadline=%s,updated_at=NOW() WHERE id=%s", + (_deadline(lifecycle["event_date"]), case["id"])) + _comment(case["id"], f"{LABELS[lifecycle['event_type']]}dato ændret fra {previous_event_date} til {lifecycle['event_date']}. Deadline opdateret.") + return {"status": "created", "last_error": None} + + +def _comment(case_id, message): + execute_query("""INSERT INTO sag_kommentarer + (sag_id,forfatter,indhold,er_intern,er_system_besked) + VALUES(%s,'BMC Hub',%s,TRUE,TRUE)""", (case_id, html.escape(message))) + + +def _actor(event): + from app.core.auth_service import AuthService + user = execute_query_single("SELECT user_id,is_active,is_superadmin,full_name FROM users WHERE user_id=%s", + (event["created_by"],)) + if not user or not user["is_active"]: + raise HTTPException(403, "Forløbets opretter er ikke længere aktiv. Gem forløbet med en aktiv bruger.") + user["id"] = user["user_id"] + user["permissions"] = AuthService.get_user_permissions(user["id"]) + if not user["is_superadmin"]: + permissions = set(user["permissions"]) + if not permissions.intersection({"cases.create", "tickets.create"}): + raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette sager.") + if event.get("template_id") and "templates.run" not in permissions: + raise HTTPException(403, "Forløbets opretter mangler rettighed til at bruge skabelonen.") + return user + + +async def _create_case(event): + from app.core.case_visibility import require_case + actor = _actor(event) + if not execute_query_single("SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s", + (event["contact_id"], event["customer_id"])): + raise HTTPException(409, "Kontakten er ikke længere tilknyttet kunden. Vælg kunde igen.") + candidates = find_parent_candidates(event["contact_id"], event["customer_id"], event["event_type"]) + parent_id = event.get("parent_case_id") + if parent_id and parent_id not in {row["id"] for row in candidates}: + execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s, + updated_at=NOW() WHERE id=%s""", + ("Den valgte hovedsag kan ikke længere bruges. Vælg hovedsag igen.", event["id"])) + return False + if not parent_id and len(candidates) > 1: + execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s, + updated_at=NOW() WHERE id=%s""", + ("Flere åbne sager matcher. Vælg hovedsag på kontaktkortet.", event["id"])) + return False + parent_id = parent_id or (candidates[0]["id"] if candidates else None) + if parent_id: + require_case(actor, parent_id) + if not actor["is_superadmin"] and not set(actor["permissions"]).intersection({"cases.edit", "tickets.edit"}): + raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette undersager.") + # Lock and recheck before adding a relation; a concurrently closed parent cannot be used. + parent = execute_query_single("SELECT status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE", (parent_id,)) + if not parent or parent.get("deleted_at") or str(parent["status"]).strip().lower() in closed_statuses(): + raise HTTPException(409, "Hovedsagen er afsluttet. Vælg hovedsag igen.") + contact = execute_query_single("SELECT first_name,last_name FROM contacts WHERE id=%s", (event["contact_id"],)) + if not contact: + raise HTTPException(409, "Kontakten findes ikke længere.") + name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name")))) + label = LABELS[event["event_type"]] + responsible = event.get("responsible_user_id") + if responsible and not execute_query_single("SELECT user_id FROM users WHERE user_id=%s AND is_active", (responsible,)): + raise HTTPException(409, "Den ansvarlige bruger er ikke længere aktiv. Vælg ansvarlig igen.") + case = execute_query_single("""INSERT INTO sag_sager + (titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,created_by_user_id,deadline) + VALUES(%s,%s,'ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id""", + (f"{label}: {name}"[:255], f"{label} for {name}. Dato: {event['event_date']}.\nOprettet automatisk fra kontaktkortet.", + event["customer_id"], responsible, actor["id"], _deadline(event["event_date"]))) + case_id = case["id"] + execute_query("INSERT INTO sag_kontakter(sag_id,contact_id,role,is_primary) VALUES(%s,%s,'Kontakt',TRUE)", + (case_id, event["contact_id"])) + execute_query("INSERT INTO sag_tags(sag_id,tag_navn) VALUES(%s,%s)", (case_id, event["event_type"])) + if parent_id: + execute_query('INSERT INTO sag_relationer(kilde_sag_id,"målsag_id",relationstype) VALUES(%s,%s,\'undersag\')', + (parent_id, case_id)) + if event.get("template_id"): + if not execute_query_single("""SELECT id FROM task_templates WHERE id=%s + AND category=%s AND is_active AND deleted_at IS NULL""", + (event["template_id"], event["event_type"])): + raise HTTPException(409, "Skabelonen er ændret eller deaktiveret. Vælg skabelon igen.") + from app.modules.task_templates.backend.models import TemplateRunRequest + from app.modules.task_templates.backend.router import run_template_for_case + await run_template_for_case(case_id, TemplateRunRequest(template_id=event["template_id"], start_date=event["event_date"]), actor) + execute_query("""UPDATE contact_lifecycle_events SET case_id=%s,parent_case_id=%s, + status='created',last_error=NULL,updated_at=NOW() WHERE id=%s""", + (case_id, parent_id, event["id"])) + return True + + +async def process_due_lifecycles(now=None): + """Row locks and a single transaction encompass case, relation and template writes.""" + ready = execute_query_single("SELECT to_regclass('contact_lifecycle_events') IS NOT NULL AS ready") + if not ready or not ready["ready"]: + return 0 + now = now or datetime.now(timezone.utc) + if now.tzinfo is None: + raise ValueError("now must include timezone") + rows = execute_query("""SELECT id FROM contact_lifecycle_events + WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error') + AND ((event_date-lead_days)+TIME '08:00') AT TIME ZONE 'Europe/Copenhagen' <= %s + ORDER BY event_date,id LIMIT 100""", (now,)) or [] + created = 0 + for row in rows: + try: + with atomic_transaction(): + event = execute_query_single("SELECT * FROM contact_lifecycle_events WHERE id=%s FOR UPDATE SKIP LOCKED", (row["id"],)) + if not event or not event["auto_create"] or event.get("case_id") or event["status"] not in {"scheduled", "error"}: + continue + if scheduled_at(event["event_date"], event["lead_days"]) > now: + continue + did_create = await _create_case(event) + created += int(did_create) + except Exception as exc: + logger.exception("Contact lifecycle %s failed", row["id"]) + message = str(exc.detail) if isinstance(exc, HTTPException) else "Sagen kunne ikke oprettes. Systemet prøver igen; kontakt administrator hvis fejlen fortsætter." + execute_query("""UPDATE contact_lifecycle_events SET status='error',last_error=%s,updated_at=NOW() + WHERE id=%s AND case_id IS NULL AND status IN ('scheduled','error')""", (message, row["id"])) + return created + + +async def check_contact_lifecycles(): + """Isolate lifecycle failures from other scheduled jobs.""" + try: + return await process_due_lifecycles() + except Exception: + logger.exception("Contact lifecycle scheduler failed") + return 0 diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index 9f110de..44e64ec 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -34,6 +34,8 @@ from email.utils import parseaddr logger = logging.getLogger(__name__) router = APIRouter() +from app.contacts.backend.lifecycle import router as lifecycle_router +router.include_router(lifecycle_router) class ContactCreate(BaseModel): @@ -687,6 +689,9 @@ def _contact_merge_counts(contact_id: int) -> list[dict]: for label, table, column in CONTACT_MERGE_RELATIONS: row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {} counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)}) + if (execute_query_single("SELECT to_regclass('contact_lifecycle_events') AS relation") or {}).get('relation'): + row = execute_query_single("SELECT COUNT(*)::int AS count FROM contact_lifecycle_events WHERE contact_id=%s", (contact_id,)) or {} + counts.append({"key": "contact_lifecycle_events", "label": "Onboarding/offboarding", "count": int(row.get('count') or 0)}) conversation_row = execute_query_single( """ SELECT COUNT(DISTINCT conversation.id)::int AS count @@ -817,6 +822,11 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest): moved[table] = int(cursor.fetchone()["count"] or 0) cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id)) + cursor.execute("SELECT to_regclass('contact_lifecycle_events') AS relation") + if cursor.fetchone()['relation']: + cursor.execute("UPDATE contact_lifecycle_events SET contact_id=%s,updated_at=NOW() WHERE contact_id=%s", (contact_id, source_id)) + moved['contact_lifecycle_events'] = cursor.rowcount + cursor.execute( """ INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations) diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index f892e20..db56832 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -314,6 +314,14 @@ +
+
+
Onboarding og offboarding
+

Planlæg start og fratrædelse, og få oprettet en sag i god tid.

+
+
Henter forløb…
+
+
System Info
@@ -934,6 +942,7 @@ {% endblock %} {% block extra_js %} + {% include "alert_notes/frontend/alert_box.html" %} diff --git a/docs/CONTACT_LIFECYCLE.md b/docs/CONTACT_LIFECYCLE.md new file mode 100644 index 0000000..4c7d9de --- /dev/null +++ b/docs/CONTACT_LIFECYCLE.md @@ -0,0 +1,36 @@ +# Onboarding og offboarding på kontaktkortet + +Kontaktkortets oversigt viser separate forløb. En dato kan gemmes uden automatik. +Automatik bruger kalenderdage og kl. 08 i Europe/Copenhagen, kontrolleres ved +API-start og hvert femte minut og indhenter forfaldne forløb efter nedetid. +Sagens deadline sættes til kl. 17 på datoen, gemt som UTC efter sagsmodulets konvention. + +Åbne hovedsager matches på kunde, direkte kontaktrelation og eksplicit type/tag. +En kandidat giver en undersag; flere kræver valg på kontaktkortet. En oprettet +sags reference bevares ved datoændring. Afsluttede sager kræver manuel behandling. +Annullering stopper automatikken og sletter ingen sager. + +## Installation + +Anvend kun `migrations/1076_contact_lifecycle.sql` i det ønskede miljø og genstart +API'et. Migrationen tilføjer en tabel og indeks uden at ændre eksisterende data. +Uden migration svarer de nye endpoints med 503; eksisterende kontakt-API bevares. +Ingen forløb eller sager oprettes af migrationen. + +API: GET/POST `/api/v1/contacts/{id}/lifecycle`, GET `/preview`, +PATCH `/{event_id}` og POST `/{event_id}/cancel` under samme base. +Læsning kræver cases.view/tickets.view; redigering cases.edit/tickets.edit. +Automatik kræver desuden sagsoprettelse og skabeloner templates.run. +Automatikkens seneste godkender skal fortsat være aktiv og have rettighederne. + +## Kontrol og drift + +Kør `pytest tests/test_contact_lifecycle_api.py tests/test_contact_lifecycle_worker.py tests/test_contacts_router_simple.py` +med projektets venv og `node tests/js/contact-lifecycle.test.cjs`. +Kontrollér i browseren: gem dato uden automatik, planlæg automatisk forløb, +vælg mellem flere hovedsager, åbn oprettet sag, ændr dato og annuller. +Kortet viser fejl og behov for manuel behandling. Tekniske fejl logges af +jobbet `check_contact_lifecycles`; forløb i fejl genforsøges ved næste kørsel. + +Ved tilbagerulning deaktiveres jobbet/kodeændringen; behold tabellen for at +bevare historik og referencer. Sammenlægning af kontakter flytter forløbene med. diff --git a/main.py b/main.py index b9dbbef..2a1876c 100644 --- a/main.py +++ b/main.py @@ -203,6 +203,19 @@ async def lifespan(app: FastAPI): ) logger.info("✅ Reminder job scheduled (every 5 minutes)") + # Contact lifecycle automation is independent of reminder notification settings. + from datetime import datetime, timezone + from app.contacts.backend.lifecycle_service import check_contact_lifecycles + backup_scheduler.scheduler.add_job( + func=check_contact_lifecycles, + trigger=IntervalTrigger(minutes=5), + id='check_contact_lifecycles', + name='Contact Onboarding / Offboarding', + next_run_time=datetime.now(timezone.utc), + max_instances=1, + replace_existing=True, + ) + # Register subscription invoice processing job from app.jobs.process_subscriptions import process_subscriptions diff --git a/migrations/1076_contact_lifecycle.sql b/migrations/1076_contact_lifecycle.sql new file mode 100644 index 0000000..066c3bb --- /dev/null +++ b/migrations/1076_contact_lifecycle.sql @@ -0,0 +1,23 @@ +-- Additive contact onboarding/offboarding planning. Existing contacts and cases are unchanged. +CREATE TABLE IF NOT EXISTS contact_lifecycle_events ( + id BIGSERIAL PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts(id), + customer_id INTEGER NOT NULL REFERENCES customers(id), + event_type TEXT NOT NULL CHECK (event_type IN ('onboarding', 'offboarding')), + event_date DATE NOT NULL, + lead_days INTEGER NOT NULL DEFAULT 14 CHECK (lead_days BETWEEN 0 AND 3650), + auto_create BOOLEAN NOT NULL DEFAULT FALSE, + template_id INTEGER REFERENCES task_templates(id), + responsible_user_id INTEGER REFERENCES users(user_id), + parent_case_id INTEGER REFERENCES sag_sager(id), + case_id INTEGER UNIQUE REFERENCES sag_sager(id), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN + ('draft','scheduled','needs_parent','created','manual_review','cancelled','error')), + last_error TEXT, + created_by INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS contact_lifecycle_contact_idx ON contact_lifecycle_events(contact_id); +CREATE INDEX IF NOT EXISTS contact_lifecycle_due_idx ON contact_lifecycle_events(event_date) + WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error'); diff --git a/static/js/contact-lifecycle.js b/static/js/contact-lifecycle.js new file mode 100644 index 0000000..c22d6e0 --- /dev/null +++ b/static/js/contact-lifecycle.js @@ -0,0 +1,157 @@ +(function (root) { + 'use strict'; + const labels = {onboarding: 'Onboarding', offboarding: 'Offboarding'}; + const statuses = {draft: 'Dato gemt · automatik slået fra', scheduled: 'Planlagt', needs_parent: 'Vælg hovedsag', created: 'Sag oprettet', manual_review: 'Kræver manuel behandling', cancelled: 'Annulleret', error: 'Oprettelse fejlede'}; + const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const date = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium'}).format(new Date(value.length === 10 ? value + 'T12:00:00Z' : value)) : 'Ikke planlagt'; + const schedule = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium', timeStyle:'short'}).format(new Date(value)) : ''; + function templateOptions(templates, customerId, type) { + return templates.filter(item => (['global','internal'].includes(item.template_type) || !item.customer_id || Number(item.customer_id) === Number(customerId)) && (!item.category || item.category === type)); + } + function editPayload(payload, created) { + if (!created) return payload; + return {event_date:payload.event_date,lead_days:payload.lead_days,auto_create:payload.auto_create}; + } + root.ContactLifecycle = {escape, templateOptions, editPayload}; + if (typeof document === 'undefined') return; + document.addEventListener('DOMContentLoaded', () => { + const cards = document.getElementById('contactLifecycleCards'); + if (!cards) return; + const contactId = Number(location.pathname.split('/').filter(Boolean).pop()); + const base = `/api/v1/contacts/${contactId}/lifecycle`; + let data = {items:[],companies:[],users:[],templates:[]}; + let current = null; + let previewVersion = 0; + const feedback = document.getElementById('contactLifecycleFeedback'); + async function request(url, method = 'GET', body) { + const headers = {'Content-Type':'application/json'}; + const token = localStorage.getItem('access_token'); + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(url, {method, headers, credentials:'same-origin', ...(body === undefined ? {} : {body:JSON.stringify(body)})}); + const result = await response.json(); + if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kontrollér oplysningerne, og prøv igen.'); + return result; + } + function notify(message, danger = false) { + feedback.className = `alert ${danger ? 'alert-danger' : 'alert-success'} py-2`; + feedback.textContent = message; + } + async function load() { + try { + data = await request(base); + cards.innerHTML = Object.entries(labels).map(([type,label]) => { + const items = data.items.filter(item => item.event_type === type); + const live = items.filter(item => item.status !== 'cancelled'); + return `
${label}
${live.length ? live.map(item => `
${escape(date(item.event_date))}
${escape((data.companies.find(company => Number(company.id) === Number(item.customer_id)) || {}).name || '')}
${escape(statuses[item.status] || item.status)}
${item.auto_create && item.scheduled_at && !item.case_id ? `
Oprettelse: ${escape(schedule(item.scheduled_at))}
` : ''}${item.last_error ? `
${escape(item.last_error)}
` : ''}
${item.case_id ? `Åbn sag #${Number(item.case_id)}` : ''}
`).join('') : '
Ingen dato planlagt
'}${items.some(item => item.status === 'cancelled') ? `
${items.filter(item => item.status === 'cancelled').length} annulleret · eksisterende sager er bevaret
` : ''}
`; + }).join(''); + if (data.can_edit === false) cards.querySelectorAll('[data-lifecycle-new],[data-lifecycle-edit],[data-lifecycle-cancel]').forEach(button => button.remove()); + } catch (error) { + cards.innerHTML = '
'; + notify(`Forløbene kunne ikke hentes: ${error.message}`, true); + } + } + cards.addEventListener('click', async event => { + const button = event.target.closest('button'); + if (!button) return; + if (button.hasAttribute('data-lifecycle-reload')) return load(); + if (button.dataset.lifecycleNew) return open(button.dataset.lifecycleNew); + if (button.dataset.lifecycleEdit) { + const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleEdit)); + if (item) open(item.event_type, item); + } + if (button.dataset.lifecycleCancel) { + const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleCancel)); + if (!item || !confirm(`Annuller ${labels[item.event_type].toLowerCase()} den ${date(item.event_date)}? Eksisterende sager bevares.`)) return; + button.disabled = true; + try { await request(`${base}/${item.id}/cancel`, 'POST'); await load(); notify('Forløbet er annulleret. Eksisterende sager er bevaret.'); } + catch (error) { notify(error.message, true); button.disabled = false; } + } + }); + function open(type, item = null) { + if (data.can_edit === false) return; + current = item; + previewVersion++; + document.getElementById('contactLifecycleModal')?.remove(); + const element = document.createElement('div'); + element.id = 'contactLifecycleModal'; + element.className = 'modal fade'; + element.tabIndex = -1; + element.setAttribute('aria-labelledby','contactLifecycleModalTitle'); + element.innerHTML = ``; + document.body.append(element); + const form = element.querySelector('form'); + const field = name => form.elements.namedItem(name); + field('event_date').value = item?.event_date || ''; + field('customer_id').value = item?.customer_id || (data.companies.length === 1 ? data.companies[0].id : ''); + field('lead_days').value = item?.lead_days ?? 14; + field('auto_create').checked = Boolean(item?.auto_create); + field('responsible_user_id').value = item?.responsible_user_id || ''; + if (item?.case_id) ['customer_id','template_id','parent_case_id','responsible_user_id'].forEach(name => { field(name).disabled = true; }); + let templateId = item?.template_id || ''; + function templates() { + const available = templateOptions(data.templates, field('customer_id').value, type); + field('template_id').innerHTML = '' + available.map(template => ``).join(''); + field('template_id').value = available.some(template => Number(template.id) === Number(templateId)) ? templateId : ''; + } + async function preview() { + const version = ++previewVersion; + const container = element.querySelector('#lifecyclePreview'); + element.querySelector('#lifecycleAutoFields').classList.toggle('d-none', !field('auto_create').checked); + const customer = field('customer_id').value; + const eventDate = field('event_date').value; + const days = Number(field('lead_days').value); + if (!customer || !eventDate || !Number.isInteger(days) || days < 0) { container.textContent = 'Vælg dato og kunde.'; return; } + if (current?.case_id) { + field('parent_case_id').innerHTML = ``; + element.querySelector('#lifecycleParentHelp').textContent = 'Sagens tilknytninger bevares.'; + container.textContent = `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.`; + return; + } + const selectedParent = field('parent_case_id').value || current?.parent_case_id; + container.textContent = 'Henter forslag…'; + try { + const query = new URLSearchParams({customer_id:customer,event_type:type,event_date:eventDate,lead_days:String(days)}); + const result = await request(`${base}/preview?${query}`); + if (version !== previewVersion || !element.isConnected) return; + const candidates = result.candidates || []; + field('parent_case_id').innerHTML = `` + candidates.map(candidate => ``).join(''); + field('parent_case_id').value = candidates.some(candidate => Number(candidate.id) === Number(selectedParent)) ? selectedParent : (result.parent_case_id || ''); + element.querySelector('#lifecycleParentHelp').textContent = candidates.length > 1 ? 'Flere relevante sager: vælg hovedsag. Uden valg afventer automatikken.' : candidates.length === 1 ? 'Der oprettes en undersag til denne åbne sag.' : 'Ingen relevant åben sag. Der oprettes en selvstændig sag.'; + container.textContent = current?.case_id ? `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.` : field('auto_create').checked ? `Sagen oprettes ${schedule(result.scheduled_at)} med deadline ${date(eventDate)}.${candidates.length > 1 && !field('parent_case_id').value ? ' Afventer valg af hovedsag.' : ''}` : `Datoen ${date(eventDate)} gemmes. Ingen automatisk sagsoprettelse.`; + } catch (error) { if (version === previewVersion) container.textContent = `Forslag kunne ikke hentes: ${error.message}`; } + } + templates(); + form.addEventListener('change', event => { + if (event.target.name === 'customer_id') templates(); + if (event.target.name === 'template_id') templateId = event.target.value; + if (!['template_id','responsible_user_id'].includes(event.target.name)) preview(); + }); + form.addEventListener('click', event => { + const button = event.target.closest('[data-lead]'); + if (button) { field('lead_days').value = button.dataset.lead; preview(); } + }); + form.addEventListener('submit', async event => { + event.preventDefault(); + if (!form.reportValidity()) return; + const button = element.querySelector('#lifecycleSave'); + const errorBox = element.querySelector('#lifecycleError'); + button.disabled = true; + errorBox.classList.add('d-none'); + const payload = {customer_id:Number(field('customer_id').value),event_date:field('event_date').value,lead_days:Number(field('lead_days').value),auto_create:field('auto_create').checked,template_id:Number(field('template_id').value)||null,responsible_user_id:Number(field('responsible_user_id').value)||null,parent_case_id:Number(field('parent_case_id').value)||null}; + if (!current) payload.event_type = type; + try { + const saved = await request(current ? `${base}/${current.id}` : base, current ? 'PATCH' : 'POST', editPayload(payload, Boolean(current?.case_id))); + modal.hide(); + await load(); + notify(saved.status === 'needs_parent' ? 'Forløbet er gemt. Vælg en hovedsag for at aktivere sagsoprettelsen.' : 'Forløbet er gemt.'); + } catch (error) { errorBox.textContent = error.message; errorBox.classList.remove('d-none'); button.disabled = false; } + }); + const modal = new bootstrap.Modal(element); + element.addEventListener('shown.bs.modal', () => field('event_date').focus(), {once:true}); + element.addEventListener('hidden.bs.modal', () => { previewVersion++; modal.dispose(); element.remove(); }, {once:true}); + modal.show(); + preview(); + } + load(); + }); +})(typeof window === 'undefined' ? globalThis : window); diff --git a/tests/js/contact-lifecycle.test.cjs b/tests/js/contact-lifecycle.test.cjs new file mode 100644 index 0000000..57f1e87 --- /dev/null +++ b/tests/js/contact-lifecycle.test.cjs @@ -0,0 +1,18 @@ +const assert = require('node:assert/strict'); +global.window = {}; +require('../../static/js/contact-lifecycle.js'); +const {escape, templateOptions, editPayload} = window.ContactLifecycle; +assert.equal(escape(''), '<img src=x onerror="bad">'); +const templates = [ + {id:1,category:'onboarding',customer_id:null}, + {id:2,category:'onboarding',customer_id:12}, + {id:3,category:'offboarding',customer_id:12}, + {id:4,category:'onboarding',customer_id:13}, +]; +assert.deepEqual(templateOptions(templates, '12', 'onboarding').map(item => item.id), [1,2]); +assert.deepEqual(templateOptions(templates, '12', 'offboarding').map(item => item.id), [3]); +assert.deepEqual(templateOptions(templates, '', 'onboarding').map(item => item.id), [1]); +const update = {customer_id:12,event_date:'2026-11-20',lead_days:14,auto_create:true,parent_case_id:99,template_id:1,responsible_user_id:2}; +assert.deepEqual(editPayload(update,true), {event_date:'2026-11-20',lead_days:14,auto_create:true}); +assert.equal(editPayload(update,false), update); +console.log('Contact lifecycle escaping and customer/type template scoping passed'); diff --git a/tests/test_bottom_bar_filters.py b/tests/test_bottom_bar_filters.py index 956fec5..2c307b5 100644 --- a/tests/test_bottom_bar_filters.py +++ b/tests/test_bottom_bar_filters.py @@ -15,7 +15,8 @@ def test_mail_inbox_supports_unread_deep_link(): assert "get('filter') === 'unread'" in source assert "url += '&unread=true'" in source - assert "state.filter === 'unread' ? 500 : 150" in source + # The inbox now uses a single, count-aware page size for every filter. + assert "pageSize: 100" in source def test_procurement_page_applies_requested_state_filter(): diff --git a/tests/test_contact_lifecycle_api.py b/tests/test_contact_lifecycle_api.py new file mode 100644 index 0000000..d705b51 --- /dev/null +++ b/tests/test_contact_lifecycle_api.py @@ -0,0 +1,123 @@ +from contextlib import nullcontext +from datetime import date + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from app.contacts.backend import lifecycle as api +from app.contacts.backend import lifecycle_service as service + + +USER = {'id': 5, 'is_superadmin': True} + + +def event(**overrides): + return dict(id=9, contact_id=1, customer_id=2, event_type='onboarding', + event_date=date(2026, 11, 17), lead_days=14, auto_create=False, + template_id=None, responsible_user_id=None, parent_case_id=None, + case_id=None, status='draft', last_error=None, **overrides) + + +@pytest.fixture +def setup_api(monkeypatch): + monkeypatch.setattr(api, '_ready', lambda: None) + monkeypatch.setattr(api, '_contact', lambda _: None) + monkeypatch.setattr(api, '_membership', lambda *args: None) + monkeypatch.setattr(api, 'atomic_transaction', nullcontext) + monkeypatch.setattr(api, 'hidden_case_ids', lambda _: set()) + monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: []) + + +def test_default_automation_is_disabled_and_lead_is_fourteen(): + request = api.LifecycleCreate(customer_id=2, event_type='onboarding', event_date='2026-11-17') + assert request.auto_create is False + assert request.lead_days == 14 + with pytest.raises(ValidationError): + api.LifecycleCreate(customer_id=2, event_type='offboarding', event_date='2026-11-17', lead_days=-1) + + +def test_missing_migration_is_explicit_503(monkeypatch): + monkeypatch.setattr(api, 'execute_query_single', lambda *args: {'relation': None}) + with pytest.raises(HTTPException) as error: + api._ready() + assert error.value.status_code == 503 + + +def test_customer_must_belong_to_contact(monkeypatch): + monkeypatch.setattr(api, 'execute_query_single', lambda *args: None) + with pytest.raises(HTTPException) as error: + api._membership(1, 90) + assert error.value.status_code == 400 + + +def test_enabling_automation_requires_case_creation(setup_api): + row = event() + row['auto_create'] = True + with pytest.raises(HTTPException) as error: + api._validate(row, {'id': 5, 'permissions': ['cases.edit']}) + assert error.value.status_code == 403 + + +def test_template_requires_run_permission(setup_api): + row = event() + row['template_id'] = 4 + with pytest.raises(HTTPException) as error: + api._validate(row, {'id': 5, 'permissions': ['cases.edit']}) + assert error.value.status_code == 403 + + +def test_ambiguous_parent_saves_waiting_for_choice(setup_api, monkeypatch): + monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [{'id': 10}, {'id': 11}]) + writes = [] + def query(sql, params): + writes.append((sql, params)) + row = event() + row.update(auto_create=True, status='needs_parent') + return row + monkeypatch.setattr(api, 'execute_query_single', query) + result = api.create_lifecycle(1, api.LifecycleCreate( + customer_id=2, event_type='onboarding', event_date='2026-11-17', auto_create=True), USER) + assert result['status'] == 'needs_parent' + assert writes[0][1][-2] == 'needs_parent' + + +def test_preview_excludes_confidential_candidates(setup_api, monkeypatch): + monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [{'id': 10}, {'id': 11}]) + monkeypatch.setattr(api, 'hidden_case_ids', lambda _: {11}) + result = api.preview_lifecycle(1, 2, 'onboarding', date(2026, 11, 17), 14, USER) + assert result['candidates'] == [{'id': 10}] + assert result['parent_case_id'] == 10 + assert result['needs_parent'] is False + + +def test_date_change_updates_same_case_without_insert(setup_api, monkeypatch): + row = event() + row.update(case_id=45, status='created') + monkeypatch.setattr(api, '_find_event', lambda *args, **kwargs: row) + monkeypatch.setattr(api, 'require_case', lambda *args: None) + calls = [] + monkeypatch.setattr(service, 'update_created_case_date', lambda *args: + calls.append(args) or {'status': 'created', 'last_error': None}) + writes = [] + def query(sql, params): + writes.append(sql) + return dict(row, event_date=date(2026, 11, 20)) + monkeypatch.setattr(api, 'execute_query_single', query) + result = api.update_lifecycle(1, 9, api.LifecycleUpdate(event_date='2026-11-20'), USER) + assert result['case_id'] == 45 + assert calls[0][1] == date(2026, 11, 17) + assert all('INSERT' not in sql for sql in writes) + + +def test_cancel_preserves_case_and_disables_automation(setup_api, monkeypatch): + monkeypatch.setattr(api, '_find_event', lambda *args, **kwargs: event()) + writes = [] + def query(sql, params): + writes.append(sql) + return event() + monkeypatch.setattr(api, 'execute_query_single', query) + api.cancel_lifecycle(1, 9, USER) + assert 'auto_create=FALSE' in writes[0] + assert 'DELETE' not in writes[0] + assert 'case_id=' not in writes[0] diff --git a/tests/test_contact_lifecycle_worker.py b/tests/test_contact_lifecycle_worker.py new file mode 100644 index 0000000..b3d79ee --- /dev/null +++ b/tests/test_contact_lifecycle_worker.py @@ -0,0 +1,109 @@ +import asyncio +from contextlib import contextmanager +from datetime import date, datetime, timezone + +import pytest + +from app.contacts.backend import lifecycle_service as service + + +@pytest.mark.parametrize("event,lead,expected", [ + (date(2026, 3, 30), 1, "2026-03-29T08:00:00+02:00"), + (date(2026, 10, 26), 1, "2026-10-25T08:00:00+01:00"), + (date(2026, 1, 3), 7, "2025-12-27T08:00:00+01:00"), +]) +def test_calendar_days_preserve_eight_am_across_dst(event, lead, expected): + assert service.scheduled_at(event, lead).isoformat() == expected + + +def test_candidate_query_requires_person_customer_type_and_open_main_case(monkeypatch): + calls = [] + monkeypatch.setattr(service, "closed_statuses", lambda: ["closed"]) + monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params)) or []) + assert service.find_parent_candidates(11, 22, "offboarding") == [] + sql, params = calls[0] + assert params == (22, ["closed"], 11, "offboarding", "offboarding", "offboarding") + assert "s.customer_id=%s" in sql and "k.contact_id=%s" in sql + assert "LOWER(s.type)=%s" in sql and "LOWER(TRIM(t.tag_navn))=%s" in sql + assert "LIKE" not in sql and "'undersag'" in sql and "<> ALL" in sql + + +@contextmanager +def transaction(): + yield + + +def test_due_job_rechecks_locked_row_and_catches_up_once(monkeypatch): + event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14) + created = [] + + def single(sql, params=None): + return {"ready": True} if "to_regclass" in sql else dict(event) + + async def create(row): + created.append(row["id"]) + event["case_id"] = 70 + return True + + monkeypatch.setattr(service, "execute_query_single", single) + monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}]) + monkeypatch.setattr(service, "atomic_transaction", transaction) + monkeypatch.setattr(service, "_create_case", create) + now = datetime(2026, 11, 2, tzinfo=timezone.utc) + assert asyncio.run(service.process_due_lifecycles(now)) == 1 + assert asyncio.run(service.process_due_lifecycles(now)) == 0 + assert created == [9] + + +def test_locked_event_rescheduled_after_candidate_scan_is_not_created(monkeypatch): + event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2027, 1, 1), lead_days=0) + monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event) + monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}]) + monkeypatch.setattr(service, "atomic_transaction", transaction) + assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0 + + +def test_ambiguous_parent_blocks_before_any_case_is_created(monkeypatch): + calls = [] + monkeypatch.setattr(service, "_actor", lambda event: {"id": 1, "is_superadmin": True}) + monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 1}) + monkeypatch.setattr(service, "find_parent_candidates", lambda *args: [{"id": 10}, {"id": 20}]) + monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params))) + event = dict(id=9, contact_id=11, customer_id=22, event_type="onboarding", parent_case_id=None) + assert asyncio.run(service._create_case(event)) is False + assert len(calls) == 1 and "status='needs_parent'" in calls[0][0] + + +def test_date_change_of_closed_case_requires_manual_review(monkeypatch): + from app.core import auth_service, case_visibility + monkeypatch.setattr(auth_service.AuthService, "get_user_permissions", lambda uid: ["cases.edit"]) + monkeypatch.setattr(case_visibility, "require_case", lambda *args: None) + monkeypatch.setattr(service, "closed_statuses", lambda: ["lukket"]) + monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 70, "status": "Lukket", "deleted_at": None}) + writes = [] + monkeypatch.setattr(service, "execute_query", lambda *args: writes.append(args)) + result = service.update_created_case_date(dict(case_id=70, event_date=date(2026, 12, 1), event_type="offboarding"), date(2026, 11, 1), 1) + assert result["status"] == "manual_review" and not writes + + +def test_template_failure_escapes_case_transaction_for_rollback(monkeypatch): + from fastapi import HTTPException + event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14) + rolled_back = [] + @contextmanager + def rollback_transaction(): + try: + yield + except Exception: + rolled_back.append(True) + raise + async def fail(event): + raise HTTPException(409, "Skabelon utilgængelig") + writes = [] + monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event) + monkeypatch.setattr(service, "execute_query", lambda sql, params: [{"id": 9}] if sql.lstrip().startswith("SELECT") else writes.append((sql, params))) + monkeypatch.setattr(service, "atomic_transaction", rollback_transaction) + monkeypatch.setattr(service, "_create_case", fail) + assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0 + assert rolled_back == [True] + assert writes[0][1] == ("Skabelon utilgængelig", 9) diff --git a/tests/test_invoice_error_finder.py b/tests/test_invoice_error_finder.py index 2cf0636..411c892 100644 --- a/tests/test_invoice_error_finder.py +++ b/tests/test_invoice_error_finder.py @@ -271,6 +271,7 @@ def test_detection_service_analyze_sweeps_historical_months(monkeypatch): "_analyze_single_month", fake_analyze_single_month, ) + monkeypatch.setattr(DetectionService, "_ignore_existing_issues", lambda self: None) service = DetectionService() counts = service.analyze() @@ -385,7 +386,7 @@ def test_get_issue_invoice_history_returns_reference_window(monkeypatch): return None def fake_execute_query(query, params=None): - if "FROM month_window mw" in query: + if "SELECT generate_series(" in query: return [ { "month_start": date(2026, 6, 1), @@ -418,7 +419,7 @@ def test_get_issue_invoice_history_returns_reference_window(monkeypatch): "is_reference_month": False, }, ] - if "WITH ranked_invoices AS" in query: + if "FROM invoice_error_finder_economic_invoices inv" in query: return [ { "month_start": date(2026, 6, 1), @@ -527,7 +528,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch) return None def fake_execute_query(query, params=None): - if "FROM month_window mw" in query: + if "SELECT generate_series(" in query: return [ { "month_start": date(2026, 7, 1), @@ -540,7 +541,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch) "is_reference_month": True, }, ] - if "WITH ranked_invoices AS" in query: + if "FROM invoice_error_finder_economic_invoices inv" in query: return [ { "month_start": date(2026, 7, 1), @@ -557,7 +558,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch) "line_number": 1, "product_number": "INET-1000", "product_name": None, - "description": "Fiberforbindelse", + "description": "1/1 Gbit Internet", "quantity": 1, "unit_price": 722.0, "line_net_amount": 722.0, diff --git a/tests/test_module_deactivation.py b/tests/test_module_deactivation.py index d83a0e3..a4b1154 100644 --- a/tests/test_module_deactivation.py +++ b/tests/test_module_deactivation.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -7,6 +8,8 @@ from app.core.database import execute_query def test_module_deactivation(): """Ensure module deactivation preserves data integrity.""" + if os.getenv("BMC_HUB_INTEGRATION_DB") != "1": + pytest.skip("requires an explicitly configured, isolated integration database") # Simulate module deactivation execute_query("UPDATE modules SET enabled = false WHERE name = 'sag';") @@ -26,4 +29,4 @@ def test_module_deactivation(): assert soft_deleted_cases is not None, "Soft-deleted cases should be preserved." assert soft_deleted_relations is not None, "Soft-deleted relations should be preserved." - assert soft_deleted_tags is not None, "Soft-deleted tags should be preserved." \ No newline at end of file + assert soft_deleted_tags is not None, "Soft-deleted tags should be preserved."