"""Atomic, opt-in V5 command batches. Existing case APIs are unchanged.""" import hashlib import html import json import logging from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.encoders import jsonable_encoder from psycopg2.extras import RealDictCursor from app.core.auth_dependencies import get_current_user, require_any_permission from app.core.database import get_db_connection, release_db_connection logger=logging.getLogger(__name__) router = APIRouter() SUPPORTED = {'time','assign','estimate','subcase','self','todo','waiting','solution','deadline','defer','relation','priority','group','note','mail','file','help','timer'} def fail(message): raise HTTPException(400, detail=message) def text(command, key, required=True): value = str(command.get(key) or '').strip() if required and not value: fail(f'{key} skal udfyldes') if len(value) > 10000: fail('Teksten er for lang') return value def resolve_user(cursor, token): token = str(token or '').strip().casefold().lstrip('@') cursor.execute('SELECT user_id, username, full_name FROM users WHERE is_active=true') matches = [] for row in cursor.fetchall(): name = str(row.get('full_name') or '').strip() initials = ''.join(p[0] for p in name.split()).casefold() if token in {str(row['user_id']),str(row.get('username') or '').casefold(),name.casefold(),initials}: matches.append(row) if len(matches) != 1: fail('Medarbejderen findes ikke eller initialerne er tvetydige') return matches[0] def normalize(cursor, case, payload, actor): from .router import _normalize_optional_timestamp, _validate_deferred_deadline commands = payload.get('commands') if not isinstance(commands, list) or len(commands) > 50: fail('Vælg højst 50 kommandoer') if len(str(payload.get('comment') or '')) > 50000: fail('Kommentaren er for lang') resolved, children, dates = [], set(), {'main':dict(case)} for index, raw in enumerate(commands): try: if not isinstance(raw,dict): fail('Ugyldig kommando') c = {key:value for key,value in raw.items() if key in {'type','scope','line','title','description','text','reason','assignee','minutes','category','settlement_choice','date','target','priority','group','case_id','caseId','target_id','action'}}; kind = c.get('type'); scope = c.get('scope','main') if kind in {'mail','note','solution'}: c['description']=c.get('description') or c.get('text') if kind=='waiting': c['description']=c.get('description') or c.get('reason') if kind=='relation': c['case_id']=c.get('case_id') or c.get('caseId') if kind not in SUPPORTED: fail('Ukendt kommando') if kind != 'subcase' and scope != 'main' and (not isinstance(scope,int) or isinstance(scope,bool) or scope not in children): fail('// kræver en tidligere undersag') if kind == 'subcase': perms = set(actor.get('permissions') or []) if not actor.get('is_superadmin') and not perms.intersection({'cases.create','tickets.create'}): raise HTTPException(403, detail='Du må ikke oprette undersager') if scope != 'main' and scope != len(children): fail('Ugyldig undersagsrækkefølge') c['scope']='main' title=text(c,'title') if len(title)>255: fail('Undersagens titel må højst være 255 tegn') child_index=len(children); children.add(child_index); c['child_index']=child_index dates[child_index]={'deadline':None,'deferred_until':None,'assigned_group_id':dates['main'].get('assigned_group_id')} if kind in {'assign','self'}: if kind == 'assign': text(c,'reason') else: c['reason']=str(c.get('reason') or 'Tildelt til mig via kommando') user=resolve_user(cursor,actor['id'] if kind=='self' else text(c,'assignee')) c['user_id']=user['user_id']; c['user_name']=user.get('full_name') or user['username'] cursor.execute('SELECT group_id FROM user_groups WHERE user_id=%s ORDER BY (group_id=%s) DESC, group_id', (c['user_id'],dates[scope].get('assigned_group_id'))) group=cursor.fetchone() if not group: fail('Medarbejderen mangler en gruppe') c['group_id']=group['group_id']; dates[scope]['assigned_group_id']=c['group_id'] if kind == 'group': token=text(c,'group'); text(c,'reason') cursor.execute('SELECT id,name FROM groups WHERE LOWER(name)=LOWER(%s)',(token,)); groups=cursor.fetchall() if len(groups)!=1: fail('Gruppen findes ikke eller er tvetydig') c['group_id']=groups[0]['id']; c['group_name']=groups[0]['name']; dates[scope]['assigned_group_id']=c['group_id'] if kind in {'time','estimate'}: minutes=c.get('minutes') if isinstance(minutes,bool) or not isinstance(minutes,int) or not (0 if kind=='estimate' else 1)<=minutes<=525600: fail('Ugyldigt minuttal') if kind=='time': text(c,'description'); text(c,'category',required=False); choice=str(c.get('settlement_choice') or 'draft') from app.timetracking.backend.router import _case_billing_choice c['billing']=_case_billing_choice(case['id'],choice) c['settlement_choice']=choice if kind=='todo': text(c,'title') if c.get('assignee'): target=resolve_user(cursor,c['assignee']); c['user_name']=target.get('full_name') or target['username']; c['assigned_to_user_id']=target['user_id'] if c.get('date'): try: date.fromisoformat(c['date']) except (ValueError,TypeError): fail('Ugyldig tododato') if kind in {'waiting','solution','note','mail'}: text(c,'description') if kind=='waiting': target=str(c.get('target') or 'kunde').lower() if target not in {'kunde','leverandør','intern','customer','supplier'}: fail('Ukendt afventer-type') c['target']=target if kind=='priority': value=str(c.get('priority') or '').lower() mapping={'høj':'high','normal':'normal','lav':'low','kritisk':'urgent','high':'high','low':'low','critical':'urgent','urgent':'urgent'} if value not in mapping: fail('Ukendt prioritet') c['priority']=mapping[value]; text(c,'reason') if kind in {'deadline','defer'}: value=_normalize_optional_timestamp(text(c,'date'),'deadline' if kind=='deadline' else 'deferred_until') dates[scope]['deadline' if kind=='deadline' else 'deferred_until']=value if kind=='relation': target=c.get('case_id') or c.get('target_id') try: target=int(str(target).lstrip('#')) except (ValueError,TypeError): fail('Ugyldigt sagsnummer') if scope=='main' and target==case['id']: fail('Sagen kan ikke relateres til sig selv') cursor.execute('SELECT id,titel,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(target,)); relation=cursor.fetchone() if not relation: fail('Den relaterede sag findes ikke') c['case_id']=target; c['case_title']=relation['titel']; c['customer_id']=relation['customer_id'] if kind=='timer': if c.get('action') not in {'start','pause'}: fail('Timer kræver start eller pause') if c['action']=='start': text(c,'description') resolved.append(c) except HTTPException as error: line = raw.get('line', index + 1) if isinstance(raw, dict) else index + 1 if not isinstance(line, int) or isinstance(line, bool) or line < 1: line = index + 1 raise HTTPException(error.status_code, detail=f"Linje {line}: {error.detail}") from error for values in dates.values(): _validate_deferred_deadline(values.get('deferred_until'),values.get('deadline')) return resolved def comment(cursor,case_id,author,content,internal=True,system=False): cursor.execute('INSERT INTO sag_kommentarer (sag_id,forfatter,indhold,er_intern,er_system_besked) VALUES (%s,%s,%s,%s,%s) RETURNING id',(case_id,author,html.escape(content),internal,system)) return cursor.fetchone()['id'] def apply(cursor,case,commands,payload,actor): result=[]; children={}; author=actor.get('full_name') or actor.get('username') or 'Bruger' if str(payload.get('comment') or '').strip(): result.append({'type':'comment','case_id':case['id'],'id':comment(cursor,case['id'],author,str(payload['comment']),payload.get('internal',True) is not False)}) for c in commands: kind=c['type']; cid=case['id'] if kind=='subcase' or c.get('scope','main')=='main' else children[c['scope']]; item={'type':kind,'case_id':cid} if kind in {'file','help'}: item['client_action']=c; result.append(item); continue if kind=='subcase': cursor.execute('SELECT assigned_group_id FROM sag_sager WHERE id=%s',(case['id'],)); parent_group=cursor.fetchone()['assigned_group_id'] cursor.execute("INSERT INTO sag_sager (titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,assigned_group_id,created_by_user_id) VALUES (%s,'','ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id",(c['title'],case.get('customer_id'),actor['id'],parent_group,actor['id'])) child=cursor.fetchone()['id']; children[c['child_index']]=child; item['id']=child cursor.execute('INSERT INTO sag_relationer (kilde_sag_id,målsag_id,relationstype) VALUES (%s,%s,%s)',(case['id'],child,'undersag')) cursor.execute('INSERT INTO sag_kontakter (sag_id,contact_id,role,is_primary) SELECT %s,contact_id,role,is_primary FROM sag_kontakter WHERE sag_id=%s AND deleted_at IS NULL',(child,case['id'])) elif kind in {'assign','self','group'}: gid=c['group_id']; uid=c.get('user_id') if kind!='group' else None cursor.execute('DELETE FROM sag_case_assignees a WHERE sag_id=%s AND NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id=a.user_id AND ug.group_id=%s)',(cid,gid)) if uid: cursor.execute('INSERT INTO sag_case_assignees (sag_id,user_id,assigned_by_user_id) VALUES (%s,%s,%s) ON CONFLICT (sag_id,user_id) DO NOTHING',(cid,uid,actor['id'])) cursor.execute('UPDATE sag_sager SET assigned_group_id=%s,ansvarlig_bruger_id=%s WHERE id=%s',(gid,uid,cid)) comment(cursor,cid,author,f"Tildeling: {c.get('user_name') or c.get('group')}\nBegrundelse: {c['reason']}") elif kind=='mail': cursor.execute('INSERT INTO sag_command_mail_drafts (sag_id,actor_id,subject,body) VALUES (%s,%s,%s,%s) RETURNING id',(cid,actor['id'],case['titel'],c['description'])); item['id']=cursor.fetchone()['id'] elif kind=='timer': cursor.execute('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE',(actor['id'],)) if c['action']=='pause': cursor.execute('UPDATE tmodule_times SET aktiv_timer=false,paused_at=CURRENT_TIMESTAMP WHERE medarbejder_id=%s AND sag_id=%s AND aktiv_timer=true AND slut_tid IS NULL RETURNING id',(actor['id'],cid)) paused=cursor.fetchone() if not paused: fail('Ingen aktiv timer på denne sag') item['id']=paused['id'] else: cursor.execute('SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND aktiv_timer=true AND slut_tid IS NULL',(actor['id'],)) if cursor.fetchone(): raise HTTPException(409,detail='Du har allerede en aktiv timer. Pause den først.') cursor.execute("INSERT INTO tmodule_times (sag_id,customer_id,description,original_hours,worked_date,user_name,status,billable,billing_method,start_tid,entry_type,kilde,entry_status,medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type) SELECT id,customer_id,%s,0.01,CURRENT_DATE,%s,'pending',(customer_id IS NOT NULL),'draft',CURRENT_TIMESTAMP,'manuel','sag_v5','kladde',%s,true,30,false,'support' FROM sag_sager WHERE id=%s RETURNING id",(c['description'],author,actor['id'],cid)); item['id']=cursor.fetchone()['id'] elif kind=='estimate': cursor.execute('UPDATE sag_sager SET estimated_minutes=%s WHERE id=%s',(c['minutes'],cid)) elif kind in {'deadline','defer','priority'}: column={'deadline':'deadline','defer':'deferred_until','priority':'priority'}[kind]; value=c.get('date') if kind!='priority' else c['priority'] cursor.execute(f'UPDATE sag_sager SET {column}=%s WHERE id=%s',(value,cid)) comment(cursor,cid,author,f"{kind}: {value}"+(f"\nBegrundelse: {c['reason']}" if kind=='priority' else ''),system=True) elif kind in {'todo','waiting'}: title=c['title'] if kind=='todo' else f"Afventer {c['target']}: {c['description']}" cursor.execute('INSERT INTO sag_todo_steps (sag_id,title,description,due_date,created_by_user_id,assigned_to_user_id) VALUES (%s,%s,%s,%s,%s,%s) RETURNING id',(cid,title,(f"Ansvarlig: {c['user_name']}" if c.get('user_name') else c.get('description')),c.get('date'),actor['id'],c.get('assigned_to_user_id'))); item['id']=cursor.fetchone()['id'] if kind=='waiting': cursor.execute("UPDATE sag_sager SET status='Afventer' WHERE id=%s",(cid,)); comment(cursor,cid,author,title,system=True) elif kind=='note': item['id']=comment(cursor,cid,author,c['description']) elif kind=='solution': cursor.execute('SELECT id,deleted_at FROM sag_solutions WHERE sag_id=%s FOR UPDATE',(cid,)); existing=cursor.fetchone() if existing: raise HTTPException(409,detail='Sagen har allerede en løsning. Ret den eksisterende løsning i løsningsmodulet.') cursor.execute("INSERT INTO sag_solutions (sag_id,title,description,solution_type,result,approval_status,visibility,is_final,created_by_user_id,updated_by_user_id) VALUES (%s,%s,%s,'Support','Løst','draft','internal',true,%s,%s) RETURNING *",(cid,'Løsning',c['description'],actor['id'],actor['id'])); solution=dict(cursor.fetchone()); item['id']=solution['id'] cursor.execute('INSERT INTO sag_solution_versions (solution_id,version_number,snapshot,changed_by_user_id,change_note) VALUES (%s,1,%s::jsonb,%s,%s)',(solution['id'],json.dumps(jsonable_encoder(solution)),actor['id'],'Løsning oprettet via kommando')) elif kind=='relation': cursor.execute("INSERT INTO sag_relationer (kilde_sag_id,målsag_id,relationstype) SELECT %s,%s,'Relateret til' WHERE NOT EXISTS (SELECT 1 FROM sag_relationer WHERE kilde_sag_id=%s AND målsag_id=%s AND relationstype='Relateret til' AND deleted_at IS NULL)",(cid,c['case_id'],cid,c['case_id'])) elif kind=='time': cursor.execute('SELECT customer_id FROM sag_sager WHERE id=%s',(cid,)); customer=cursor.fetchone()['customer_id']; minutes=c['minutes']; rounded=((minutes+29)//30)*30 cursor.execute("""INSERT INTO tmodule_times (sag_id,customer_id,description,original_hours,worked_date,user_name,status,billable,billing_method,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status,medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type,prepaid_card_id,fixed_price_agreement_id) VALUES (%s,%s,%s,%s,CURRENT_DATE,%s,'pending',%s,%s,%s,%s,'manuel','sag_v5',%s,%s,false,30,false,'other',%s,%s) RETURNING id""",(cid,customer,f"{c.get('category') or 'Arbejde'}: {c['description']}",minutes/60,author,customer is not None,c['billing']['method'],minutes,rounded,c['billing']['status'],actor['id'],c['billing']['card_id'],c['billing']['agreement_id'])) item['id']=cursor.fetchone()['id'] result.append(item) return result async def run(sag_id,payload,actor,execute=False): conn=get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,)); case=cursor.fetchone() if not case: raise HTTPException(404,detail='Sagen findes ikke') digest=hashlib.sha256(json.dumps({'comment':payload.get('comment'),'commands':payload.get('commands'),'internal':payload.get('internal',True)},sort_keys=True,ensure_ascii=False).encode()).hexdigest() rid=None if execute: try: rid=str(UUID(str(payload.get('request_id')))) except (ValueError,TypeError): fail('request_id skal være UUID') cursor.execute('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))',(rid,)) cursor.execute('SELECT * FROM sag_command_batches WHERE request_id=%s',(rid,)); previous=cursor.fetchone() if previous: if previous['sag_id']!=sag_id or previous['actor_id']!=actor['id'] or previous['payload_hash']!=digest: raise HTTPException(409,detail='Denne request_id er allerede brugt til andre handlinger') conn.rollback() return previous['result'] commands=normalize(cursor,case,payload,actor) if not execute: conn.rollback() return {'commands':commands,'errors':[]} result={'request_id':rid,'results':apply(cursor,case,commands,payload,actor),'errors':[]} cursor.execute('INSERT INTO sag_command_batches (request_id,sag_id,actor_id,payload_hash,result) VALUES (%s,%s,%s,%s,%s::jsonb)',(rid,sag_id,actor['id'],digest,json.dumps(jsonable_encoder(result)))) conn.commit(); return result except HTTPException: conn.rollback(); raise except Exception: conn.rollback() logger.exception('Case command batch failed for case %s',sag_id) raise HTTPException(500,detail='Handlingerne blev ikke gemt. Prøv igen; ingen delvise ændringer er gemt.') finally: conn.rollback() release_db_connection(conn) @router.post('/sag/{sag_id}/command-preview', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))]) async def preview_commands(sag_id:int,payload:dict,current_user:dict=Depends(get_current_user)): return await run(sag_id,payload,current_user) @router.post('/sag/{sag_id}/command-execute', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))]) async def execute_commands(sag_id:int,payload:dict,current_user:dict=Depends(get_current_user)): return await run(sag_id,payload,current_user,True) @router.get('/sag/{sag_id}/command-mail-drafts', dependencies=[Depends(require_any_permission('cases.view','tickets.view'))]) async def command_mail_drafts(sag_id:int,current_user:dict=Depends(get_current_user)): conn=get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute('SELECT * FROM sag_command_mail_drafts WHERE sag_id=%s AND actor_id=%s ORDER BY id DESC',(sag_id,current_user['id'])) return list(cursor.fetchall()) finally: conn.rollback() release_db_connection(conn) @router.get('/sag/{sag_id}/command-options', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))]) async def command_options(sag_id:int,current_user:dict=Depends(get_current_user)): conn=get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)) if not cursor.fetchone(): raise HTTPException(404,detail='Sagen findes ikke') cursor.execute('SELECT user_id,username,full_name FROM users WHERE is_active=true ORDER BY full_name NULLS LAST,username') users=list(cursor.fetchall()) cursor.execute('SELECT id,name FROM groups ORDER BY name') groups=list(cursor.fetchall()) cursor.execute('SELECT id,titel FROM sag_sager WHERE deleted_at IS NULL AND id<>%s ORDER BY id DESC LIMIT 100',(sag_id,)) return {'users':users,'groups':groups,'cases':list(cursor.fetchall())} finally: conn.rollback() release_db_connection(conn)