214 lines
11 KiB
Python
214 lines
11 KiB
Python
"""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)
|