2026-01-29 23:07:33 +01:00
import logging
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
import os
import shutil
2026-03-20 18:43:45 +01:00
import json
2026-03-23 20:35:15 +01:00
import re
import hashlib
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from pathlib import Path
2026-01-29 23:07:33 +01:00
from datetime import datetime
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from typing import List , Optional
from uuid import uuid4
2026-02-14 02:26:29 +01:00
from fastapi import APIRouter , HTTPException , Query , UploadFile , File , Request
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from fastapi . responses import FileResponse
2026-02-15 11:12:58 +01:00
from pydantic import BaseModel , Field
2026-03-30 07:50:15 +02:00
from app . core . database import execute_query , execute_query_single , table_has_column
2026-02-20 07:10:06 +01:00
from app . models . schemas import TodoStep , TodoStepCreate , TodoStepUpdate , QuickCreateAnalysis
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from app . core . config import settings
from app . services . email_service import EmailService
2026-02-20 07:10:06 +01:00
from app . services . case_analysis_service import CaseAnalysisService
2026-03-25 13:46:03 +01:00
from app . services . ollama_service import ollama_service
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
try :
import extract_msg
except ImportError :
extract_msg = None
import email
from email . header import decode_header
2026-01-29 23:07:33 +01:00
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-02-14 02:26:29 +01:00
2026-03-04 07:40:18 +01:00
def _table_exists ( table_name : str ) - > bool :
row = execute_query_single ( " SELECT to_regclass( %s ) AS table_name " , ( f " public. { table_name } " , ) )
return bool ( row and row . get ( " table_name " ) )
2026-02-14 02:26:29 +01:00
def _get_user_id_from_request ( request : Request ) - > int :
user_id = getattr ( request . state , " user_id " , None )
if user_id is not None :
try :
return int ( user_id )
except ValueError :
raise HTTPException ( status_code = 400 , detail = " Invalid user_id format " )
user_id = request . query_params . get ( " user_id " )
if user_id :
try :
return int ( user_id )
except ValueError :
raise HTTPException ( status_code = 400 , detail = " Invalid user_id format " )
raise HTTPException ( status_code = 401 , detail = " User not authenticated - provide user_id query parameter " )
2026-02-17 08:29:05 +01:00
def _normalize_case_status ( status_value : Optional [ str ] ) - > str :
2026-03-20 18:43:45 +01:00
allowed_statuses = [ ]
seen = set ( )
def _add_status ( value : Optional [ str ] ) - > None :
candidate = str ( value or " " ) . strip ( )
if not candidate :
return
key = candidate . lower ( )
if key in seen :
return
seen . add ( key )
allowed_statuses . append ( candidate )
try :
setting_row = execute_query_single ( " SELECT value FROM settings WHERE key = %s " , ( " case_statuses " , ) )
if setting_row and setting_row . get ( " value " ) :
parsed = json . loads ( setting_row . get ( " value " ) or " [] " )
for item in parsed if isinstance ( parsed , list ) else [ ] :
if isinstance ( item , str ) :
value = item . strip ( )
elif isinstance ( item , dict ) :
value = str ( item . get ( " value " ) or " " ) . strip ( )
else :
value = " "
_add_status ( value )
except Exception :
pass
# Include historical/current DB statuses so legacy values remain valid
try :
rows = execute_query ( " SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status " , ( ) ) or [ ]
for row in rows :
_add_status ( row . get ( " status " ) )
except Exception :
pass
if not allowed_statuses :
allowed_statuses = [ " åben " , " under behandling " , " afventer " , " løst " , " lukket " ]
allowed_map = { s . lower ( ) : s for s in allowed_statuses }
2026-02-17 08:29:05 +01:00
if not status_value :
2026-03-20 18:43:45 +01:00
return allowed_map . get ( " åben " , allowed_statuses [ 0 ] )
2026-02-17 08:29:05 +01:00
normalized = str ( status_value ) . strip ( ) . lower ( )
2026-03-20 18:43:45 +01:00
if normalized in allowed_map :
return allowed_map [ normalized ]
# Backward compatibility for legacy mapping
if normalized == " afventer " and " åben " in allowed_map :
return allowed_map [ " åben " ]
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str ( status_value ) . strip ( )
if raw_value :
return raw_value
return allowed_map . get ( " åben " , allowed_statuses [ 0 ] )
2026-02-17 08:29:05 +01:00
def _normalize_optional_timestamp ( value : Optional [ str ] , field_name : str ) - > Optional [ str ] :
if value is None :
return None
if isinstance ( value , datetime ) :
return value . strftime ( " % Y- % m- %d % H: % M: % S " )
text = str ( value ) . strip ( )
if not text :
return None
try :
parsed = datetime . fromisoformat ( text . replace ( " Z " , " +00:00 " ) )
if parsed . tzinfo is not None :
parsed = parsed . replace ( tzinfo = None )
return parsed . strftime ( " % Y- % m- %d % H: % M: % S " )
except ValueError :
raise HTTPException ( status_code = 400 , detail = f " Invalid datetime format for { field_name } " )
def _coerce_optional_int ( value : Optional [ object ] , field_name : str ) - > Optional [ int ] :
if value is None or value == " " :
return None
try :
return int ( value )
except ( TypeError , ValueError ) :
raise HTTPException ( status_code = 400 , detail = f " Invalid { field_name } " )
def _validate_user_id ( user_id : Optional [ int ] , field_name : str = " ansvarlig_bruger_id " ) - > None :
if user_id is None :
return
exists = execute_query ( " SELECT 1 FROM users WHERE user_id = %s " , ( user_id , ) )
if not exists :
raise HTTPException ( status_code = 400 , detail = f " Invalid { field_name } " )
def _validate_group_id ( group_id : Optional [ int ] , field_name : str = " assigned_group_id " ) - > None :
if group_id is None :
return
exists = execute_query ( " SELECT 1 FROM groups WHERE id = %s " , ( group_id , ) )
if not exists :
raise HTTPException ( status_code = 400 , detail = f " Invalid { field_name } " )
2026-02-20 07:10:06 +01:00
# ============================================================================
# QUICKCREATE AI ANALYSIS
# ============================================================================
class QuickCreateRequest ( BaseModel ) :
text : str = Field ( . . . , min_length = 1 , max_length = 5000 )
user_id : int
2026-03-25 13:46:03 +01:00
class RewriteTextRequest ( BaseModel ) :
text : str = Field ( . . . , min_length = 1 , max_length = 10000 )
context : Optional [ str ] = Field ( default = " case " )
class RewriteTextResponse ( BaseModel ) :
rewritten_text : str
model : Optional [ str ] = None
endpoint : Optional [ str ] = None
context : Optional [ str ] = None
2026-03-05 08:41:59 +01:00
class SagSendEmailRequest ( BaseModel ) :
to : List [ str ]
subject : str = Field ( . . . , min_length = 1 , max_length = 998 )
body_text : str = Field ( . . . , min_length = 1 )
cc : List [ str ] = Field ( default_factory = list )
bcc : List [ str ] = Field ( default_factory = list )
body_html : Optional [ str ] = None
attachment_file_ids : List [ int ] = Field ( default_factory = list )
2026-03-18 13:49:33 +01:00
thread_email_id : Optional [ int ] = None
thread_key : Optional [ str ] = None
2026-03-05 08:41:59 +01:00
def _normalize_email_list ( values : List [ str ] , field_name : str ) - > List [ str ] :
cleaned : List [ str ] = [ ]
for value in values or [ ] :
candidate = str ( value or " " ) . strip ( )
if not candidate :
continue
if " @ " not in candidate or " . " not in candidate . split ( " @ " ) [ - 1 ] :
raise HTTPException ( status_code = 400 , detail = f " Invalid email in { field_name } : { candidate } " )
cleaned . append ( candidate )
return list ( dict . fromkeys ( cleaned ) )
2026-03-23 20:35:15 +01:00
def _normalize_message_id_token ( value : Optional [ str ] ) - > Optional [ str ] :
if not value :
return None
normalized = str ( value ) . strip ( ) . strip ( " <> " ) . lower ( )
normalized = " " . join ( normalized . split ( ) )
return normalized or None
def _derive_thread_key_for_outbound (
payload_thread_key : Optional [ str ] ,
in_reply_to_header : Optional [ str ] ,
references_header : Optional [ str ] ,
generated_message_id : Optional [ str ] ,
) - > Optional [ str ] :
provided = _normalize_message_id_token ( payload_thread_key )
if provided :
return provided
if references_header :
for token in re . split ( r " [ \ s,]+ " , references_header . strip ( ) ) :
normalized = _normalize_message_id_token ( token )
if normalized :
return normalized
reply_to = _normalize_message_id_token ( in_reply_to_header )
if reply_to :
return reply_to
return _normalize_message_id_token ( generated_message_id )
def _get_signature_template ( ) - > str :
default_template = (
" {full_name} \n "
" {title} \n "
" {company_name} \n "
" Telefon: {company_phone} \n "
" Email: {email} \n "
" Web: {company_website} \n "
" Adresse: {company_address} \n "
" BMCid: {bmc_id_tag} "
)
row = execute_query_single (
" SELECT value FROM settings WHERE key = %s " ,
( " email_default_signature_template " , ) ,
)
value = ( row or { } ) . get ( " value " ) if row else None
return ( value or default_template ) . strip ( )
def _build_case_bmc_id_tag ( sag_id : int , thread_key : Optional [ str ] = None ) - > str :
""" Build a case+thread marker used in signatures for helpdesk routing hints.
Example : s53t472193
- s53 = > case id 53
- t472193 = > deterministic per - thread suffix ( or 001 when unknown )
"""
if not thread_key :
return f " s { sag_id } t001 "
normalized = re . sub ( r " [^a-z0-9]+ " , " " , str ( thread_key ) . lower ( ) )
if not normalized :
return f " s { sag_id } t001 "
digest = hashlib . sha1 ( normalized . encode ( " utf-8 " ) ) . hexdigest ( )
thread_suffix = ( int ( digest [ : 8 ] , 16 ) % 900000 ) + 100000
return f " s { sag_id } t { thread_suffix } "
def _render_signature_for_case_email ( user_id : int , sag_id : int , thread_key : Optional [ str ] = None ) - > str :
user = execute_query_single (
" SELECT full_name, email FROM users WHERE user_id = %s " ,
( user_id , ) ,
) or { }
company_rows = execute_query (
" SELECT key, value FROM settings WHERE key IN ( ' company_name ' , ' company_phone ' , ' company_address ' , ' company_email ' , ' company_website ' ) " ,
( ) ,
) or [ ]
company = { row . get ( " key " ) : row . get ( " value " ) for row in company_rows }
context = {
" full_name " : ( user . get ( " full_name " ) or " BMC Hub " ) . strip ( ) ,
" title " : " IT-Supporter " ,
" email " : ( user . get ( " email " ) or company . get ( " company_email " ) or " " ) . strip ( ) ,
" company_name " : ( company . get ( " company_name " ) or " BMC Networks ApS " ) . strip ( ) ,
" company_phone " : ( company . get ( " company_phone " ) or " " ) . strip ( ) ,
" company_website " : ( company . get ( " company_website " ) or " https://bmcnetworks.dk " ) . strip ( ) ,
" company_address " : ( company . get ( " company_address " ) or " " ) . strip ( ) ,
" bmc_id_tag " : _build_case_bmc_id_tag ( sag_id , thread_key ) ,
}
template = _get_signature_template ( )
signature = template
for key , value in context . items ( ) :
signature = signature . replace ( f " {{ { key } }} " , value )
if " BMCid: " not in signature :
signature = f " { signature } \n BMCid: { context [ ' bmc_id_tag ' ] } "
return signature . strip ( )
def _append_signature_to_body ( body_text : str , signature : str ) - > str :
clean_body = ( body_text or " " ) . rstrip ( )
clean_signature = ( signature or " " ) . strip ( )
if not clean_signature :
return clean_body
return f " { clean_body } \n \n -- \n { clean_signature } " if clean_body else clean_signature
def _append_signature_to_html ( body_html : Optional [ str ] , signature : str ) - > Optional [ str ] :
clean_signature = ( signature or " " ) . strip ( )
if not clean_signature :
return body_html
# If original message is plain text, do not synthesize an HTML-only body.
# That can cause clients/providers to display only the signature.
if not body_html or not body_html . strip ( ) :
return None
signature_html = clean_signature . replace ( " & " , " & " ) . replace ( " < " , " < " ) . replace ( " > " , " > " ) . replace ( " \n " , " <br> " )
return f " { body_html } <br><br>--<br> { signature_html } "
2026-02-20 07:10:06 +01:00
@router.post ( " /sag/analyze-quick-create " , response_model = QuickCreateAnalysis )
async def analyze_quick_create ( request : QuickCreateRequest ) :
"""
Analyze case description text using AI for QuickCreate feature .
Returns structured suggestions for customer , technician , priority , tags , etc .
"""
try :
logger . info ( f " 🔍 QuickCreate analysis requested by user { request . user_id } , text length: { len ( request . text ) } " )
# Initialize service and analyze
service = CaseAnalysisService ( )
analysis = await service . analyze_case_text ( request . text , request . user_id )
logger . info ( f " ✅ QuickCreate analysis complete: confidence= { analysis . confidence } , priority= { analysis . suggested_priority } " )
return analysis
except Exception as e :
logger . error ( f " ❌ QuickCreate analysis failed: { str ( e ) } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = f " Analysis failed: { str ( e ) } " )
2026-03-25 13:46:03 +01:00
@router.post ( " /sag/rewrite-text " , response_model = RewriteTextResponse )
async def rewrite_sag_text ( request : RewriteTextRequest ) :
""" Rewrite case/email text using Ollama with configurable prompt. """
try :
result = await ollama_service . rewrite_text ( request . text , request . context or " case " )
if not result or result . get ( " error " ) :
detail = ( result or { } ) . get ( " error " ) or " Could not rewrite text "
raise HTTPException ( status_code = 502 , detail = detail )
return RewriteTextResponse (
rewritten_text = result . get ( " rewritten_text " , " " ) ,
model = result . get ( " model " ) ,
endpoint = result . get ( " endpoint " ) ,
context = result . get ( " context " ) ,
)
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Rewrite text failed: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to rewrite text " )
@router.post ( " /rewrite-text " , response_model = RewriteTextResponse )
async def rewrite_text_global ( request : RewriteTextRequest ) :
""" Global rewrite alias used by UI flows that should avoid feature-specific route conflicts. """
try :
result = await ollama_service . rewrite_text ( request . text , request . context or " case " )
if not result or result . get ( " error " ) :
detail = ( result or { } ) . get ( " error " ) or " Could not rewrite text "
raise HTTPException ( status_code = 502 , detail = detail )
return RewriteTextResponse (
rewritten_text = result . get ( " rewritten_text " , " " ) ,
model = result . get ( " model " ) ,
endpoint = result . get ( " endpoint " ) ,
context = result . get ( " context " ) ,
)
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Global rewrite text failed: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to rewrite text " )
2026-01-29 23:07:33 +01:00
# ============================================================================
2026-02-01 00:38:10 +01:00
# SAGER - CRUD Operations
2026-01-29 23:07:33 +01:00
# ============================================================================
2026-02-01 00:38:10 +01:00
@router.get ( " /sag " )
async def list_sager (
status : Optional [ str ] = Query ( None ) ,
tag : Optional [ str ] = Query ( None ) ,
customer_id : Optional [ int ] = Query ( None ) ,
ansvarlig_bruger_id : Optional [ int ] = Query ( None ) ,
2026-02-17 08:29:05 +01:00
assigned_group_id : Optional [ int ] = Query ( None ) ,
2026-02-09 15:30:07 +01:00
include_deferred : bool = Query ( False ) ,
2026-02-14 02:26:29 +01:00
q : Optional [ str ] = Query ( None ) ,
limit : Optional [ int ] = Query ( None , ge = 1 , le = 200 ) ,
offset : int = Query ( 0 , ge = 0 ) ,
2026-02-01 00:38:10 +01:00
) :
""" List all cases with optional filtering. """
2026-01-29 23:07:33 +01:00
try :
2026-02-17 08:29:05 +01:00
query = """
SELECT s . * ,
COALESCE ( u . full_name , u . username ) AS ansvarlig_navn ,
g . name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u . user_id = s . ansvarlig_bruger_id
LEFT JOIN groups g ON g . id = s . assigned_group_id
WHERE s . deleted_at IS NULL
"""
2026-02-01 00:38:10 +01:00
params = [ ]
2026-02-09 15:30:07 +01:00
if not include_deferred :
query + = " AND (deferred_until IS NULL OR deferred_until <= NOW()) "
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
if status :
2026-02-17 08:29:05 +01:00
query + = " AND s.status = %s "
2026-02-01 00:38:10 +01:00
params . append ( status )
if customer_id :
2026-02-17 08:29:05 +01:00
query + = " AND s.customer_id = %s "
2026-02-01 00:38:10 +01:00
params . append ( customer_id )
if ansvarlig_bruger_id :
2026-02-17 08:29:05 +01:00
query + = " AND s.ansvarlig_bruger_id = %s "
2026-02-01 00:38:10 +01:00
params . append ( ansvarlig_bruger_id )
2026-02-17 08:29:05 +01:00
if assigned_group_id :
query + = " AND s.assigned_group_id = %s "
params . append ( assigned_group_id )
2026-02-14 02:26:29 +01:00
if q :
2026-02-17 08:29:05 +01:00
query + = " AND (LOWER(s.titel) LIKE %s OR CAST(s.id AS TEXT) LIKE %s ) "
2026-02-14 02:26:29 +01:00
q_like = f " % { q . lower ( ) } % "
params . extend ( [ q_like , q_like ] )
2026-01-29 23:07:33 +01:00
2026-02-17 08:29:05 +01:00
query + = " ORDER BY s.created_at DESC "
2026-02-14 02:26:29 +01:00
if limit is not None :
query + = " LIMIT %s OFFSET %s "
params . extend ( [ limit , offset ] )
2026-01-31 23:16:24 +01:00
2026-02-01 00:38:10 +01:00
cases = execute_query ( query , tuple ( params ) )
2026-01-31 23:16:24 +01:00
2026-02-01 00:38:10 +01:00
# If tag filter, filter in Python after fetch
if tag :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
case_ids = [ case [ " id " ] for case in cases ]
2026-02-01 00:38:10 +01:00
if case_ids :
tag_query = " SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL "
tagged_cases = execute_query ( tag_query , ( tag , ) )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
tagged_ids = set ( t [ " sag_id " ] for t in tagged_cases )
cases = [ c for c in cases if c [ " id " ] in tagged_ids ]
2026-02-01 00:38:10 +01:00
return cases
2026-03-23 20:35:15 +01:00
except Exception :
logger . exception ( " ❌ Error listing cases " )
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 500 , detail = " Failed to list cases " )
2026-01-31 23:16:24 +01:00
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
@router.get ( " /sag/sale-items/all " )
async def list_all_sale_items (
type : Optional [ str ] = Query ( None ) ,
status : Optional [ str ] = Query ( None ) ,
q : Optional [ str ] = Query ( None ) ,
customer_id : Optional [ int ] = Query ( None ) ,
sag_id : Optional [ int ] = Query ( None ) ,
date_from : Optional [ str ] = Query ( None ) ,
date_to : Optional [ str ] = Query ( None ) ,
) :
""" List all sale items across cases (orders overview). """
try :
2026-03-04 07:40:18 +01:00
if not _table_exists ( " sag_salgsvarer " ) :
logger . warning ( " ⚠️ sag_salgsvarer table missing - returning empty sale items list " )
return [ ]
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
query = """
SELECT si . * , s . titel AS sag_titel , s . customer_id , c . name AS customer_name
FROM sag_salgsvarer si
JOIN sag_sager s ON s . id = si . sag_id
LEFT JOIN customers c ON c . id = s . customer_id
WHERE s . deleted_at IS NULL
"""
params = [ ]
if type :
query + = " AND LOWER(si.type) = %s "
params . append ( type . lower ( ) )
if status :
query + = " AND LOWER(si.status) = %s "
params . append ( status . lower ( ) )
if q :
query + = " AND (LOWER(si.description) LIKE %s OR LOWER(si.external_ref) LIKE %s OR LOWER(s.titel) LIKE %s OR LOWER(c.name) LIKE %s ) "
like = f " % { q . lower ( ) } % "
params . extend ( [ like , like , like , like ] )
if customer_id :
query + = " AND s.customer_id = %s "
params . append ( customer_id )
if sag_id :
query + = " AND s.id = %s "
params . append ( sag_id )
if date_from :
query + = " AND si.line_date >= %s "
params . append ( date_from )
if date_to :
query + = " AND si.line_date <= %s "
params . append ( date_to )
query + = " ORDER BY si.line_date DESC NULLS LAST, si.id DESC "
return execute_query ( query , tuple ( params ) ) or [ ]
except Exception as e :
logger . error ( " ❌ Error listing sale items: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list sale items " )
2026-02-01 00:38:10 +01:00
@router.post ( " /sag " )
async def create_sag ( data : dict ) :
""" Create a new case. """
try :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if not data . get ( " titel " ) :
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 400 , detail = " titel is required " )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if not data . get ( " customer_id " ) :
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 400 , detail = " customer_id is required " )
2026-02-17 08:29:05 +01:00
status = _normalize_case_status ( data . get ( " status " ) )
deadline = _normalize_optional_timestamp ( data . get ( " deadline " ) , " deadline " )
deferred_until = _normalize_optional_timestamp ( data . get ( " deferred_until " ) , " deferred_until " )
ansvarlig_bruger_id = _coerce_optional_int ( data . get ( " ansvarlig_bruger_id " ) , " ansvarlig_bruger_id " )
assigned_group_id = _coerce_optional_int ( data . get ( " assigned_group_id " ) , " assigned_group_id " )
_validate_user_id ( ansvarlig_bruger_id )
_validate_group_id ( assigned_group_id )
2026-02-03 15:37:16 +01:00
2026-02-01 00:38:10 +01:00
query = """
INSERT INTO sag_sager
2026-02-17 08:29:05 +01:00
( titel , beskrivelse , template_key , status , customer_id , ansvarlig_bruger_id , assigned_group_id , created_by_user_id , deadline , deferred_until , deferred_until_case_id , deferred_until_status )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
2026-02-01 00:38:10 +01:00
RETURNING *
"""
params = (
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
data . get ( " titel " ) ,
data . get ( " beskrivelse " , " " ) ,
2026-02-03 15:37:16 +01:00
data . get ( " template_key " ) or data . get ( " type " , " ticket " ) ,
status ,
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
data . get ( " customer_id " ) ,
2026-02-17 08:29:05 +01:00
ansvarlig_bruger_id ,
assigned_group_id ,
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
data . get ( " created_by_user_id " , 1 ) ,
2026-02-17 08:29:05 +01:00
deadline ,
deferred_until ,
2026-02-06 10:47:14 +01:00
data . get ( " deferred_until_case_id " ) ,
data . get ( " deferred_until_status " ) ,
2026-02-01 00:38:10 +01:00
)
result = execute_query ( query , params )
if result :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
logger . info ( " ✅ Case created: %s " , result [ 0 ] [ " id " ] )
2026-02-01 00:38:10 +01:00
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to create case " )
except Exception as e :
logger . error ( " ❌ Error creating case: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to create case " )
2026-01-31 23:16:24 +01:00
2026-03-25 13:46:03 +01:00
@router.get ( " /sag/ { sag_id:int} " )
2026-02-01 00:38:10 +01:00
async def get_sag ( sag_id : int ) :
""" Get a specific case. """
try :
2026-02-17 08:29:05 +01:00
query = """
SELECT s . * ,
COALESCE ( u . full_name , u . username ) AS ansvarlig_navn ,
g . name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u . user_id = s . ansvarlig_bruger_id
LEFT JOIN groups g ON g . id = s . assigned_group_id
WHERE s . id = % s AND s . deleted_at IS NULL
"""
2026-02-01 00:38:10 +01:00
result = execute_query ( query , ( sag_id , ) )
if not result :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
return result [ 0 ]
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error getting case: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to get case " )
2026-01-31 23:16:24 +01:00
2026-02-06 10:47:14 +01:00
@router.get ( " /sag/ {sag_id} /modules " )
async def get_case_module_prefs ( sag_id : int ) :
""" Get module visibility preferences for a case. """
try :
query = " SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s "
prefs = execute_query ( query , ( sag_id , ) )
return prefs or [ ]
except Exception as e :
logger . error ( " ❌ Error getting module prefs: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to get module prefs " )
@router.post ( " /sag/ {sag_id} /modules " )
async def set_case_module_pref ( sag_id : int , data : dict ) :
""" Set module visibility preference for a case. """
try :
module_key = data . get ( " module_key " )
is_enabled = data . get ( " is_enabled " )
if not module_key or is_enabled is None :
raise HTTPException ( status_code = 400 , detail = " module_key and is_enabled are required " )
query = """
INSERT INTO sag_module_prefs ( sag_id , module_key , is_enabled )
VALUES ( % s , % s , % s )
ON CONFLICT ( sag_id , module_key )
DO UPDATE SET is_enabled = EXCLUDED . is_enabled
RETURNING module_key , is_enabled
"""
result = execute_query ( query , ( sag_id , module_key , bool ( is_enabled ) ) )
return result [ 0 ] if result else { " module_key " : module_key , " is_enabled " : bool ( is_enabled ) }
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error setting module pref: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to set module pref " )
2026-02-14 02:26:29 +01:00
# =========================================================================
# SAG TODO STEPS
# =========================================================================
@router.get ( " /sag/ {sag_id} /todo-steps " , response_model = List [ TodoStep ] )
async def list_todo_steps ( sag_id : int ) :
try :
query = """
SELECT
t . * ,
COALESCE ( u_created . full_name , u_created . username ) AS created_by_name ,
COALESCE ( u_completed . full_name , u_completed . username ) AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created . user_id = t . created_by_user_id
LEFT JOIN users u_completed ON u_completed . user_id = t . completed_by_user_id
WHERE t . sag_id = % s AND t . deleted_at IS NULL
2026-03-20 00:24:58 +01:00
ORDER BY t . is_done ASC , t . is_next DESC , t . due_date NULLS LAST , t . created_at DESC
2026-02-14 02:26:29 +01:00
"""
return execute_query ( query , ( sag_id , ) ) or [ ]
except Exception as e :
logger . error ( " ❌ Error listing todo steps: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list todo steps " )
@router.post ( " /sag/ {sag_id} /todo-steps " , response_model = TodoStep )
async def create_todo_step ( sag_id : int , request : Request , data : TodoStepCreate ) :
try :
user_id = _get_user_id_from_request ( request )
if not data . title :
raise HTTPException ( status_code = 400 , detail = " title is required " )
insert_query = """
INSERT INTO sag_todo_steps
( sag_id , title , description , due_date , created_by_user_id )
VALUES ( % s , % s , % s , % s , % s )
RETURNING id
"""
result = execute_query ( insert_query , (
sag_id ,
data . title ,
data . description ,
data . due_date ,
user_id
) )
if not result :
raise HTTPException ( status_code = 500 , detail = " Failed to create todo step " )
step_id = result [ 0 ] [ " id " ]
return execute_query (
"""
SELECT
t . * ,
COALESCE ( u_created . full_name , u_created . username ) AS created_by_name ,
COALESCE ( u_completed . full_name , u_completed . username ) AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created . user_id = t . created_by_user_id
LEFT JOIN users u_completed ON u_completed . user_id = t . completed_by_user_id
WHERE t . id = % s
""" ,
( step_id , )
) [ 0 ]
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error creating todo step: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to create todo step " )
@router.patch ( " /sag/todo-steps/ {step_id} " , response_model = TodoStep )
async def update_todo_step ( step_id : int , request : Request , data : TodoStepUpdate ) :
try :
2026-03-20 00:24:58 +01:00
if data . is_done is None and data . is_next is None :
raise HTTPException ( status_code = 400 , detail = " Provide is_done or is_next " )
2026-02-14 02:26:29 +01:00
2026-03-20 00:24:58 +01:00
step_row = execute_query_single (
" SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL " ,
( step_id , )
)
if not step_row :
raise HTTPException ( status_code = 404 , detail = " Todo step not found " )
if data . is_done is not None :
user_id = _get_user_id_from_request ( request )
if data . is_done :
update_query = """
UPDATE sag_todo_steps
SET is_done = TRUE ,
is_next = FALSE ,
completed_by_user_id = % s ,
completed_at = CURRENT_TIMESTAMP
WHERE id = % s AND deleted_at IS NULL
RETURNING id
"""
execute_query ( update_query , ( user_id , step_id ) )
else :
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE ,
completed_by_user_id = NULL ,
completed_at = NULL
WHERE id = % s AND deleted_at IS NULL
RETURNING id
"""
execute_query ( update_query , ( step_id , ) )
if data . is_next is not None :
if step_row . get ( " is_done " ) and data . is_next :
raise HTTPException ( status_code = 400 , detail = " Completed todo cannot be marked as next " )
if data . is_next :
execute_query (
"""
UPDATE sag_todo_steps
SET is_next = FALSE
WHERE sag_id = % s AND deleted_at IS NULL
""" ,
( step_row [ " sag_id " ] , )
)
execute_query (
"""
2026-02-14 02:26:29 +01:00
UPDATE sag_todo_steps
2026-03-20 00:24:58 +01:00
SET is_next = % s
2026-02-14 02:26:29 +01:00
WHERE id = % s AND deleted_at IS NULL
2026-03-20 00:24:58 +01:00
""" ,
( bool ( data . is_next ) , step_id )
)
2026-02-14 02:26:29 +01:00
return execute_query (
"""
SELECT
t . * ,
COALESCE ( u_created . full_name , u_created . username ) AS created_by_name ,
COALESCE ( u_completed . full_name , u_completed . username ) AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created . user_id = t . created_by_user_id
LEFT JOIN users u_completed ON u_completed . user_id = t . completed_by_user_id
WHERE t . id = % s
""" ,
( step_id , )
) [ 0 ]
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error updating todo step: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to update todo step " )
@router.delete ( " /sag/todo-steps/ {step_id} " )
async def delete_todo_step ( step_id : int ) :
try :
result = execute_query (
"""
UPDATE sag_todo_steps
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = % s AND deleted_at IS NULL
RETURNING id
""" ,
( step_id , )
)
if not result :
raise HTTPException ( status_code = 404 , detail = " Todo step not found " )
return { " status " : " deleted " }
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error deleting todo step: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to delete todo step " )
2026-03-25 13:46:03 +01:00
@router.patch ( " /sag/ { sag_id:int} " )
2026-02-01 00:38:10 +01:00
async def update_sag ( sag_id : int , updates : dict ) :
""" Update a case. """
2026-01-29 23:07:33 +01:00
try :
2026-02-01 00:38:10 +01:00
# Check if case exists
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-01-29 23:07:33 +01:00
2026-02-15 11:12:58 +01:00
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if " type " in updates and " template_key " not in updates :
updates [ " template_key " ] = updates . get ( " type " )
2026-02-17 08:29:05 +01:00
if " status " in updates :
updates [ " status " ] = _normalize_case_status ( updates . get ( " status " ) )
if " deadline " in updates :
updates [ " deadline " ] = _normalize_optional_timestamp ( updates . get ( " deadline " ) , " deadline " )
2026-03-20 00:24:58 +01:00
if " start_date " in updates :
updates [ " start_date " ] = _normalize_optional_timestamp ( updates . get ( " start_date " ) , " start_date " )
2026-02-17 08:29:05 +01:00
if " deferred_until " in updates :
updates [ " deferred_until " ] = _normalize_optional_timestamp ( updates . get ( " deferred_until " ) , " deferred_until " )
2026-03-20 00:24:58 +01:00
if " priority " in updates :
updates [ " priority " ] = ( str ( updates . get ( " priority " ) or " " ) . strip ( ) . lower ( ) or " normal " )
2026-02-17 08:29:05 +01:00
if " ansvarlig_bruger_id " in updates :
updates [ " ansvarlig_bruger_id " ] = _coerce_optional_int ( updates . get ( " ansvarlig_bruger_id " ) , " ansvarlig_bruger_id " )
_validate_user_id ( updates [ " ansvarlig_bruger_id " ] )
if " assigned_group_id " in updates :
updates [ " assigned_group_id " ] = _coerce_optional_int ( updates . get ( " assigned_group_id " ) , " assigned_group_id " )
_validate_group_id ( updates [ " assigned_group_id " ] )
2026-02-01 00:38:10 +01:00
# Build dynamic update query
2026-02-17 08:29:05 +01:00
allowed_fields = [
" titel " ,
" beskrivelse " ,
" template_key " ,
" status " ,
" ansvarlig_bruger_id " ,
" assigned_group_id " ,
2026-03-20 00:24:58 +01:00
" priority " ,
" start_date " ,
2026-02-17 08:29:05 +01:00
" deadline " ,
" deferred_until " ,
" deferred_until_case_id " ,
" deferred_until_status " ,
]
2026-02-01 00:38:10 +01:00
set_clauses = [ ]
params = [ ]
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
for field in allowed_fields :
if field in updates :
set_clauses . append ( f " { field } = %s " )
params . append ( updates [ field ] )
if not set_clauses :
raise HTTPException ( status_code = 400 , detail = " No valid fields to update " )
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
params . append ( sag_id )
2026-02-15 11:12:58 +01:00
set_sql = " , " . join ( set_clauses )
query = f " UPDATE sag_sager SET { set_sql } WHERE id = %s RETURNING * "
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
result = execute_query ( query , tuple ( params ) )
2026-01-29 23:07:33 +01:00
if result :
2026-02-01 00:38:10 +01:00
logger . info ( " ✅ Case updated: %s " , sag_id )
2026-01-29 23:07:33 +01:00
return result [ 0 ]
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 500 , detail = " Failed to update case " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error updating case: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to update case " )
2026-02-15 11:12:58 +01:00
2026-03-05 08:41:59 +01:00
# ---------------------------------------------------------------------------
# Beskrivelse inline editing with history
# ---------------------------------------------------------------------------
class BeskrivelsePatch ( BaseModel ) :
beskrivelse : str
@router.patch ( " /sag/ {sag_id} /beskrivelse " )
async def update_sag_beskrivelse ( sag_id : int , body : BeskrivelsePatch , request : Request ) :
""" Update case description and store a change history entry. """
try :
row = execute_query_single (
" SELECT id, beskrivelse FROM sag_sager WHERE id = %s AND deleted_at IS NULL " ,
( sag_id , )
)
if not row :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
old_beskrivelse = row . get ( " beskrivelse " )
new_beskrivelse = body . beskrivelse
# Resolve acting user (may be None for anonymous)
user_id = _get_user_id_from_request ( request )
changed_by_name = None
if user_id :
u = execute_query_single (
" SELECT COALESCE(full_name, username, CONCAT( ' Bruger # ' , user_id::text)) AS name FROM users WHERE user_id = %s " ,
( user_id , )
)
if u :
changed_by_name = u [ " name " ]
# Write history entry
execute_query (
""" INSERT INTO sag_beskrivelse_history
( sag_id , beskrivelse_before , beskrivelse_after , changed_by_user_id , changed_by_name )
VALUES ( % s , % s , % s , % s , % s ) """ ,
( sag_id , old_beskrivelse , new_beskrivelse , user_id , changed_by_name )
)
# Update the case
execute_query (
" UPDATE sag_sager SET beskrivelse = %s , updated_at = NOW() WHERE id = %s " ,
( new_beskrivelse , sag_id )
)
logger . info ( " ✅ Beskrivelse updated for sag %s by user %s " , sag_id , user_id )
return { " ok " : True , " beskrivelse " : new_beskrivelse }
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error updating beskrivelse for sag %s : %s " , sag_id , e )
raise HTTPException ( status_code = 500 , detail = " Failed to update description " )
@router.get ( " /sag/ {sag_id} /beskrivelse/history " )
async def get_sag_beskrivelse_history ( sag_id : int ) :
""" Return the change history for a case ' s description, newest first. """
exists = execute_query_single (
" SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " ,
( sag_id , )
)
if not exists :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
rows = execute_query (
""" SELECT id, beskrivelse_before, beskrivelse_after,
changed_by_name , changed_at
FROM sag_beskrivelse_history
WHERE sag_id = % s
ORDER BY changed_at DESC
LIMIT 50 """ ,
( sag_id , )
) or [ ]
return rows
2026-02-15 11:12:58 +01:00
class PipelineUpdate ( BaseModel ) :
amount : Optional [ float ] = None
probability : Optional [ int ] = Field ( default = None , ge = 0 , le = 100 )
stage_id : Optional [ int ] = None
description : Optional [ str ] = None
@router.patch ( " /sag/ {sag_id} /pipeline " )
async def update_sag_pipeline ( sag_id : int , pipeline_data : PipelineUpdate ) :
""" Update pipeline fields for a case. """
try :
exists = execute_query (
" SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " ,
( sag_id , )
)
if not exists :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
provided = pipeline_data . model_dump ( exclude_unset = True )
if not provided :
raise HTTPException ( status_code = 400 , detail = " No pipeline fields provided " )
if " stage_id " in provided and provided [ " stage_id " ] is not None :
stage_exists = execute_query (
" SELECT id FROM pipeline_stages WHERE id = %s " ,
( provided [ " stage_id " ] , )
)
if not stage_exists :
raise HTTPException ( status_code = 400 , detail = " Invalid pipeline stage " )
set_clauses = [ ]
params = [ ]
if " amount " in provided :
set_clauses . append ( " pipeline_amount = %s " )
params . append ( provided [ " amount " ] )
if " probability " in provided :
set_clauses . append ( " pipeline_probability = %s " )
params . append ( provided [ " probability " ] )
if " stage_id " in provided :
set_clauses . append ( " pipeline_stage_id = %s " )
params . append ( provided [ " stage_id " ] )
if " description " in provided :
set_clauses . append ( " pipeline_description = %s " )
params . append ( provided [ " description " ] )
params . append ( sag_id )
query = f " UPDATE sag_sager SET { ' , ' . join ( set_clauses ) } WHERE id = %s RETURNING * "
result = execute_query ( query , tuple ( params ) )
if not result :
raise HTTPException ( status_code = 500 , detail = " Failed to update pipeline " )
logger . info ( " ✅ Pipeline updated for case: %s " , sag_id )
return result [ 0 ]
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error updating pipeline for case %s : %s " , sag_id , e )
raise HTTPException ( status_code = 500 , detail = " Failed to update pipeline " )
2026-03-25 13:46:03 +01:00
@router.delete ( " /sag/ { sag_id:int} " )
2026-02-01 00:38:10 +01:00
async def delete_sag ( sag_id : int ) :
""" Soft-delete a case. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
query = " UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id "
result = execute_query ( query , ( sag_id , ) )
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
if result :
logger . info ( " ✅ Case soft-deleted: %s " , sag_id )
return { " status " : " deleted " , " id " : sag_id }
raise HTTPException ( status_code = 500 , detail = " Failed to delete case " )
2026-01-29 23:07:33 +01:00
except HTTPException :
raise
except Exception as e :
2026-02-01 00:38:10 +01:00
logger . error ( " ❌ Error deleting case: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to delete case " )
2026-01-29 23:07:33 +01:00
# ============================================================================
2026-02-01 00:38:10 +01:00
# RELATIONER - Case Relations
2026-01-29 23:07:33 +01:00
# ============================================================================
2026-02-01 00:38:10 +01:00
@router.get ( " /sag/ {sag_id} /relationer " )
async def get_relationer ( sag_id : int ) :
""" Get all relations for a case. """
2026-01-29 23:07:33 +01:00
try :
2026-02-01 00:38:10 +01:00
# Check if case exists
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-01-29 23:07:33 +01:00
query = """
2026-02-01 00:38:10 +01:00
SELECT sr . * ,
ss_kilde . titel as kilde_titel ,
ss_mål . titel as mål_titel
FROM sag_relationer sr
JOIN sag_sager ss_kilde ON sr . kilde_sag_id = ss_kilde . id
JOIN sag_sager ss_mål ON sr . målsag_id = ss_mål . id
WHERE ( sr . kilde_sag_id = % s OR sr . målsag_id = % s )
AND sr . deleted_at IS NULL
ORDER BY sr . created_at DESC
2026-01-29 23:07:33 +01:00
"""
2026-02-01 00:38:10 +01:00
result = execute_query ( query , ( sag_id , sag_id ) )
return result
except HTTPException :
raise
2026-01-29 23:07:33 +01:00
except Exception as e :
2026-02-01 00:38:10 +01:00
logger . error ( " ❌ Error getting relations: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to get relations " )
2026-01-31 23:16:24 +01:00
2026-02-01 00:38:10 +01:00
@router.post ( " /sag/ {sag_id} /relationer " )
async def create_relation ( sag_id : int , data : dict ) :
""" Add a relation to another case. """
2026-01-29 23:07:33 +01:00
try :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if not data . get ( " målsag_id " ) or not data . get ( " relationstype " ) :
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 400 , detail = " målsag_id and relationstype required " )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
målsag_id = data . get ( " målsag_id " )
relationstype = data . get ( " relationstype " )
2026-02-01 00:38:10 +01:00
# Validate both cases exist
check1 = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
check2 = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( målsag_id , ) )
if not check1 or not check2 :
raise HTTPException ( status_code = 404 , detail = " One or both cases not found " )
2026-01-31 23:16:24 +01:00
query = """
2026-02-01 00:38:10 +01:00
INSERT INTO sag_relationer ( kilde_sag_id , målsag_id , relationstype )
VALUES ( % s , % s , % s )
2026-01-31 23:16:24 +01:00
RETURNING *
"""
2026-02-01 00:38:10 +01:00
result = execute_query ( query , ( sag_id , målsag_id , relationstype ) )
2026-01-29 23:07:33 +01:00
if result :
2026-02-01 00:38:10 +01:00
logger . info ( " ✅ Relation created: %s -> %s ( %s ) " , sag_id , målsag_id , relationstype )
2026-01-31 23:16:24 +01:00
return result [ 0 ]
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 500 , detail = " Failed to create relation " )
except HTTPException :
raise
2026-01-29 23:07:33 +01:00
except Exception as e :
2026-02-01 00:38:10 +01:00
logger . error ( " ❌ Error creating relation: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to create relation " )
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
@router.delete ( " /sag/ {sag_id} /relationer/ {relation_id} " )
async def delete_relation ( sag_id : int , relation_id : int ) :
""" Soft-delete a relation. """
2026-01-29 23:07:33 +01:00
try :
2026-02-01 00:38:10 +01:00
check = execute_query (
" SELECT id FROM sag_relationer WHERE id = %s AND deleted_at IS NULL AND (kilde_sag_id = %s OR målsag_id = %s ) " ,
( relation_id , sag_id , sag_id )
)
if not check :
raise HTTPException ( status_code = 404 , detail = " Relation not found " )
query = " UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s RETURNING id "
result = execute_query ( query , ( relation_id , ) )
2026-01-31 23:16:24 +01:00
if result :
2026-02-01 00:38:10 +01:00
logger . info ( " ✅ Relation soft-deleted: %s " , relation_id )
return { " status " : " deleted " , " id " : relation_id }
raise HTTPException ( status_code = 500 , detail = " Failed to delete relation " )
except HTTPException :
raise
2026-01-29 23:07:33 +01:00
except Exception as e :
2026-02-01 00:38:10 +01:00
logger . error ( " ❌ Error deleting relation: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to delete relation " )
# ============================================================================
# TAGS - Case Tags
# ============================================================================
2026-01-29 23:07:33 +01:00
2026-03-05 08:41:59 +01:00
@router.get ( " /sag/tags/all " )
async def get_all_tags ( ) :
""" Return all distinct tag names across all cases (for autocomplete). """
rows = execute_query (
" SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn ASC LIMIT 200 "
) or [ ]
return rows
2026-02-01 00:38:10 +01:00
@router.get ( " /sag/ {sag_id} /tags " )
async def get_tags ( sag_id : int ) :
""" Get all tags for a case. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
query = " SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC "
result = execute_query ( query , ( sag_id , ) )
return result
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error getting tags: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to get tags " )
2026-01-31 23:16:24 +01:00
2026-02-01 00:38:10 +01:00
@router.post ( " /sag/ {sag_id} /tags " )
async def add_tag ( sag_id : int , data : dict ) :
""" Add a tag to a case. """
2026-01-29 23:07:33 +01:00
try :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if not data . get ( " tag_navn " ) :
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 400 , detail = " tag_navn is required " )
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-01-29 23:07:33 +01:00
query = """
2026-02-01 00:38:10 +01:00
INSERT INTO sag_tags ( sag_id , tag_navn )
VALUES ( % s , % s )
2026-01-29 23:07:33 +01:00
RETURNING *
"""
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
result = execute_query ( query , ( sag_id , data . get ( " tag_navn " ) ) )
2026-02-01 00:38:10 +01:00
2026-01-29 23:07:33 +01:00
if result :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
logger . info ( " ✅ Tag added: %s -> %s " , sag_id , data . get ( " tag_navn " ) )
2026-01-29 23:07:33 +01:00
return result [ 0 ]
2026-02-01 00:38:10 +01:00
raise HTTPException ( status_code = 500 , detail = " Failed to add tag " )
except HTTPException :
raise
2026-01-29 23:07:33 +01:00
except Exception as e :
2026-02-01 00:38:10 +01:00
logger . error ( " ❌ Error adding tag: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to add tag " )
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
@router.delete ( " /sag/ {sag_id} /tags/ {tag_id} " )
async def delete_tag ( sag_id : int , tag_id : int ) :
""" Soft-delete a tag. """
2026-01-29 23:07:33 +01:00
try :
2026-02-01 00:38:10 +01:00
check = execute_query (
" SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL " ,
( tag_id , sag_id )
)
if not check :
raise HTTPException ( status_code = 404 , detail = " Tag not found " )
2026-01-29 23:07:33 +01:00
2026-02-01 00:38:10 +01:00
query = " UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id "
result = execute_query ( query , ( tag_id , ) )
if result :
logger . info ( " ✅ Tag soft-deleted: %s " , tag_id )
return { " status " : " deleted " , " id " : tag_id }
raise HTTPException ( status_code = 500 , detail = " Failed to delete tag " )
except HTTPException :
raise
2026-01-29 23:07:33 +01:00
except Exception as e :
2026-02-01 00:38:10 +01:00
logger . error ( " ❌ Error deleting tag: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to delete tag " )
2026-02-01 11:58:44 +01:00
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
# ============================================================================
# CUSTOMERS - Case Customers (Many-to-Many)
# ============================================================================
@router.get ( " /sag/ {sag_id} /customers " )
async def list_case_customers ( sag_id : int ) :
""" List customers associated with a case. """
try :
query = """
SELECT sk . * , c . name as customer_name , c . email as customer_email
FROM sag_kunder sk
JOIN customers c ON sk . customer_id = c . id
WHERE sk . sag_id = % s AND sk . deleted_at IS NULL
"""
result = execute_query ( query , ( sag_id , ) )
return result
except Exception as e :
logger . error ( " ❌ Error listing case customers: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list case customers " )
@router.post ( " /sag/ {sag_id} /customers " )
async def add_case_customer ( sag_id : int , data : dict ) :
""" Add a customer to a case. """
try :
if not data . get ( " customer_id " ) :
raise HTTPException ( status_code = 400 , detail = " customer_id is required " )
role = data . get ( " role " , " Kunde " )
# Check if already exists
check = execute_query (
" SELECT id FROM sag_kunder WHERE sag_id = %s AND customer_id = %s AND deleted_at IS NULL " ,
( sag_id , data [ " customer_id " ] )
)
if check :
return check [ 0 ] # Already linked
query = """
INSERT INTO sag_kunder ( sag_id , customer_id , role )
VALUES ( % s , % s , % s )
RETURNING *
"""
result = execute_query ( query , ( sag_id , data [ " customer_id " ] , role ) )
if result :
logger . info ( " ✅ Customer %s added to case %s " , data [ " customer_id " ] , sag_id )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to add case customer " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error adding case customer: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to add case customer " )
@router.delete ( " /sag/ {sag_id} /customers/ {customer_id} " )
async def remove_case_customer ( sag_id : int , customer_id : int ) :
""" Remove customer from case. """
try :
query = " UPDATE sag_kunder SET deleted_at = NOW() WHERE sag_id = %s AND customer_id = %s RETURNING id "
result = execute_query ( query , ( sag_id , customer_id ) )
if result :
logger . info ( " ✅ Customer %s removed from case %s " , customer_id , sag_id )
return { " status " : " deleted " }
raise HTTPException ( status_code = 404 , detail = " Customer link not found " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error removing case customer: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to remove case customer " )
# ============================================================================
# CONTACTS - Case Contacts
# ============================================================================
@router.get ( " /sag/ {sag_id} /contacts " )
async def list_case_contacts ( sag_id : int ) :
""" List contacts associated with a case. """
try :
query = """
2026-02-06 10:47:14 +01:00
SELECT
sk . * ,
c . first_name ,
c . last_name ,
c . email ,
c . phone ,
c . mobile ,
c . title ,
company . customer_name
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
FROM sag_kontakter sk
JOIN contacts c ON sk . contact_id = c . id
2026-02-06 10:47:14 +01:00
LEFT JOIN LATERAL (
SELECT cu . name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu . id = cc . customer_id
WHERE cc . contact_id = c . id
ORDER BY cc . is_primary DESC , cu . name
LIMIT 1
) company ON TRUE
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
WHERE sk . sag_id = % s AND sk . deleted_at IS NULL
"""
result = execute_query ( query , ( sag_id , ) )
return result
except Exception as e :
logger . error ( " ❌ Error listing case contacts: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list case contacts " )
@router.post ( " /sag/ {sag_id} /contacts " )
async def add_case_contact ( sag_id : int , data : dict ) :
""" Add a contact to a case. """
try :
if not data . get ( " contact_id " ) :
raise HTTPException ( status_code = 400 , detail = " contact_id is required " )
role = data . get ( " role " , " Kontakt " )
2026-02-06 10:47:14 +01:00
is_primary = bool ( data . get ( " is_primary " , False ) )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
# Check if already exists
check = execute_query (
" SELECT id FROM sag_kontakter WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL " ,
( sag_id , data [ " contact_id " ] )
)
if check :
return check [ 0 ] # Already linked
2026-02-06 10:47:14 +01:00
if is_primary :
execute_update (
" UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s " ,
( sag_id , )
)
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
query = """
2026-02-06 10:47:14 +01:00
INSERT INTO sag_kontakter ( sag_id , contact_id , role , is_primary )
VALUES ( % s , % s , % s , % s )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
RETURNING *
"""
2026-02-06 10:47:14 +01:00
result = execute_query ( query , ( sag_id , data [ " contact_id " ] , role , is_primary ) )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if result :
logger . info ( " ✅ Contact added to case %s : %s " , sag_id , data [ " contact_id " ] )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to add contact " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error adding case contact: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to add contact " )
@router.delete ( " /sag/ {sag_id} /contacts/ {contact_id} " )
async def remove_case_contact ( sag_id : int , contact_id : int ) :
""" Remove contact from case. """
try :
query = " UPDATE sag_kontakter SET deleted_at = NOW() WHERE sag_id = %s AND contact_id = %s RETURNING id "
result = execute_query ( query , ( sag_id , contact_id ) )
if result :
logger . info ( " ✅ Contact %s removed from case %s " , contact_id , sag_id )
return { " status " : " deleted " }
raise HTTPException ( status_code = 404 , detail = " Contact link not found " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error removing case contact: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to remove case contact " )
2026-02-06 10:47:14 +01:00
@router.patch ( " /sag/ {sag_id} /contacts/ {contact_id} " )
async def update_case_contact ( sag_id : int , contact_id : int , data : dict ) :
""" Update role or primary status for a case contact. """
try :
existing = execute_query (
" SELECT id FROM sag_kontakter WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL " ,
( sag_id , contact_id )
)
if not existing :
raise HTTPException ( status_code = 404 , detail = " Contact link not found " )
role = data . get ( " role " )
is_primary = data . get ( " is_primary " )
updates = [ ]
params = [ ]
if role is not None :
updates . append ( " role = %s " )
params . append ( role )
if is_primary is not None :
if bool ( is_primary ) :
execute_update (
" UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s " ,
( sag_id , )
)
updates . append ( " is_primary = %s " )
params . append ( bool ( is_primary ) )
if not updates :
raise HTTPException ( status_code = 400 , detail = " No fields to update " )
params . extend ( [ sag_id , contact_id ] )
query = f """
UPDATE sag_kontakter
SET { ' , ' . join ( updates ) }
WHERE sag_id = % s AND contact_id = % s
RETURNING *
"""
result = execute_query ( query , tuple ( params ) )
if result :
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to update contact " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error updating case contact: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to update contact " )
2026-02-01 11:58:44 +01:00
# ============================================================================
# HARDWARE - Placeholder endpoints for frontend compatibility
# ============================================================================
@router.get ( " /sag/ {sag_id} /hardware " )
async def list_case_hardware ( sag_id : int ) :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
""" List hardware associated with a case. """
try :
query = """
SELECT sh . id as relation_id , h . *
FROM sag_hardware sh
JOIN hardware_assets h ON sh . hardware_id = h . id
WHERE sh . sag_id = % s AND sh . deleted_at IS NULL
ORDER BY sh . created_at DESC
"""
result = execute_query ( query , ( sag_id , ) )
return result
except Exception as e :
logger . error ( " ❌ Error listing case hardware: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list case hardware " )
2026-02-01 11:58:44 +01:00
@router.post ( " /sag/ {sag_id} /hardware " )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
async def add_case_hardware ( sag_id : int , data : dict ) :
""" Add hardware to case. """
try :
if not data . get ( " hardware_id " ) :
raise HTTPException ( status_code = 400 , detail = " hardware_id is required " )
hardware_id = data . get ( " hardware_id " )
# Check if already linked
check = execute_query (
" SELECT id FROM sag_hardware WHERE sag_id = %s AND hardware_id = %s AND deleted_at IS NULL " ,
( sag_id , hardware_id )
)
if check :
return check [ 0 ] # Already linked
query = """
INSERT INTO sag_hardware ( sag_id , hardware_id , note )
VALUES ( % s , % s , % s )
ON CONFLICT ( sag_id , hardware_id )
DO UPDATE SET deleted_at = NULL , note = EXCLUDED . note
RETURNING *
"""
result = execute_query ( query , ( sag_id , hardware_id , data . get ( " note " ) ) )
if result :
logger . info ( " ✅ Hardware %s added to case %s " , hardware_id , sag_id )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to add hardware " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error adding case hardware: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to add hardware " )
2026-02-01 11:58:44 +01:00
@router.delete ( " /sag/ {sag_id} /hardware/ {hardware_id} " )
async def remove_case_hardware ( sag_id : int , hardware_id : int ) :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
""" Remove hardware from case. """
try :
query = " UPDATE sag_hardware SET deleted_at = NOW() WHERE sag_id = %s AND hardware_id = %s RETURNING id "
result = execute_query ( query , ( sag_id , hardware_id ) )
if result :
logger . info ( " ✅ Hardware %s removed from case %s " , hardware_id , sag_id )
return { " status " : " deleted " }
raise HTTPException ( status_code = 404 , detail = " Hardware link not found " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error removing case hardware: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to remove hardware " )
2026-02-01 11:58:44 +01:00
# ============================================================================
# LOCATIONS - Placeholder endpoints for frontend compatibility
# ============================================================================
@router.get ( " /sag/ {sag_id} /locations " )
async def list_case_locations ( sag_id : int ) :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
""" List locations associated with a case. """
try :
query = """
SELECT sl . id as relation_id , l . *
FROM sag_lokationer sl
JOIN locations_locations l ON sl . location_id = l . id
WHERE sl . sag_id = % s AND sl . deleted_at IS NULL
ORDER BY sl . created_at DESC
"""
result = execute_query ( query , ( sag_id , ) )
return result
except Exception as e :
logger . error ( " ❌ Error listing case locations: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list case locations " )
2026-02-01 11:58:44 +01:00
@router.post ( " /sag/ {sag_id} /locations " )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
async def add_case_location ( sag_id : int , data : dict ) :
""" Add location to case. """
try :
if not data . get ( " location_id " ) :
raise HTTPException ( status_code = 400 , detail = " location_id is required " )
location_id = data . get ( " location_id " )
# Check if already linked
check = execute_query (
" SELECT id FROM sag_lokationer WHERE sag_id = %s AND location_id = %s AND deleted_at IS NULL " ,
( sag_id , location_id )
)
if check :
return check [ 0 ] # Already linked
query = """
INSERT INTO sag_lokationer ( sag_id , location_id , note )
VALUES ( % s , % s , % s )
ON CONFLICT ( sag_id , location_id )
DO UPDATE SET deleted_at = NULL , note = EXCLUDED . note
RETURNING *
"""
result = execute_query ( query , ( sag_id , location_id , data . get ( " note " ) ) )
if result :
logger . info ( " ✅ Location %s added to case %s " , location_id , sag_id )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to add location " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error adding case location: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to add location " )
2026-02-01 11:58:44 +01:00
@router.delete ( " /sag/ {sag_id} /locations/ {location_id} " )
async def remove_case_location ( sag_id : int , location_id : int ) :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
""" Remove location from case. """
try :
2026-02-17 08:29:05 +01:00
query = """
UPDATE sag_lokationer
SET deleted_at = NOW ( )
WHERE sag_id = % s
AND deleted_at IS NULL
AND ( location_id = % s OR id = % s )
RETURNING id
"""
result = execute_query ( query , ( sag_id , location_id , location_id ) )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if result :
logger . info ( " ✅ Location %s removed from case %s " , location_id , sag_id )
return { " status " : " deleted " }
raise HTTPException ( status_code = 404 , detail = " Location link not found " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error removing case location: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to remove location " )
# ============================================================================
# VAREKØB & SALG - Aggregation (Case Tree)
# ============================================================================
@router.get ( " /sag/ {sag_id} /varekob-salg " )
async def get_varekob_salg ( sag_id : int , include_subcases : bool = True ) :
""" Aggregate time entries (and future sales items) for a case tree. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-03-04 07:40:18 +01:00
has_sale_items_table = _table_exists ( " sag_salgsvarer " )
if not has_sale_items_table :
logger . warning ( " ⚠️ sag_salgsvarer table missing - sale item aggregation skipped for sag_id= %s " , sag_id )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if include_subcases :
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN målsag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id ,
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN kilde_sag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
) ,
case_tree AS (
SELECT id FROM sag_sager WHERE id = % s AND deleted_at IS NULL
UNION
SELECT nr . child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr . parent_id = ct . id
)
SELECT s . id , s . titel
FROM sag_sager s
JOIN case_tree ct ON s . id = ct . id
WHERE s . deleted_at IS NULL
ORDER BY s . id
"""
case_tree = execute_query ( case_tree_query , ( sag_id , ) )
time_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN målsag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id ,
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN kilde_sag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
) ,
case_tree AS (
SELECT id FROM sag_sager WHERE id = % s AND deleted_at IS NULL
UNION
SELECT nr . child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr . parent_id = ct . id
)
SELECT t . * , s . titel AS source_sag_titel
FROM tmodule_times t
JOIN case_tree ct ON t . sag_id = ct . id
LEFT JOIN sag_sager s ON s . id = t . sag_id
ORDER BY t . worked_date DESC NULLS LAST , t . id DESC
"""
time_entries = execute_query ( time_query , ( sag_id , ) )
2026-03-04 07:40:18 +01:00
if has_sale_items_table :
sale_items_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN målsag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id ,
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN kilde_sag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
) ,
case_tree AS (
SELECT id FROM sag_sager WHERE id = % s AND deleted_at IS NULL
UNION
SELECT nr . child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr . parent_id = ct . id
)
SELECT si . * , s . titel AS source_sag_titel
FROM sag_salgsvarer si
JOIN case_tree ct ON si . sag_id = ct . id
LEFT JOIN sag_sager s ON s . id = si . sag_id
ORDER BY si . line_date DESC NULLS LAST , si . id DESC
"""
sale_items = execute_query ( sale_items_query , ( sag_id , ) )
else :
sale_items = [ ]
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
else :
case_tree = execute_query (
" SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL " ,
( sag_id , )
)
time_query = """
SELECT t . * , s . titel AS source_sag_titel
FROM tmodule_times t
LEFT JOIN sag_sager s ON s . id = t . sag_id
WHERE t . sag_id = % s
ORDER BY t . worked_date DESC NULLS LAST , t . id DESC
"""
time_entries = execute_query ( time_query , ( sag_id , ) )
2026-03-04 07:40:18 +01:00
if has_sale_items_table :
sale_items_query = """
SELECT si . * , s . titel AS source_sag_titel
FROM sag_salgsvarer si
LEFT JOIN sag_sager s ON s . id = si . sag_id
WHERE si . sag_id = % s
ORDER BY si . line_date DESC NULLS LAST , si . id DESC
"""
sale_items = execute_query ( sale_items_query , ( sag_id , ) )
else :
sale_items = [ ]
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
total_entries = len ( time_entries or [ ] )
total_hours = 0
billable_hours = 0
sale_total = 0
purchase_total = 0
for entry in time_entries or [ ] :
hours = entry . get ( " approved_hours " ) or entry . get ( " original_hours " ) or 0
total_hours + = float ( hours )
if entry . get ( " billable " ) :
billable_hours + = float ( hours )
for item in sale_items or [ ] :
amount = item . get ( " amount " ) or 0
if ( item . get ( " type " ) or " " ) . lower ( ) == " purchase " :
purchase_total + = float ( amount )
else :
sale_total + = float ( amount )
return {
" sag_id " : sag_id ,
" include_subcases " : include_subcases ,
" case_tree " : case_tree or [ ] ,
" time_entries " : time_entries or [ ] ,
" sale_items " : sale_items or [ ] ,
" totals " : {
" total_entries " : total_entries ,
" total_hours " : round ( total_hours , 2 ) ,
" billable_hours " : round ( billable_hours , 2 ) ,
" sale_total " : round ( sale_total , 2 ) ,
" purchase_total " : round ( purchase_total , 2 ) ,
" net_total " : round ( sale_total - purchase_total , 2 )
}
}
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error aggregating case data: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to aggregate case data " )
2026-02-14 02:26:29 +01:00
@router.get ( " /sag/ {sag_id} /calendar-events " )
async def get_case_calendar_events ( sag_id : int , include_children : bool = True ) :
""" Return calendar events for a case and optionally its child cases. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
if include_children :
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN målsag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id ,
CASE
WHEN LOWER ( relationstype ) IN ( ' afledt af ' , ' afledt_af ' ) THEN kilde_sag_id
WHEN LOWER ( relationstype ) IN ( ' årsag til ' , ' årsag_til ' ) THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
) ,
case_tree AS (
SELECT id FROM sag_sager WHERE id = % s AND deleted_at IS NULL
UNION
SELECT nr . child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr . parent_id = ct . id
)
SELECT s . id , s . titel
FROM sag_sager s
JOIN case_tree ct ON s . id = ct . id
WHERE s . deleted_at IS NULL
ORDER BY s . id
"""
case_rows = execute_query ( case_tree_query , ( sag_id , ) ) or [ ]
else :
case_rows = execute_query (
" SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL " ,
( sag_id , )
) or [ ]
case_ids = [ row [ " id " ] for row in case_rows ]
case_titles = { row [ " id " ] : row . get ( " titel " ) for row in case_rows }
if not case_ids :
return { " current " : [ ] , " children " : [ ] }
placeholders = " , " . join ( [ " %s " ] * len ( case_ids ) )
reminder_query = f """
SELECT r . id , r . sag_id , r . title , r . message , r . event_type , r . priority ,
r . next_check_at , r . scheduled_at
FROM sag_reminders r
WHERE r . deleted_at IS NULL
AND r . is_active = true
AND r . sag_id IN ( { placeholders } )
"""
reminders = execute_query ( reminder_query , tuple ( case_ids ) ) or [ ]
case_query = f """
SELECT id , titel , deadline , deferred_until
FROM sag_sager
WHERE deleted_at IS NULL
AND id IN ( { placeholders } )
"""
case_dates = execute_query ( case_query , tuple ( case_ids ) ) or [ ]
events_by_case : dict [ int , list ] = { cid : [ ] for cid in case_ids }
for row in reminders :
start_value = row . get ( " next_check_at " ) or row . get ( " scheduled_at " )
if not start_value :
continue
events_by_case [ row [ " sag_id " ] ] . append ( {
" id " : f " reminder: { row [ ' id ' ] } " ,
" title " : row . get ( " title " ) ,
" message " : row . get ( " message " ) ,
" event_kind " : row . get ( " event_type " ) or " reminder " ,
" start " : start_value . isoformat ( ) ,
" url " : f " /sag/ { row [ ' sag_id ' ] } "
} )
for row in case_dates :
if row . get ( " deadline " ) :
events_by_case [ row [ " id " ] ] . append ( {
" id " : f " deadline: { row [ ' id ' ] } " ,
" title " : f " Deadline: { row . get ( ' titel ' ) } " ,
" event_kind " : " deadline " ,
" start " : row [ " deadline " ] . isoformat ( ) ,
" url " : f " /sag/ { row [ ' id ' ] } "
} )
if row . get ( " deferred_until " ) :
events_by_case [ row [ " id " ] ] . append ( {
" id " : f " deferred: { row [ ' id ' ] } " ,
" title " : f " Deferred: { row . get ( ' titel ' ) } " ,
" event_kind " : " deferred " ,
" start " : row [ " deferred_until " ] . isoformat ( ) ,
" url " : f " /sag/ { row [ ' id ' ] } "
} )
current_events = events_by_case . get ( sag_id , [ ] )
children = [ ]
for cid in case_ids :
if cid == sag_id :
continue
children . append ( {
" case_id " : cid ,
" case_title " : case_titles . get ( cid ) or f " Sag # { cid } " ,
" events " : events_by_case . get ( cid , [ ] )
} )
return { " current " : current_events , " children " : children }
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error loading case calendar events: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to load calendar events " )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
# ============================================================================
# VAREKØB & SALG - CRUD (Case-linked sale items)
# ============================================================================
@router.get ( " /sag/ {sag_id} /sale-items " )
async def list_sale_items ( sag_id : int ) :
""" List sale items for a case. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-03-04 07:40:18 +01:00
if not _table_exists ( " sag_salgsvarer " ) :
logger . warning ( " ⚠️ sag_salgsvarer table missing - returning empty sale items list for sag_id= %s " , sag_id )
return [ ]
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
query = """
SELECT si . * , s . titel AS source_sag_titel
FROM sag_salgsvarer si
LEFT JOIN sag_sager s ON s . id = si . sag_id
WHERE si . sag_id = % s
ORDER BY si . line_date DESC NULLS LAST , si . id DESC
"""
return execute_query ( query , ( sag_id , ) ) or [ ]
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error listing sale items: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list sale items " )
@router.post ( " /sag/ {sag_id} /sale-items " )
async def create_sale_item ( sag_id : int , data : dict ) :
""" Create a sale item for a case. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
description = data . get ( " description " )
amount = data . get ( " amount " )
if not description :
raise HTTPException ( status_code = 400 , detail = " description is required " )
if amount is None :
raise HTTPException ( status_code = 400 , detail = " amount is required " )
item_type = ( data . get ( " type " ) or " sale " ) . lower ( )
if item_type not in ( " sale " , " purchase " ) :
raise HTTPException ( status_code = 400 , detail = " type must be sale or purchase " )
status = ( data . get ( " status " ) or " draft " ) . lower ( )
if status not in ( " draft " , " confirmed " , " cancelled " ) :
raise HTTPException ( status_code = 400 , detail = " status must be draft, confirmed, or cancelled " )
query = """
INSERT INTO sag_salgsvarer
2026-02-08 12:42:19 +01:00
( sag_id , type , description , quantity , unit , unit_price , amount , currency , status , line_date , external_ref , product_id )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
VALUES
2026-02-08 12:42:19 +01:00
( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
RETURNING *
"""
params = (
sag_id ,
item_type ,
description ,
data . get ( " quantity " ) ,
data . get ( " unit " ) ,
data . get ( " unit_price " ) ,
amount ,
data . get ( " currency " , " DKK " ) ,
status ,
data . get ( " line_date " ) ,
data . get ( " external_ref " ) ,
2026-02-08 12:42:19 +01:00
data . get ( " product_id " ) ,
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
)
result = execute_query ( query , params )
if result :
logger . info ( " ✅ Sale item created for case %s " , sag_id )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to create sale item " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error creating sale item: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to create sale item " )
@router.get ( " /sag/ {sag_id} /sale-items/ {item_id} " )
async def get_sale_item ( sag_id : int , item_id : int ) :
""" Get a single sale item for a case. """
try :
query = """
SELECT si . * , s . titel AS source_sag_titel
FROM sag_salgsvarer si
LEFT JOIN sag_sager s ON s . id = si . sag_id
WHERE si . sag_id = % s AND si . id = % s
"""
result = execute_query ( query , ( sag_id , item_id ) )
if not result :
raise HTTPException ( status_code = 404 , detail = " Sale item not found " )
return result [ 0 ]
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error getting sale item: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to get sale item " )
@router.patch ( " /sag/ {sag_id} /sale-items/ {item_id} " )
async def update_sale_item ( sag_id : int , item_id : int , updates : dict ) :
""" Update a sale item for a case. """
try :
check = execute_query (
" SELECT id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s " ,
( item_id , sag_id )
)
if not check :
raise HTTPException ( status_code = 404 , detail = " Sale item not found " )
allowed_fields = [
" type " ,
" description " ,
" quantity " ,
" unit " ,
" unit_price " ,
" amount " ,
" currency " ,
" status " ,
" line_date " ,
" external_ref " ,
2026-02-08 12:42:19 +01:00
" product_id " ,
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
]
set_clauses = [ ]
params = [ ]
for field in allowed_fields :
if field in updates :
if field == " type " :
value = ( updates [ field ] or " " ) . lower ( )
if value not in ( " sale " , " purchase " ) :
raise HTTPException ( status_code = 400 , detail = " type must be sale or purchase " )
set_clauses . append ( " type = %s " )
params . append ( value )
elif field == " status " :
value = ( updates [ field ] or " " ) . lower ( )
if value not in ( " draft " , " confirmed " , " cancelled " ) :
raise HTTPException ( status_code = 400 , detail = " status must be draft, confirmed, or cancelled " )
set_clauses . append ( " status = %s " )
params . append ( value )
else :
set_clauses . append ( f " { field } = %s " )
params . append ( updates [ field ] )
if not set_clauses :
raise HTTPException ( status_code = 400 , detail = " No valid fields to update " )
params . extend ( [ item_id , sag_id ] )
query = f " UPDATE sag_salgsvarer SET { ' , ' . join ( set_clauses ) } WHERE id = %s AND sag_id = %s RETURNING * "
result = execute_query ( query , tuple ( params ) )
if result :
logger . info ( " ✅ Sale item updated: %s " , item_id )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to update sale item " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error updating sale item: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to update sale item " )
@router.delete ( " /sag/ {sag_id} /sale-items/ {item_id} " )
async def delete_sale_item ( sag_id : int , item_id : int ) :
""" Delete a sale item for a case. """
try :
query = " DELETE FROM sag_salgsvarer WHERE id = %s AND sag_id = %s RETURNING id "
result = execute_query ( query , ( item_id , sag_id ) )
if result :
logger . info ( " ✅ Sale item deleted: %s " , item_id )
return { " status " : " deleted " , " id " : item_id }
raise HTTPException ( status_code = 404 , detail = " Sale item not found " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error deleting sale item: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to delete sale item " )
# ============================================================================
# KOMMENTARER - Case Comments
# ============================================================================
@router.get ( " /sag/ {sag_id} /kommentarer " )
async def get_kommentarer ( sag_id : int ) :
""" Get all comments for a case. """
try :
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-03-23 20:35:15 +01:00
query = " SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC "
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
result = execute_query ( query , ( sag_id , ) )
return result
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error getting comments: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to get comments " )
@router.post ( " /sag/ {sag_id} /kommentarer " )
2026-03-23 20:35:15 +01:00
async def add_kommentar ( sag_id : int , data : dict , request : Request ) :
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
""" Add a comment to a case. """
try :
if not data . get ( " indhold " ) :
raise HTTPException ( status_code = 400 , detail = " indhold is required " )
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
2026-03-23 20:35:15 +01:00
er_system_besked = bool ( data . get ( " er_system_besked " , False ) )
if er_system_besked :
forfatter = str ( data . get ( " forfatter " ) or " System " ) . strip ( ) or " System "
else :
forfatter = None
try :
user_id = _get_user_id_from_request ( request )
user_row = execute_query_single (
"""
SELECT COALESCE ( NULLIF ( TRIM ( full_name ) , ' ' ) , NULLIF ( TRIM ( username ) , ' ' ) ) AS display_name
FROM users
WHERE user_id = % s
""" ,
( user_id , ) ,
)
if user_row and user_row . get ( " display_name " ) :
forfatter = str ( user_row . get ( " display_name " ) ) . strip ( )
except HTTPException :
# Fallback to provided author for legacy callers without auth context.
pass
if not forfatter :
provided_author = str ( data . get ( " forfatter " ) or " " ) . strip ( )
if provided_author and provided_author . lower ( ) != " bruger " :
forfatter = provided_author
else :
forfatter = " Bruger "
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
query = """
INSERT INTO sag_kommentarer ( sag_id , forfatter , indhold , er_system_besked )
VALUES ( % s , % s , % s , % s )
RETURNING *
"""
result = execute_query ( query , ( sag_id , forfatter , data . get ( " indhold " ) , er_system_besked ) )
if result :
logger . info ( " ✅ Comment added to case %s by %s " , sag_id , forfatter )
return result [ 0 ]
raise HTTPException ( status_code = 500 , detail = " Failed to add comment " )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error adding comment: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to add comment " )
# ============================================================================
# FILES - Case Files
# ============================================================================
UPLOAD_BASE_PATH = Path ( settings . UPLOAD_DIR ) . resolve ( )
SAG_FILE_SUBDIR = " sag_files "
( UPLOAD_BASE_PATH / SAG_FILE_SUBDIR ) . mkdir ( parents = True , exist_ok = True )
ALLOWED_EXTENSIONS = { ext . lower ( ) for ext in settings . ALLOWED_EXTENSIONS }
MAX_ATTACHMENT_SIZE = settings . EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
def _generate_stored_name ( filename : str , subdir : str ) - > str :
cleaned = Path ( filename ) . name
unique = f " { uuid4 ( ) . hex } _ { cleaned } "
return f " { subdir } / { unique } "
def _resolve_attachment_path ( stored_name : str ) - > Path :
return UPLOAD_BASE_PATH / stored_name
def _store_upload_file ( upload_file : UploadFile , subdir : str ) :
if not upload_file . filename :
raise HTTPException ( 400 , detail = " Filename missing " )
ext = Path ( upload_file . filename ) . suffix . lower ( ) . lstrip ( " . " )
# Basic check - allow more types for generic files?
# if ext not in ALLOWED_EXTENSIONS: ...
upload_file . file . seek ( 0 , os . SEEK_END )
size = upload_file . file . tell ( )
upload_file . file . seek ( 0 )
if size > MAX_ATTACHMENT_SIZE :
raise HTTPException ( 400 , detail = f " File too large (> { settings . EMAIL_MAX_UPLOAD_SIZE_MB } MB) " )
stored_name = _generate_stored_name ( upload_file . filename , subdir )
destination = _resolve_attachment_path ( stored_name )
destination . parent . mkdir ( parents = True , exist_ok = True )
try :
with destination . open ( " wb " ) as buffer :
shutil . copyfileobj ( upload_file . file , buffer )
except Exception as e :
logger . error ( f " Upload failed: { e } " )
raise HTTPException ( 500 , detail = " Server upload failed " )
return stored_name , size
@router.get ( " /sag/ {sag_id} /files " )
async def list_sag_files ( sag_id : int ) :
""" List files attached to a case. """
try :
query = """
SELECT * FROM sag_files
WHERE sag_id = % s
ORDER BY created_at DESC
"""
files = execute_query ( query , ( sag_id , ) )
# Add download URL
if files :
for f in files :
f [ " download_url " ] = f " /api/v1/sag/ { sag_id } /files/ { f [ ' id ' ] } "
return files or [ ]
except Exception as e :
logger . error ( " ❌ Error listing files: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to list files " )
@router.post ( " /sag/ {sag_id} /files " )
async def upload_sag_files ( sag_id : int , files : List [ UploadFile ] = File ( . . . ) ) :
""" Upload files to a case. """
check = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not check :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
saved_files = [ ]
for file in files :
try :
stored_name , size = _store_upload_file ( file , SAG_FILE_SUBDIR )
query = """
INSERT INTO sag_files ( sag_id , filename , content_type , size_bytes , stored_name )
VALUES ( % s , % s , % s , % s , % s )
RETURNING id , filename , created_at
"""
result = execute_query ( query , ( sag_id , file . filename , file . content_type , size , stored_name ) )
if result :
saved = result [ 0 ]
saved [ " download_url " ] = f " /api/v1/sag/ { sag_id } /files/ { saved [ ' id ' ] } "
saved_files . append ( saved )
except HTTPException :
continue # Skip invalid
except Exception as e :
logger . error ( f " Error saving file { file . filename } : { e } " )
continue
return saved_files
@router.get ( " /sag/ {sag_id} /files/ {file_id} " )
2026-02-06 10:47:14 +01:00
async def download_sag_file ( sag_id : int , file_id : int , download : bool = False ) :
""" Download or preview a specific file.
Args :
download : If True , force download . If False ( default ) , display inline in browser .
"""
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
query = " SELECT * FROM sag_files WHERE id = %s AND sag_id = %s "
result = execute_query ( query , ( file_id , sag_id ) )
if not result :
raise HTTPException ( status_code = 404 , detail = " File not found " )
file_data = result [ 0 ]
path = _resolve_attachment_path ( file_data [ " stored_name " ] )
if not path . exists ( ) :
raise HTTPException ( status_code = 404 , detail = " File lost on server " )
2026-02-06 10:47:14 +01:00
# Determine content disposition
headers = { }
if download :
headers [ " Content-Disposition " ] = f ' attachment; filename= " { file_data [ " filename " ] } " '
else :
headers [ " Content-Disposition " ] = f ' inline; filename= " { file_data [ " filename " ] } " '
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
return FileResponse (
path = path ,
filename = file_data [ " filename " ] ,
2026-02-06 10:47:14 +01:00
media_type = file_data . get ( " content_type " , " application/octet-stream " ) ,
headers = headers
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
)
@router.delete ( " /sag/ {sag_id} /files/ {file_id} " )
async def delete_sag_file ( sag_id : int , file_id : int ) :
""" Delete a file. """
query = " DELETE FROM sag_files WHERE id = %s AND sag_id = %s RETURNING stored_name "
result = execute_query ( query , ( file_id , sag_id ) )
if result :
# Clean up disk
path = _resolve_attachment_path ( result [ 0 ] [ " stored_name " ] )
if path . exists ( ) :
try :
os . remove ( path )
except :
pass
return { " status " : " deleted " }
raise HTTPException ( status_code = 404 , detail = " File not found " )
# ============================================================================
# EMAILS - Case Emails (Linked)
# ============================================================================
@router.post ( " /sag/ {sag_id} /email-links " )
async def add_sag_email_link ( sag_id : int , payload : dict ) :
""" Link an existing email to a case. """
email_id = payload . get ( " email_id " )
if not email_id :
raise HTTPException ( status_code = 400 , detail = " email_id required " )
query = """
INSERT INTO sag_emails ( sag_id , email_id )
VALUES ( % s , % s )
ON CONFLICT DO NOTHING
RETURNING *
"""
execute_query ( query , ( sag_id , email_id ) )
return { " status " : " linked " }
@router.get ( " /sag/ {sag_id} /email-links " )
async def get_sag_emails ( sag_id : int ) :
""" Get emails linked to a case. """
query = """
2026-03-03 10:42:16 +01:00
WITH linked_emails AS (
SELECT
e . * ,
COALESCE (
NULLIF ( REGEXP_REPLACE ( ( REGEXP_SPLIT_TO_ARRAY ( COALESCE ( e . email_references , ' ' ) , E ' [ \\ s,]+ ' ) ) [ 1 ] , ' [<> \\ s] ' , ' ' , ' g ' ) , ' ' ) ,
2026-03-23 20:35:15 +01:00
NULLIF (
REGEXP_REPLACE (
2026-03-30 07:50:15 +02:00
( REGEXP_SPLIT_TO_ARRAY ( COALESCE ( e . in_reply_to , ' ' ) , E ' [ \\ s,]+ ' ) ) [ 1 ] ,
' [<> \\ s] ' ,
2026-03-23 20:35:15 +01:00
' ' ,
' g '
) ,
' '
) ,
2026-03-30 07:50:15 +02:00
NULLIF ( REGEXP_REPLACE ( TRIM ( COALESCE ( e . thread_key , ' ' ) ) , ' [<> \\ s] ' , ' ' , ' g ' ) , ' ' ) ,
NULLIF (
REGEXP_REPLACE (
LOWER ( TRIM ( COALESCE ( e . subject , ' ' ) ) ) ,
' ^(?:(?:re|fw|fwd|sv|aw) \\ s*: \\ s*)+ ' ,
' ' ,
' i '
) ,
' '
) ,
2026-03-03 10:42:16 +01:00
NULLIF ( REGEXP_REPLACE ( TRIM ( COALESCE ( e . message_id , ' ' ) ) , ' [<> \\ s] ' , ' ' , ' g ' ) , ' ' ) ,
CONCAT ( ' email- ' , e . id : : text )
2026-03-23 20:35:15 +01:00
) AS resolved_thread_key
2026-03-03 10:42:16 +01:00
FROM email_messages e
JOIN sag_emails se ON e . id = se . email_id
WHERE se . sag_id = % s
)
SELECT
linked_emails . * ,
2026-03-23 20:35:15 +01:00
(
LOWER ( COALESCE ( linked_emails . folder , ' ' ) ) LIKE ' sent %% '
OR LOWER ( COALESCE ( linked_emails . status , ' ' ) ) = ' sent '
) AS is_outgoing ,
COUNT ( * ) OVER ( PARTITION BY linked_emails . resolved_thread_key ) AS thread_message_count ,
MAX ( linked_emails . received_date ) OVER ( PARTITION BY linked_emails . resolved_thread_key ) AS thread_last_received_date
2026-03-03 10:42:16 +01:00
FROM linked_emails
ORDER BY thread_last_received_date DESC NULLS LAST , received_date DESC
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
"""
return execute_query ( query , ( sag_id , ) ) or [ ]
@router.delete ( " /sag/ {sag_id} /email-links/ {email_id} " )
async def remove_sag_email_link ( sag_id : int , email_id : int ) :
""" Unlink an email. """
query = " DELETE FROM sag_emails WHERE sag_id = %s AND email_id = %s "
execute_query ( query , ( sag_id , email_id ) )
return { " status " : " unlinked " }
def _decode_header_str ( header_val ) :
if not header_val : return " "
try :
decoded_list = decode_header ( header_val )
result = " "
for content , encoding in decoded_list :
if isinstance ( content , bytes ) :
if encoding :
try :
result + = content . decode ( encoding )
except :
result + = content . decode ( ' utf-8 ' , errors = ' ignore ' )
else :
result + = content . decode ( ' utf-8 ' , errors = ' ignore ' )
else :
result + = str ( content )
return result
except :
return str ( header_val )
@router.post ( " /sag/ {sag_id} /upload-email " )
async def upload_sag_email ( sag_id : int , file : UploadFile = File ( . . . ) ) :
""" Upload .eml/.msg, parse it, save to email_messages, and link to case. """
content = await file . read ( )
filename = file . filename . lower ( )
email_data = { }
temp_id = uuid4 ( ) . hex
# 1. Parse File
if filename . endswith ( ' .msg ' ) :
if not extract_msg : raise HTTPException ( 500 , " extract-msg missing " )
import io
msg = extract_msg . Message ( io . BytesIO ( content ) )
email_data = {
' message_id ' : msg . messageId or f " msg- { temp_id } " ,
' subject ' : msg . subject or " No Subject " ,
' sender_email ' : msg . sender or " " ,
' sender_name ' : msg . sender or " " ,
' recipient_email ' : msg . to or " " ,
' cc ' : msg . cc or " " ,
' body_text ' : msg . body ,
' body_html ' : msg . htmlBody ,
' received_date ' : msg . date or datetime . now ( ) ,
' folder ' : ' Imported ' ,
' attachments ' : [ ] ,
' has_attachments ' : False ,
' attachment_count ' : 0
}
elif filename . endswith ( ' .eml ' ) :
msg = email . message_from_bytes ( content )
body_text = " "
if msg . is_multipart ( ) :
for part in msg . walk ( ) :
if part . get_content_type ( ) == " text/plain " :
body_text = part . get_payload ( decode = True ) . decode ( ' utf-8 ' , errors = ' ignore ' )
break
else :
body_text = msg . get_payload ( decode = True ) . decode ( ' utf-8 ' , errors = ' ignore ' )
email_data = {
' message_id ' : msg . get ( ' Message-ID ' , f " eml- { temp_id } " ) ,
2026-03-03 10:42:16 +01:00
' in_reply_to ' : _decode_header_str ( msg . get ( ' In-Reply-To ' , ' ' ) ) ,
' email_references ' : _decode_header_str ( msg . get ( ' References ' , ' ' ) ) ,
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
' subject ' : _decode_header_str ( msg . get ( ' Subject ' , ' No Subject ' ) ) ,
' sender_email ' : _decode_header_str ( msg . get ( ' From ' , ' ' ) ) ,
' sender_name ' : _decode_header_str ( msg . get ( ' From ' , ' ' ) ) ,
' recipient_email ' : _decode_header_str ( msg . get ( ' To ' , ' ' ) ) ,
' cc ' : _decode_header_str ( msg . get ( ' Cc ' , ' ' ) ) ,
' received_date ' : datetime . now ( ) ,
' body_text ' : body_text ,
' body_html ' : " " ,
' folder ' : ' Imported ' ,
' has_attachments ' : False ,
' attachment_count ' : 0 ,
' attachments ' : [ ]
}
else :
raise HTTPException ( 400 , " Only .msg or .eml files allowed " )
# 2. Save Email via Service (deduplicate)
svc = EmailService ( )
existing = execute_query_single ( " SELECT id FROM email_messages WHERE message_id = %s " , ( email_data [ ' message_id ' ] , ) )
if existing :
email_id = existing [ ' id ' ]
else :
# We try to strict save, keeping it simple
email_id = await svc . save_email ( email_data )
# 3. Link
await add_sag_email_link ( sag_id , { " email_id " : email_id } )
return { " status " : " imported " , " email_id " : email_id }
2026-03-05 08:41:59 +01:00
@router.post ( " /sag/ {sag_id} /emails/send " )
2026-03-23 20:35:15 +01:00
async def send_sag_email ( sag_id : int , payload : SagSendEmailRequest , request : Request ) :
2026-03-05 08:41:59 +01:00
""" Send outbound email directly from case email tab and link it to case. """
case_exists = execute_query ( " SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL " , ( sag_id , ) )
if not case_exists :
raise HTTPException ( status_code = 404 , detail = " Case not found " )
to_addresses = _normalize_email_list ( payload . to , " to " )
cc_addresses = _normalize_email_list ( payload . cc , " cc " )
bcc_addresses = _normalize_email_list ( payload . bcc , " bcc " )
if not to_addresses :
raise HTTPException ( status_code = 400 , detail = " At least one recipient in ' to ' is required " )
subject = ( payload . subject or " " ) . strip ( )
body_text = ( payload . body_text or " " ) . strip ( )
if not subject :
raise HTTPException ( status_code = 400 , detail = " subject is required " )
if not body_text :
raise HTTPException ( status_code = 400 , detail = " body_text is required " )
2026-03-23 20:35:15 +01:00
try :
user_id = _get_user_id_from_request ( request )
except HTTPException :
user_id = 1
2026-03-05 08:41:59 +01:00
attachment_rows = [ ]
attachment_ids = list ( dict . fromkeys ( payload . attachment_file_ids or [ ] ) )
if attachment_ids :
placeholders = " , " . join ( [ " %s " ] * len ( attachment_ids ) )
attachment_query = f """
SELECT id , filename , content_type , size_bytes , stored_name
FROM sag_files
WHERE sag_id = % s AND id IN ( { placeholders } )
"""
attachment_rows = execute_query ( attachment_query , ( sag_id , * attachment_ids ) )
if len ( attachment_rows ) != len ( attachment_ids ) :
raise HTTPException ( status_code = 400 , detail = " One or more selected attachments were not found on this case " )
smtp_attachments = [ ]
for row in attachment_rows :
path = _resolve_attachment_path ( row [ " stored_name " ] )
if not path . exists ( ) :
raise HTTPException ( status_code = 404 , detail = f " Attachment file is missing on server: { row [ ' filename ' ] } " )
smtp_attachments . append ( {
" filename " : row [ " filename " ] ,
" content_type " : row . get ( " content_type " ) or " application/octet-stream " ,
" content " : path . read_bytes ( ) ,
" size " : row . get ( " size_bytes " ) or 0 ,
" file_path " : str ( path ) ,
} )
2026-03-18 13:49:33 +01:00
in_reply_to_header = None
references_header = None
2026-03-30 07:50:15 +02:00
selected_thread_key = None
2026-03-18 13:49:33 +01:00
if payload . thread_email_id :
thread_row = None
try :
thread_row = execute_query_single (
"""
2026-03-30 07:50:15 +02:00
SELECT id , message_id , in_reply_to , email_references , thread_key
2026-03-18 13:49:33 +01:00
FROM email_messages
WHERE id = % s
""" ,
( payload . thread_email_id , ) ,
)
except Exception :
# Backward compatibility for DBs without in_reply_to/email_references columns.
2026-03-30 07:50:15 +02:00
try :
thread_row = execute_query_single (
"""
SELECT id , message_id , thread_key
FROM email_messages
WHERE id = % s
""" ,
( payload . thread_email_id , ) ,
)
except Exception :
thread_row = execute_query_single (
"""
SELECT id , message_id
FROM email_messages
WHERE id = % s
""" ,
( payload . thread_email_id , ) ,
)
2026-03-18 13:49:33 +01:00
if thread_row :
base_message_id = str ( thread_row . get ( " message_id " ) or " " ) . strip ( )
if base_message_id and not base_message_id . startswith ( " < " ) :
base_message_id = f " < { base_message_id } > "
if base_message_id :
in_reply_to_header = base_message_id
existing_refs = str ( thread_row . get ( " email_references " ) or " " ) . strip ( )
if existing_refs :
references_header = f " { existing_refs } { base_message_id } " . strip ( )
else :
references_header = base_message_id
2026-03-30 07:50:15 +02:00
selected_thread_key = _derive_thread_key_for_outbound (
thread_row . get ( " thread_key " ) ,
thread_row . get ( " in_reply_to " ) ,
thread_row . get ( " email_references " ) ,
thread_row . get ( " message_id " ) ,
)
effective_payload_thread_key = payload . thread_key or selected_thread_key
2026-03-23 20:35:15 +01:00
provisional_thread_key = _derive_thread_key_for_outbound (
2026-03-30 07:50:15 +02:00
effective_payload_thread_key ,
2026-03-23 20:35:15 +01:00
in_reply_to_header ,
references_header ,
None ,
)
signature = _render_signature_for_case_email ( user_id , sag_id , provisional_thread_key )
body_text = _append_signature_to_body ( body_text , signature )
body_html = _append_signature_to_html ( payload . body_html , signature )
2026-03-05 08:41:59 +01:00
email_service = EmailService ( )
2026-03-23 20:35:15 +01:00
success , send_message , generated_message_id , provider_thread_key = await email_service . send_email_with_attachments (
2026-03-05 08:41:59 +01:00
to_addresses = to_addresses ,
subject = subject ,
body_text = body_text ,
2026-03-23 20:35:15 +01:00
body_html = body_html ,
2026-03-05 08:41:59 +01:00
cc = cc_addresses ,
bcc = bcc_addresses ,
2026-03-18 13:49:33 +01:00
in_reply_to = in_reply_to_header ,
references = references_header ,
2026-03-05 08:41:59 +01:00
attachments = smtp_attachments ,
respect_dry_run = False ,
)
if not success :
logger . error ( " ❌ Failed to send case email for case %s : %s " , sag_id , send_message )
2026-03-23 20:35:15 +01:00
failure_detail = str ( send_message or " Email send failed without provider detail " ) . strip ( )
# Keep error user-facing but concise; frontend will display this detail directly.
failure_detail = failure_detail . replace ( " ❌ " , " " ) . strip ( )
if failure_detail . lower ( ) == " failed to send email " :
failure_detail = " Email send failed (provider returned generic error; check Graph app permissions and API logs) "
raise HTTPException ( status_code = 500 , detail = f " { failure_detail } [diag:sag-send-v3] " )
2026-03-05 08:41:59 +01:00
sender_name = settings . EMAIL_SMTP_FROM_NAME or " BMC Hub "
sender_email = settings . EMAIL_SMTP_FROM_ADDRESS or " "
2026-03-30 07:50:15 +02:00
provider_thread_key_normalized = _normalize_message_id_token ( provider_thread_key )
# Keep replies in the existing case thread when we already know the target thread.
# Some providers may return a new conversation id even for replies.
derived_thread_key = _derive_thread_key_for_outbound (
effective_payload_thread_key ,
in_reply_to_header ,
references_header ,
None ,
)
thread_key = derived_thread_key or provider_thread_key_normalized
2026-03-23 20:35:15 +01:00
if not thread_key :
thread_key = _derive_thread_key_for_outbound (
2026-03-30 07:50:15 +02:00
effective_payload_thread_key ,
2026-03-23 20:35:15 +01:00
in_reply_to_header ,
references_header ,
generated_message_id ,
)
2026-03-05 08:41:59 +01:00
2026-03-18 13:49:33 +01:00
insert_result = None
2026-03-30 07:50:15 +02:00
insert_error = None
2026-03-18 13:49:33 +01:00
try :
insert_email_query = """
INSERT INTO email_messages (
message_id , subject , sender_email , sender_name ,
recipient_email , cc , body_text , body_html ,
2026-03-23 20:35:15 +01:00
in_reply_to , email_references , thread_key ,
2026-03-18 13:49:33 +01:00
received_date , folder , has_attachments , attachment_count ,
status , import_method , linked_case_id
)
2026-03-23 20:35:15 +01:00
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
ON CONFLICT ( message_id ) DO UPDATE
SET
subject = EXCLUDED . subject ,
sender_email = EXCLUDED . sender_email ,
sender_name = EXCLUDED . sender_name ,
recipient_email = EXCLUDED . recipient_email ,
cc = EXCLUDED . cc ,
body_text = EXCLUDED . body_text ,
body_html = EXCLUDED . body_html ,
in_reply_to = COALESCE ( EXCLUDED . in_reply_to , email_messages . in_reply_to ) ,
email_references = COALESCE ( EXCLUDED . email_references , email_messages . email_references ) ,
thread_key = COALESCE ( EXCLUDED . thread_key , email_messages . thread_key ) ,
folder = ' Sent ' ,
has_attachments = EXCLUDED . has_attachments ,
attachment_count = EXCLUDED . attachment_count ,
status = ' sent ' ,
import_method = COALESCE ( email_messages . import_method , EXCLUDED . import_method ) ,
linked_case_id = COALESCE ( email_messages . linked_case_id , EXCLUDED . linked_case_id ) ,
updated_at = CURRENT_TIMESTAMP
2026-03-18 13:49:33 +01:00
RETURNING id
"""
insert_result = execute_query (
insert_email_query ,
(
generated_message_id ,
subject ,
sender_email ,
sender_name ,
" , " . join ( to_addresses ) ,
" , " . join ( cc_addresses ) ,
body_text ,
2026-03-23 20:35:15 +01:00
body_html ,
2026-03-18 13:49:33 +01:00
in_reply_to_header ,
references_header ,
2026-03-23 20:35:15 +01:00
thread_key ,
2026-03-18 13:49:33 +01:00
datetime . now ( ) ,
" Sent " ,
bool ( smtp_attachments ) ,
len ( smtp_attachments ) ,
" sent " ,
2026-03-23 20:35:15 +01:00
" manual_upload " ,
2026-03-18 13:49:33 +01:00
sag_id ,
) ,
)
2026-03-30 07:50:15 +02:00
except Exception as e :
insert_error = e
logger . warning ( " ⚠️ Outbound email full insert fallback for case %s : %s " , sag_id , e )
if not insert_result :
try :
insert_email_query = """
INSERT INTO email_messages (
message_id , subject , sender_email , sender_name ,
recipient_email , cc , body_text , body_html ,
received_date , folder , has_attachments , attachment_count ,
status , import_method , linked_case_id
)
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
ON CONFLICT ( message_id ) DO UPDATE
SET
subject = EXCLUDED . subject ,
sender_email = EXCLUDED . sender_email ,
sender_name = EXCLUDED . sender_name ,
recipient_email = EXCLUDED . recipient_email ,
cc = EXCLUDED . cc ,
body_text = EXCLUDED . body_text ,
body_html = EXCLUDED . body_html ,
folder = ' Sent ' ,
has_attachments = EXCLUDED . has_attachments ,
attachment_count = EXCLUDED . attachment_count ,
status = ' sent ' ,
import_method = COALESCE ( email_messages . import_method , EXCLUDED . import_method ) ,
linked_case_id = COALESCE ( email_messages . linked_case_id , EXCLUDED . linked_case_id ) ,
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query (
insert_email_query ,
(
generated_message_id ,
subject ,
sender_email ,
sender_name ,
" , " . join ( to_addresses ) ,
" , " . join ( cc_addresses ) ,
body_text ,
body_html ,
datetime . now ( ) ,
" Sent " ,
bool ( smtp_attachments ) ,
len ( smtp_attachments ) ,
" sent " ,
" manual_upload " ,
sag_id ,
) ,
2026-03-18 13:49:33 +01:00
)
2026-03-30 07:50:15 +02:00
except Exception as e :
insert_error = e
logger . warning ( " ⚠️ Outbound email medium insert fallback for case %s : %s " , sag_id , e )
2026-03-05 08:41:59 +01:00
if not insert_result :
2026-03-30 07:50:15 +02:00
# Legacy-safe fallback: persist with minimal guaranteed columns.
try :
insert_email_query = """
INSERT INTO email_messages (
message_id , subject , sender_email , sender_name ,
recipient_email , cc , body_text ,
received_date , folder , has_attachments , attachment_count
)
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
ON CONFLICT ( message_id ) DO UPDATE
SET
subject = EXCLUDED . subject ,
sender_email = EXCLUDED . sender_email ,
sender_name = EXCLUDED . sender_name ,
recipient_email = EXCLUDED . recipient_email ,
cc = EXCLUDED . cc ,
body_text = EXCLUDED . body_text ,
folder = ' Sent ' ,
has_attachments = EXCLUDED . has_attachments ,
attachment_count = EXCLUDED . attachment_count ,
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query (
insert_email_query ,
(
generated_message_id ,
subject ,
sender_email ,
sender_name ,
" , " . join ( to_addresses ) ,
" , " . join ( cc_addresses ) ,
body_text ,
datetime . now ( ) ,
" Sent " ,
bool ( smtp_attachments ) ,
len ( smtp_attachments ) ,
) ,
)
except Exception as e :
insert_error = e
logger . error ( " ❌ Email sent but outbound log insert failed for case %s : %s " , sag_id , e )
2026-03-05 08:41:59 +01:00
2026-03-30 07:50:15 +02:00
email_id = None
if insert_result :
email_id = insert_result [ 0 ] [ " id " ]
else :
# Last chance recovery: if row exists already, continue with that id.
existing_email = execute_query_single (
" SELECT id FROM email_messages WHERE message_id = %s " ,
( generated_message_id , ) ,
) if generated_message_id else None
if existing_email :
email_id = existing_email [ " id " ]
else :
warning_detail = str ( insert_error or " email logging failed " )
logger . error ( " ❌ Email sent but no local email_id could be resolved for case %s " , sag_id )
return {
" status " : " sent " ,
" email_id " : None ,
" message " : send_message ,
" warning " : f " Email sent but could not be logged locally: { warning_detail } " ,
}
2026-03-05 08:41:59 +01:00
2026-03-30 07:50:15 +02:00
if smtp_attachments and email_id :
2026-03-05 08:41:59 +01:00
from psycopg2 import Binary
for attachment in smtp_attachments :
2026-03-30 07:50:15 +02:00
try :
execute_query (
"""
INSERT INTO email_attachments (
email_id , filename , content_type , size_bytes , file_path , content_data
)
VALUES ( % s , % s , % s , % s , % s , % s )
""" ,
(
email_id ,
attachment [ " filename " ] ,
attachment [ " content_type " ] ,
attachment . get ( " size " ) or len ( attachment [ " content " ] ) ,
attachment . get ( " file_path " ) ,
Binary ( attachment [ " content " ] ) ,
) ,
)
except Exception as e :
logger . warning (
" ⚠️ Could not persist outbound email attachment ' %s ' for email_id= %s : %s " ,
attachment . get ( " filename " ) ,
2026-03-05 08:41:59 +01:00
email_id ,
2026-03-30 07:50:15 +02:00
e ,
)
2026-03-05 08:41:59 +01:00
2026-03-30 07:50:15 +02:00
linked_ok = False
try :
execute_query (
"""
INSERT INTO sag_emails ( sag_id , email_id )
VALUES ( % s , % s )
ON CONFLICT DO NOTHING
""" ,
( sag_id , email_id ) ,
)
linked_ok = True
except Exception as e :
logger . warning ( " ⚠️ Could not insert sag_emails link for case= %s email_id= %s : %s " , sag_id , email_id , e )
if table_has_column ( " email_messages " , " linked_case_id " ) :
try :
execute_query (
" UPDATE email_messages SET linked_case_id = %s WHERE id = %s " ,
( sag_id , email_id ) ,
)
linked_ok = True
except Exception as nested_e :
logger . warning (
" ⚠️ Fallback linked_case_id update also failed for case= %s email_id= %s : %s " ,
sag_id ,
email_id ,
nested_e ,
)
2026-03-05 08:41:59 +01:00
2026-03-23 20:35:15 +01:00
sent_ts = datetime . now ( ) . isoformat ( )
outgoing_comment = (
" 📧 Udgående email \n "
f " Email-ID: { email_id } \n "
f " Fra: { sender_email or sender_name or ' BMC Hub ' } \n "
f " Til: { ' , ' . join ( to_addresses ) } \n "
f " Emne: { subject } \n "
f " Modtaget: { sent_ts } \n \n "
f " { body_text } "
)
2026-03-30 07:50:15 +02:00
comment_row = { }
try :
has_system_flag = table_has_column ( " sag_kommentarer " , " er_system_besked " )
attempted_errors = [ ]
has_comment_id_col = table_has_column ( " sag_kommentarer " , " kommentar_id " )
has_id_col = table_has_column ( " sag_kommentarer " , " id " )
# Prefer the variant that matches the live schema to avoid noisy SQL errors in logs.
if has_comment_id_col :
returning_variants = [ " kommentar_id " , " id AS kommentar_id " ]
elif has_id_col :
returning_variants = [ " id AS kommentar_id " , " kommentar_id " ]
else :
returning_variants = [ " kommentar_id " , " id AS kommentar_id " ]
if has_system_flag :
comment_variants = [
(
f """
INSERT INTO sag_kommentarer ( sag_id , forfatter , indhold , er_system_besked )
VALUES ( % s , % s , % s , % s )
RETURNING { returning_expr } , created_at
""" ,
( sag_id , ' Email Bot ' , outgoing_comment , True ) ,
)
for returning_expr in returning_variants
]
else :
comment_variants = [
(
f """
INSERT INTO sag_kommentarer ( sag_id , forfatter , indhold )
VALUES ( % s , % s , % s )
RETURNING { returning_expr } , created_at
""" ,
( sag_id , ' Email Bot ' , outgoing_comment ) ,
)
for returning_expr in returning_variants
]
for variant_query , variant_params in comment_variants :
try :
comment_row = execute_query_single ( variant_query , variant_params ) or { }
if comment_row :
break
except Exception as variant_error :
attempted_errors . append ( str ( variant_error ) )
if not comment_row and attempted_errors :
logger . warning (
" ⚠️ Outbound email sent but comment logging variants failed for case %s : %s " ,
sag_id ,
" | " . join ( attempted_errors ) ,
)
except Exception as e :
logger . warning ( " ⚠️ Outbound email sent but comment logging failed for case %s : %s " , sag_id , e )
2026-03-23 20:35:15 +01:00
comment_created_at = comment_row . get ( " created_at " )
if isinstance ( comment_created_at , datetime ) :
comment_created_at = comment_created_at . isoformat ( )
elif comment_created_at is None :
comment_created_at = sent_ts
2026-03-05 08:41:59 +01:00
logger . info (
2026-03-30 07:50:15 +02:00
" ✅ Outbound case email sent and linked (case= %s , email_id= %s , thread_email_id= %s , payload_thread_key= %s , stored_thread_key= %s , provider_thread_key= %s , recipients= %s ) " ,
2026-03-05 08:41:59 +01:00
sag_id ,
email_id ,
2026-03-18 13:49:33 +01:00
payload . thread_email_id ,
2026-03-30 07:50:15 +02:00
effective_payload_thread_key ,
thread_key ,
provider_thread_key_normalized ,
2026-03-05 08:41:59 +01:00
" , " . join ( to_addresses ) ,
)
return {
" status " : " sent " ,
" email_id " : email_id ,
" message " : send_message ,
2026-03-30 07:50:15 +02:00
" linked_to_case " : linked_ok ,
" warning " : None if linked_ok else " Email sent, but automatic case-thread link fallback was required " ,
2026-03-23 20:35:15 +01:00
" comment " : {
" kommentar_id " : comment_row . get ( " kommentar_id " ) ,
" forfatter " : " Email Bot " ,
" indhold " : outgoing_comment ,
" created_at " : comment_created_at ,
" er_system_besked " : True ,
} ,
2026-03-05 08:41:59 +01:00
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
# ============================================================================
# SOLUTIONS
# ============================================================================
from . import solutions
router . include_router ( solutions . router )