"""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)