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 @@ +
Planlæg start og fratrædelse, og få oprettet en sag i god tid.
+ +