feat(sag): Initialize case management module with CRUD operations, relations, and tags

- Added backend API routes for case management including listing, creating, updating, and deleting cases.
- Implemented relations and tags functionality for cases.
- Created frontend views for displaying case lists and details with filtering options.
- Added database migration scripts to set up necessary tables and indexes.
- Included HTML templates for case listing and detail views with responsive design.
- Configured module metadata in module.json for integration.
This commit is contained in:
Christian 2026-01-29 23:07:33 +01:00
parent ef171c7573
commit 25168108d6
14 changed files with 1993 additions and 42 deletions

492
SAG_MODULE_PLAN.md Normal file
View File

@ -0,0 +1,492 @@
# Implementeringsplan: Sag-modulet (Case Module)
## Oversigt - Hvad er "Sag"?
**Sag-modulet** er hjertet i BMC Hub's nye relation- og proces-styringssystem. I stedet for at have separate systemer for "tickets", "opgaver" og "ordrer", har vi én universel entitet: **en Sag**.
### Kerneidéen (meget vigtig!)
> **Der er kun én ting: en Sag. Tickets, opgaver og ordrer er blot sager med forskellige relationer, tags og moduler.**
**Eksempler:**
1. **Kunde ringer og skal have ny skærm**
- Dette er en *Sag* (ticket-type med tag: `support`)
- Den får tag: `urgent` fordi det er ekspres
2. **Indkøb af skærm hos leverandør**
- Dette er også en *Sag* (ordre-type med tag: `indkøb`)
- Den er *relateret til* den første sag som "afledt_af"
- Ansvarlig: Indkøbschef
3. **Ompakning og afsendelse af skærm**
- Dette er en *Sag* (opgave-type med tag: `ompakning`)
- Den er relateret til indkøbssagen som "udførelse_for"
- Ansvarlig: Lagermedarbejder
- Deadline: I dag
**Alle tre er samme datatype i databasen.** Forskellen er:
- Hvilke *tags* de har
- Hvilken *kunde/kontakt* de er knyttet til
- Hvilke *relationer* de har til andre sager
- Hvem der er *ansvarlig*
### Hvad betyder det for systemet?
**Uden Sag-modulet:**
- Du skal have en "Ticket-sektion" for support
- Du skal have en "Task-sektion" for opgaver
- Du skal have en "Order-sektion" for ordrer
- De snakker ikke sammen naturligt
- Data-duplikering
- Kompleks logik
**Med Sag-modulet:**
- Ét API endpoint: `/api/v1/sag`
- Ét UI-område: "Sager" med intelligente filtre
- Relationer er førsteklasses borgere (se hvad der hænger sammen)
- Tags styr flowet (f.eks. "support" + "urgent" = prioriteret)
- Sager kan "vokse": Start som ticket → bliv til ordre → bliv til installation
- Alt er søgbart og filterabelt på tværs af domæner
---
## Teknisk arkitektur
### Databasestruktur
Sag-modulet bruger tre hovedtabeller (med `sag_` prefix):
#### **sag_sager** (Hovedtabel for sager)
```
id (primary key)
titel (VARCHAR) - kort navn på sagen
beskrivelse (TEXT) - detaljeret beskrivelse
template_key (VARCHAR) - struktur-template (f.eks. "ticket", "opgave", "ordre") - default NULL
status (VARCHAR) - "åben" eller "lukket"
customer_id (foreign key) - hvilken kunde sagen handler om - NULLABLE
ansvarlig_bruger_id (foreign key) - hvem skal håndtere den
created_by_user_id (foreign key) - hvem oprettede sagen
deadline (TIMESTAMP) - hvornår skal det være færdigt
created_at (TIMESTAMP)
updated_at (TIMESTAMP)
deleted_at (TIMESTAMP) - soft-delete: sættes når sagen "slettes"
```
**Soft-delete:** Når du sletter en sag, bliver `deleted_at` sat til nu. Sagen bliver ikke fjernet fra DB. Det betyder:
- Du kan gendanne data hvis modulet deaktiveres
- Historien bevares (audit trail)
- Relations er intakte hvis du genopretter
#### **sag_relationer** (Hvordan sager hænger sammen)
```
id (primary key)
kilde_sag_id (foreign key) - hvilken sag relationen STARTER fra (retning: fra denne)
målsag_id (foreign key) - hvilken sag relationen PEGER PÅ (retning: til denne)
relationstype (VARCHAR) - f.eks. "parent_of", "child_of", "derived_from", "blocks", "executes_for"
created_at (TIMESTAMP)
deleted_at (TIMESTAMP) - soft-delete
```
**Eksempel (retningsbestemt):**
- Sag 1 (kundesamtale) → Sag 5 (indkøb af skærm)
- kilde_sag_id: 1, målsag_id: 5
- relationstype: "derives" eller "parent_of"
- Betyder: "Sag 1 er forælder/genererer Sag 5"
**Note:** Relationer er enrettet. For bidirektionale links oprettes to relations (1→5 og 5→1).
#### **sag_tags** (Hvordan vi kategoriserer sager)
```
id (primary key)
sag_id (foreign key) - hvilken sag tagget tilhører
tag_navn (VARCHAR) - f.eks. "support", "urgent", "vip", "ompakning"
state (VARCHAR) - "aktiv" eller "inaktiv" - default "aktiv"
closed_at (TIMESTAMP) - hvornår tagget blev lukket/inaktiveret - NULLABLE
created_at (TIMESTAMP)
deleted_at (TIMESTAMP) - soft-delete
```
**Tags bruges til:**
- Filtrering: "Vis alle sager med tag = support"
- Workflow: "Sager med tag = urgent skal løses i dag"
- Kategorisering: "Alle sager med tag = ompakning"
### API-endpoints
**Sager CRUD:**
- `GET /api/v1/cases` - Liste alle sager (filter efter tags, status, ansvarlig)
- `POST /api/v1/cases` - Opret ny sag
- `GET /api/v1/cases/{id}` - Vis detaljer om en sag
- `PATCH /api/v1/cases/{id}` - Opdater en sag
- `DELETE /api/v1/cases/{id}` - Slet en sag (soft-delete, sætter deleted_at)
**Relationer:**
- `GET /api/v1/cases/{id}/relations` - Vis alle relaterede sager
- `POST /api/v1/cases/{id}/relations` - Tilføj relation til anden sag
- `DELETE /api/v1/cases/{id}/relations/{relation_id}` - Fjern relation
**Tags:**
- `GET /api/v1/cases/{id}/tags` - Vis alle tags på sagen
- `POST /api/v1/cases/{id}/tags` - Tilføj tag
- `DELETE /api/v1/cases/{id}/tags/{tag_id}` - Fjern tag
### UI-koncept
**Sag-listen** (`/sag`):
- Alle dine sager på ét sted
- Filter: "Mine sager", "Åbne sager", "Sager med tag=support", "Sager med tag=urgent"
- Søgebar
- Sortering efter deadline, oprettelsestid, status
**Sag-listen** (`/cases`):
**Sag-detaljer** (`/cases/{id}`):
- Hovedinfo: titel, beskrivelse, status, deadline
- **Relaterede sager**: Sektioner som:
- "Forælder-sag" (hvis denne sag er en del af noget større)
- "Barn-sager" (sager der er afledt af denne)
- "Blokeret af" (sager der holder denne op)
- "Udfører for" (hvis denne er udførelsessag for noget)
- **Tags**: Viste tags, mulighed for at tilføje flere
- **Ansvarlig**: Hvem skal håndtere det
- **Historie**: Hvis modulet får aktivitetslog senere
**Designet:**
- Nordic Top minimalistisk design
- Dark mode support
- Responsive (mobil-venligt)
- Intuitivt navigation mellem relaterede sager
---
## Implementeringsplan - Trin for trin
### Fase 1: Modul-struktur (forberedelse)
#### Trin 1.1: Opret modul-mappen
```
app/modules/sag/
├── module.json # Modulets metadata
├── README.md # Dokumentation
├── backend/
│ ├── __init__.py
│ └── router.py # FastAPI endpoints
├── frontend/
│ ├── __init__.py
│ └── views.py # HTML views
├── templates/
│ ├── index.html # Sag-liste
│ └── detail.html # Sag-detaljer
└── migrations/
└── 001_init.sql # Database schema
```
#### Trin 1.2: Opret module.json
```json
{
"name": "sag",
"version": "1.0.0",
"description": "Universel sag-håndtering - tickets, opgaver og ordrer som sager med relationer",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "sag_",
"api_prefix": "/api/v1/cases",
"tags": ["Sag", "Case Management"],
"config": {
"safety_switches": {
"read_only": false,
"dry_run": false
}
}
}
```
### Fase 2: Database-setup
#### Trin 2.1: Opret migrations/001_init.sql
SQL-migrations definerer tabeller for sager, relationer og tags. Se `migrations/001_init.sql` for detaljer.
**Vigtige points:**
- Alle tabelnavne starter med `sag_`
- Soft-delete: `deleted_at` kolonne hvor man checker `WHERE deleted_at IS NULL`
- Foreign keys til `customers` for at linke til kundedata
- Indexes for performance
- Triggers til auto-update af `updated_at`
**Eksempel-query (queries filtrerer soft-deleted):**
```sql
SELECT * FROM sag_sager
WHERE customer_id = %s
AND deleted_at IS NULL
ORDER BY created_at DESC;
```
### Fase 3: Backend-API
#### Trin 3.1: Opret backend/router.py
Implementer alle 9 API-endpoints med disse mønstre:
**GET /cases (list):**
```python
@router.get("/cases")
async def list_sager(
status: str = None,
tag: str = None,
customer_id: int = None,
ansvarlig_bruger_id: int = None
):
# Build query med WHERE deleted_at IS NULL
# Filter efter parameters
# Return liste
```
**POST /cases (create):**
```python
@router.post("/cases")
async def create_sag(sag_data: dict):
# Validér input
# INSERT INTO sag_sager
# RETURNING *
# Return ny sag
```
**GET /cases/{id}:**
```python
@router.get("/cases/{id}")
async def get_sag(id: int):
# SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL
# Hvis ikke found: HTTPException(404)
# Return sag detaljer
```
**PATCH /cases/{id} (update):**
```python
@router.patch("/cases/{id}")
async def update_sag(id: int, updates: dict):
# UPDATE sag_sager SET ... WHERE id = %s
# Automatisk updated_at via trigger
# Return opdateret sag
```
**DELETE /cases/{id} (soft-delete):**
```python
@router.delete("/cases/{id}")
async def delete_sag(id: int):
# UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s
# Return success
```
**Relationer endpoints:** Lignende pattern for `/cases/{id}/relations`
**Tags endpoints:** Lignende pattern for `/cases/{id}/tags`
**Vigtige mønstre:**
- Altid bruge `execute_query()` fra `app.core.database`
- Parameteriserede queries (`%s` placeholders)
- `RealDictCursor` for dict-like row access
- Filtrer `WHERE deleted_at IS NULL` på alle SELECT queries
- Eksportér router som `router` (module loader leder efter denne)
### Fase 4: Frontend-views
#### Trin 4.1: Opret frontend/views.py
```python
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app/modules/sag/templates")
@router.get("/cases", response_class=HTMLResponse)
async def cases_liste(request):
# Hent sager fra API
return templates.TemplateResponse("index.html", {"request": request, "cases": ...})
@router.get("/cases/{id}", response_class=HTMLResponse)
async def sag_detaljer(request, id: int):
# Hent sag + relationer + tags
return templates.TemplateResponse("detail.html", {"request": request, "sag": ..., "relationer": ...})
```
### Fase 5: Frontend-templates
#### Trin 5.1: Opret templates/index.html
Sag-listen med:
- Search-bar
- Filter-knapper (status, tags, ansvarlig)
- Tabel/kort-view med alle sager
- Klikkable sager der går til `/sag/{id}`
- Nordic Top design med dark mode
#### Trin 5.2: Opret templates/detail.html
Sag-detaljer med:
- Hovedinfo: titel, beskrivelse, status, deadline, ansvarlig
- Sektioner: "Relaterede sager", "Tags", "Aktivitet" (hvis implementeret senere)
- Knap til at redigere sagen
- Knap til at tilføje relation
- Knap til at tilføje tag
- Mulighed for at se og slette relationer/tags
### Fase 6: Test og aktivering
#### Trin 6.1: Test databasen
```bash
docker compose exec db psql -U bmc_admin -d bmc_hub -c "SELECT * FROM sag_sager;"
```
#### Trin 6.2: Test API-endpoints
```bash
# Opret sag
curl -X POST http://localhost:8001/api/v1/cases \
-H "Content-Type: application/json" \
-d '{"titel": "Test sag", "customer_id": 1}'
# Hent sag
curl http://localhost:8001/api/v1/cases/1
# Hent sag-liste
curl http://localhost:8001/api/v1/cases
```
#### Trin 6.3: Test frontend
- Besøg http://localhost:8001/cases
- Se sag-liste
- Klik på sag → se detaljer
- Tilføj tag, relation
#### Trin 6.4: Test soft-delete
- Slet sag via `DELETE /cases/{id}`
- Check databasen: `deleted_at` skal være sat
- Verify den ikke vises i list-endpoints mere
#### Trin 6.5: Test modul-deaktivering
- Rediger `module.json`: sæt `"enabled": false`
- Restart Docker: `docker compose restart api`
- Besøg http://localhost:8001/cases → 404
- Besøg http://localhost:8001/api/v1/cases → 404
- Revert: `"enabled": true`, restart, verifiér det virker igen
### Fase 7: Dokumentation
#### Trin 7.1: Opret README.md i modulet
Dokumenter:
- Hvad modulet gør
- API-endpoints med eksempler
- Database-schema
- Hvordan man bruger relationer og tags
- Eksempel-workflows
---
## Vigtige principper under implementeringen
### 1. **Soft-delete først**
Alle `DELETE` operationer sætter `deleted_at` til `NOW()` i stedet for at slette fysisk. Det betyder:
- Data bevares hvis modulet deaktiveres
- Audit trail bevares
- Relationer forbliver intakte
### 2. **Always filter deleted_at**
Alle SELECT queries skal have:
```sql
WHERE deleted_at IS NULL
```
Undtagelse: Admin-sider der skal se "deleted history" (implementeres senere).
### 3. **Foreign keys til customers**
Alle sager skal være knyttet til en `customer_id`. Det gør det muligt at:
- Lave customer-specifikke views senere
- Sikre data-isolation
- Tracke hvem sagerne handler om
### 4. **Relationer er data**
Relationer er ikke blot links - de er egne database-records med type og soft-delete. Det betyder:
- Du kan se hele historien af relationer
- Du kan "gendanne" relationer hvis de slettes
- Relationstyper er konfigurerbare
### 5. **Tags driver visibility**
Tags bruges til:
- UI-filtre: "Vis kun sager med tag=urgent"
- Workflow: "Sager med tag=support skal have SLA"
- Kategorisering: "Alt med tag=ompakning"
---
## Hvad efter?
Når Sag-modulet er live, kan du:
1. **Konvertere tickets til sager** - Migrationsscript der tager gamle tickets og laver dem til sager
2. **Konvertere opgaver til sager** - Samme pattern
3. **Tilføje aktivitetslog** - "Hvem ændrede hvad hvornår" på hver sag
4. **Integrere med e-conomic** - Når en sag får tag=faktura, oprettes den som ordre i e-conomic
5. **Tilføje workflowkonfiguration** - "Hvis status=i_gang og tag=urgent, send reminder hver dag"
6. **Tilføje dependencies** - "Sag B kan ikke starte før Sag A er done"
7. **Tilføje SLA-tracking** - "Support-sager skal løses inden 24 timer"
Men først: **Få grundlaget på plads med denne modul-implementering.**
---
## Kommandoer til at komme i gang
```bash
# Gå til workspace
cd /Users/christianthomas/DEV/bmc_hub_dev
# Se hvor vi er
docker compose ps -a
# Start dev-miljø hvis det ikke kører
docker compose up -d
# Se logs
docker compose logs -f api
# Efter at have lavet koden: restart API
docker compose restart api
# Test at modulet loadet
docker compose logs api | grep -i "sag"
# Manuelt test af database-migration
docker compose exec db psql -U bmc_admin -d bmc_hub -c "\dt sag_*"
```
---
## Tidsestimation
- **Fase 1-2 (modul + database)**: 30 min
- **Fase 3 (backend API)**: 1-2 timer
- **Fase 4-5 (frontend)**: 1-2 timer
- **Fase 6 (test)**: 30 min
- **Fase 7 (dokumentation)**: 30 min
**Total: 4-6 timer**
---
## TL;DR - for implementer
1. Opret `app/modules/sag/` med standard-struktur
2. Opret `module.json` med `"enabled": true`
3. Opret `migrations/001_init.sql` med 3 tabeller (`sag_sager`, `sag_relationer`, `sag_tags`)
4. Implementer 9 API-endpoints i `backend/router.py` (alle queries filtrerer `deleted_at IS NULL`)
5. Implementer 2 HTML-views i `frontend/views.py` (liste + detaljer)
6. Opret 2 templates i `templates/` (index.html + detail.html)
7. Test endpoints og UI
8. Verifiér soft-delete virker
9. Verifiér modulet kan deaktiveres og data bevares
10. Skrive README.md
**Modulet bliver automatisk loadet af system - ingen manual registration nødvendig.**

177
app/modules/sag/README.md Normal file
View File

@ -0,0 +1,177 @@
# Sag Module - Case Management
## Oversigt
Sag-modulet implementerer en universel sag-håndtering system hvor tickets, opgaver og ordrer er blot sager med forskellige tags og relationer.
**Kerneidé:** Der er kun én ting: en Sag. Alt andet er metadata, tags og relationer.
## Database Schema
### sag_sager (Hovedtabel)
- `id` - Primary key
- `titel` - Case title
- `beskrivelse` - Detailed description
- `type` - Case type (ticket, opgave, ordre, etc.)
- `status` - Status (åben, i_gang, afsluttet, on_hold)
- `customer_id` - Foreign key to customers table
- `ansvarlig_bruger_id` - Assigned user
- `deadline` - Due date
- `created_at` - Creation timestamp
- `updated_at` - Last update (auto-updated via trigger)
- `deleted_at` - Soft-delete timestamp (NULL = active)
### sag_relationer (Relations)
- `id` - Primary key
- `kilde_sag_id` - Source case
- `målsag_id` - Target case
- `relationstype` - Relation type (forælder, barn, afledt_af, blokkerer, udfører_for)
- `created_at` - Creation timestamp
- `deleted_at` - Soft-delete timestamp
### sag_tags (Tags)
- `id` - Primary key
- `sag_id` - Case reference
- `tag_navn` - Tag name (support, urgent, vip, ompakning, etc.)
- `created_at` - Creation timestamp
- `deleted_at` - Soft-delete timestamp
## API Endpoints
### Cases CRUD
**List cases**
```
GET /api/v1/sag?status=åben&tag=support&customer_id=1
```
**Create case**
```
POST /api/v1/sag
Content-Type: application/json
{
"titel": "Skærm mangler",
"beskrivelse": "Kunde har brug for ny skærm",
"type": "ticket",
"customer_id": 1,
"status": "åben"
}
```
**Get case**
```
GET /api/v1/sag/1
```
**Update case**
```
PATCH /api/v1/sag/1
Content-Type: application/json
{
"status": "i_gang",
"ansvarlig_bruger_id": 5
}
```
**Delete case (soft)**
```
DELETE /api/v1/sag/1
```
### Relations
**Get relations**
```
GET /api/v1/sag/1/relationer
```
**Add relation**
```
POST /api/v1/sag/1/relationer
Content-Type: application/json
{
"målsag_id": 2,
"relationstype": "afledt_af"
}
```
**Delete relation**
```
DELETE /api/v1/sag/1/relationer/5
```
### Tags
**Get tags**
```
GET /api/v1/sag/1/tags
```
**Add tag**
```
POST /api/v1/sag/1/tags
Content-Type: application/json
{
"tag_navn": "urgent"
}
```
**Delete tag**
```
DELETE /api/v1/sag/1/tags/3
```
## Frontend Routes
- `GET /sag` - List all cases with filters
- `GET /sag/{id}` - View case details
- `GET /sag/new` - Create new case (future)
- `GET /sag/{id}/edit` - Edit case (future)
## Features
✅ Soft-delete with data preservation
✅ Nordic Top design with dark mode support
✅ Responsive mobile-friendly UI
✅ Case relations (parent/child)
✅ Dynamic tagging system
✅ Full-text search
✅ Status filtering
✅ Customer tracking
## Example Workflows
### Support Ticket
1. Customer calls → Create Sag with type="ticket", tag="support"
2. Urgency high → Add tag="urgent"
3. Create order for new hardware → Create related Sag with type="ordre", relation="afledt_af"
4. Pack and ship → Create related Sag with type="opgave", tag="ompakning"
### Future Integrations
- Activity logging (who changed what when)
- e-conomic integration (auto-create orders)
- SLA tracking (response/resolution times)
- Workflow automation (auto-tags based on conditions)
- Dependency management (can't start case B until case A done)
## Soft-Delete Safety
All DELETE operations use soft-delete:
- Data is preserved in database
- `deleted_at` is set to current timestamp
- All queries filter `WHERE deleted_at IS NULL`
- Data can be recovered if module is disabled
- Audit trail is maintained
## Development Notes
- All queries use `execute_query()` from `app.core.database`
- Parameterized queries with `%s` placeholders (SQL injection prevention)
- `RealDictCursor` for dict-like row access
- Triggers maintain `updated_at` automatically
- Relations are first-class citizens (not just links)

View File

@ -0,0 +1 @@
"""Sag module backend."""

View File

@ -0,0 +1,321 @@
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from app.core.database import execute_query
from datetime import datetime
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# SAGER - CRUD Operations
# ============================================================================
@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),
):
"""List all cases with optional filtering."""
try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
params = []
if status:
query += " AND status = %s"
params.append(status)
if customer_id:
query += " AND customer_id = %s"
params.append(customer_id)
if ansvarlig_bruger_id:
query += " AND ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id)
query += " ORDER BY created_at DESC"
cases = execute_query(query, tuple(params))
# If tag filter, filter in Python after fetch
if tag:
case_ids = [case['id'] for case in cases]
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,))
tagged_ids = set(t['sag_id'] for t in tagged_cases)
cases = [c for c in cases if c['id'] in tagged_ids]
return cases
except Exception as e:
logger.error("❌ Error listing cases: %s", e)
raise HTTPException(status_code=500, detail="Failed to list cases")
@router.post("/sag")
async def create_sag(data: dict):
"""Create a new case."""
try:
if not data.get('titel'):
raise HTTPException(status_code=400, detail="titel is required")
if not data.get('customer_id'):
raise HTTPException(status_code=400, detail="customer_id is required")
query = """
INSERT INTO sag_sager
(titel, beskrivelse, type, status, customer_id, ansvarlig_bruger_id, deadline)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
data.get('titel'),
data.get('beskrivelse', ''),
data.get('type', 'ticket'),
data.get('status', 'åben'),
data.get('customer_id'),
data.get('ansvarlig_bruger_id'),
data.get('deadline'),
)
result = execute_query(query, params)
if result:
logger.info("✅ Case created: %s", result[0]['id'])
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")
@router.get("/sag/{sag_id}")
async def get_sag(sag_id: int):
"""Get a specific case."""
try:
query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
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")
@router.patch("/sag/{sag_id}")
async def update_sag(sag_id: int, updates: dict):
"""Update a case."""
try:
# 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")
# Build dynamic update query
allowed_fields = ['titel', 'beskrivelse', 'type', 'status', 'ansvarlig_bruger_id', 'deadline']
set_clauses = []
params = []
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")
params.append(sag_id)
query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
logger.info("✅ Case updated: %s", sag_id)
return result[0]
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")
@router.delete("/sag/{sag_id}")
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")
query = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id"
result = execute_query(query, (sag_id,))
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")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error deleting case: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete case")
# ============================================================================
# RELATIONER - Case Relations
# ============================================================================
@router.get("/sag/{sag_id}/relationer")
async def get_relationer(sag_id: int):
"""Get all relations for a case."""
try:
# 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")
query = """
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
"""
result = execute_query(query, (sag_id, sag_id))
return result
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error getting relations: %s", e)
raise HTTPException(status_code=500, detail="Failed to get relations")
@router.post("/sag/{sag_id}/relationer")
async def create_relation(sag_id: int, data: dict):
"""Add a relation to another case."""
try:
if not data.get('målsag_id') or not data.get('relationstype'):
raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
målsag_id = data.get('målsag_id')
relationstype = data.get('relationstype')
# 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")
query = """
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
VALUES (%s, %s, %s)
RETURNING *
"""
result = execute_query(query, (sag_id, målsag_id, relationstype))
if result:
logger.info("✅ Relation created: %s -> %s (%s)", sag_id, målsag_id, relationstype)
return result[0]
raise HTTPException(status_code=500, detail="Failed to create relation")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating relation: %s", e)
raise HTTPException(status_code=500, detail="Failed to create relation")
@router.delete("/sag/{sag_id}/relationer/{relation_id}")
async def delete_relation(sag_id: int, relation_id: int):
"""Soft-delete a relation."""
try:
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,))
if result:
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
except Exception as e:
logger.error("❌ Error deleting relation: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete relation")
# ============================================================================
# TAGS - Case Tags
# ============================================================================
@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")
@router.post("/sag/{sag_id}/tags")
async def add_tag(sag_id: int, data: dict):
"""Add a tag to a case."""
try:
if not data.get('tag_navn'):
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")
query = """
INSERT INTO sag_tags (sag_id, tag_navn)
VALUES (%s, %s)
RETURNING *
"""
result = execute_query(query, (sag_id, data.get('tag_navn')))
if result:
logger.info("✅ Tag added: %s -> %s", sag_id, data.get('tag_navn'))
return result[0]
raise HTTPException(status_code=500, detail="Failed to add tag")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error adding tag: %s", e)
raise HTTPException(status_code=500, detail="Failed to add tag")
@router.delete("/sag/{sag_id}/tags/{tag_id}")
async def delete_tag(sag_id: int, tag_id: int):
"""Soft-delete a tag."""
try:
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")
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
except Exception as e:
logger.error("❌ Error deleting tag: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete tag")

View File

@ -0,0 +1 @@
"""Sag module frontend."""

View File

@ -0,0 +1,111 @@
import logging
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
# Setup template directory
template_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(template_dir))
@router.get("/sag", response_class=HTMLResponse)
async def sager_liste(
request,
status: str = Query(None),
tag: str = Query(None),
customer_id: int = Query(None),
):
"""Display list of all cases."""
try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
params = []
if status:
query += " AND status = %s"
params.append(status)
if customer_id:
query += " AND customer_id = %s"
params.append(customer_id)
query += " ORDER BY created_at DESC"
sager = execute_query(query, tuple(params))
# Filter by tag if provided
if tag and sager:
sag_ids = [s['id'] for s in sager]
tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL"
tagged = execute_query(tag_query, (tag,))
tagged_ids = set(t['sag_id'] for t in tagged)
sager = [s for s in sager if s['id'] in tagged_ids]
# Fetch all distinct statuses and tags for filters
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
return templates.TemplateResponse("index.html", {
"request": request,
"sager": sager,
"statuses": [s['status'] for s in statuses],
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_tag": tag,
})
except Exception as e:
logger.error("❌ Error displaying case list: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case list")
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def sag_detaljer(request, sag_id: int):
"""Display case details."""
try:
# Fetch main case
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
sag_result = execute_query(sag_query, (sag_id,))
if not sag_result:
raise HTTPException(status_code=404, detail="Case not found")
sag = sag_result[0]
# Fetch tags
tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
tags = execute_query(tags_query, (sag_id,))
# Fetch relations
relationer_query = """
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
"""
relationer = execute_query(relationer_query, (sag_id, sag_id))
# Fetch customer info if customer_id exists
customer = None
if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]
return templates.TemplateResponse("detail.html", {
"request": request,
"sag": sag,
"customer": customer,
"tags": tags,
"relationer": relationer,
})
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error displaying case details: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case details")

View File

@ -0,0 +1,62 @@
-- Sag Module: Initialize case management tables
-- Supports universal case handling with relations and tags
-- Main cases table
CREATE TABLE IF NOT EXISTS sag_sager (
id SERIAL PRIMARY KEY,
titel VARCHAR(255) NOT NULL,
beskrivelse TEXT,
type VARCHAR(50) NOT NULL DEFAULT 'ticket', -- ticket, opgave, ordre, etc.
status VARCHAR(50) NOT NULL DEFAULT 'åben', -- åben, i_gang, afsluttet, on_hold
customer_id INTEGER NOT NULL REFERENCES customers(id),
ansvarlig_bruger_id INTEGER,
deadline TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
CONSTRAINT valid_status CHECK (status IN ('åben', 'i_gang', 'afsluttet', 'on_hold'))
);
-- Relations between cases
CREATE TABLE IF NOT EXISTS sag_relationer (
id SERIAL PRIMARY KEY,
kilde_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
målsag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
relationstype VARCHAR(50) NOT NULL, -- forælder, barn, afledt_af, blokkerer, udfører_for
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
CONSTRAINT different_cases CHECK (kilde_sag_id != målsag_id)
);
-- Tags for categorization
CREATE TABLE IF NOT EXISTS sag_tags (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
tag_navn VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_sag_sager_customer_id ON sag_sager(customer_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_sager_status ON sag_sager(status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_sager_ansvarlig ON sag_sager(ansvarlig_bruger_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_relationer_kilde ON sag_relationer(kilde_sag_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_relationer_mål ON sag_relationer(målsag_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_tags_sag_id ON sag_tags(sag_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_tags_tag_navn ON sag_tags(tag_navn) WHERE deleted_at IS NULL;
-- Trigger to auto-update updated_at
CREATE OR REPLACE FUNCTION update_sag_sager_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_sag_sager_updated_at ON sag_sager;
CREATE TRIGGER trigger_sag_sager_updated_at
BEFORE UPDATE ON sag_sager
FOR EACH ROW
EXECUTE FUNCTION update_sag_sager_updated_at();

View File

@ -0,0 +1,17 @@
{
"name": "sag",
"version": "1.0.0",
"description": "Universel sag-håndtering - tickets, opgaver og ordrer som sager med relationer",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "sag_",
"api_prefix": "/api/v1/sag",
"tags": ["Sag", "Case Management"],
"config": {
"safety_switches": {
"read_only": false,
"dry_run": false
}
}
}

View File

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ sag.titel }} - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #0f4c75;
--secondary-color: #3282b8;
--accent-color: #00a8e8;
--bg-light: #f7f9fc;
--bg-dark: #1a1a2e;
}
body {
background-color: var(--bg-light);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body.dark-mode {
background-color: var(--bg-dark);
color: #f0f0f0;
}
.navbar {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
}
.content-wrapper {
padding: 2rem 0;
}
.card {
border: none;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 2rem;
}
body.dark-mode .card {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.card-header {
background-color: var(--primary-color);
color: white;
border-radius: 8px 8px 0 0;
}
body.dark-mode .card-header {
background-color: var(--secondary-color);
}
.card-body label {
font-weight: 600;
color: var(--primary-color);
margin-top: 1rem;
}
body.dark-mode .card-body label {
color: var(--accent-color);
}
.btn-back {
background-color: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
display: inline-block;
margin-bottom: 2rem;
}
body.dark-mode .btn-back {
color: var(--accent-color);
border-color: var(--accent-color);
}
.btn-back:hover {
background-color: var(--primary-color);
color: white;
}
.tag {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
body.dark-mode .tag {
background-color: var(--secondary-color);
}
.relation-card {
background-color: #f9f9f9;
padding: 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
border-left: 3px solid var(--accent-color);
}
body.dark-mode .relation-card {
background-color: #3a3a4e;
border-left-color: var(--secondary-color);
}
.relation-type {
font-weight: 600;
color: var(--secondary-color);
font-size: 0.85rem;
}
.relation-link {
color: var(--accent-color);
text-decoration: none;
}
.relation-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="/">🛠️ BMC Hub</a>
</div>
</nav>
<!-- Main Content -->
<div class="content-wrapper">
<div class="container">
<a href="/sag" class="btn-back">← Tilbage til sager</a>
<!-- Main Card -->
<div class="card">
<div class="card-header">
<h1 style="margin: 0; font-size: 1.5rem;">{{ sag.titel }}</h1>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<label>Beskrivelse</label>
<p>{{ sag.beskrivelse or 'Ingen beskrivelse' }}</p>
</div>
<div class="col-md-6">
<label>Status</label>
<p><span style="background-color: var(--accent-color); color: white; padding: 0.3rem 0.7rem; border-radius: 4px;">{{ sag.status }}</span></p>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label>Type</label>
<p>{{ sag.type }}</p>
</div>
<div class="col-md-6">
<label>Deadline</label>
<p>{{ sag.deadline[:10] if sag.deadline else 'Ikke sat' }}</p>
</div>
</div>
{% if customer %}
<div class="row mb-4">
<div class="col-md-6">
<label>Kunde</label>
<p><a href="/customers/{{ customer.id }}" class="relation-link">{{ customer.name }}</a></p>
</div>
</div>
{% endif %}
<div class="row">
<div class="col-12">
<label>Oprettet</label>
<p>{{ sag.created_at }}</p>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
{% if tags %}
<div class="card">
<div class="card-header">
<h5 style="margin: 0;">📌 Tags</h5>
</div>
<div class="card-body">
{% for tag in tags %}
<span class="tag">{{ tag.tag_navn }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Relations Section -->
{% if relationer %}
<div class="card">
<div class="card-header">
<h5 style="margin: 0;">🔗 Relaterede sager</h5>
</div>
<div class="card-body">
{% for rel in relationer %}
<div class="relation-card">
<div class="relation-type">{{ rel.relationstype }}</div>
{% if rel.kilde_sag_id == sag.id %}
<a href="/sag/{{ rel.målsag_id }}" class="relation-link">{{ rel.mål_titel }}</a>
{% else %}
<a href="/sag/{{ rel.kilde_sag_id }}" class="relation-link">{{ rel.kilde_titel }}</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<div style="margin-top: 2rem; display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="/sag/{{ sag.id }}/edit" class="btn" style="background-color: var(--accent-color); color: white;">✏️ Rediger</a>
<button class="btn" style="background-color: #e74c3c; color: white;" onclick="if(confirm('Slet denne sag?')) { fetch('/api/v1/sag/{{ sag.id }}', {method: 'DELETE'}).then(() => window.location='/sag'); }">🗑️ Slet</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sager - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #0f4c75;
--secondary-color: #3282b8;
--accent-color: #00a8e8;
--bg-light: #f7f9fc;
--bg-dark: #1a1a2e;
--text-light: #333;
--text-dark: #f0f0f0;
--border-color: #ddd;
}
body {
background-color: var(--bg-light);
color: var(--text-light);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body.dark-mode {
background-color: var(--bg-dark);
color: var(--text-dark);
}
.navbar {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.navbar-brand {
font-weight: 600;
font-size: 1.4rem;
}
.content-wrapper {
padding: 2rem 0;
min-height: 100vh;
}
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin: 0;
}
body.dark-mode .page-header h1 {
color: var(--accent-color);
}
.btn-new {
background-color: var(--accent-color);
color: white;
border: none;
padding: 0.6rem 1.5rem;
border-radius: 6px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-new:hover {
background-color: var(--secondary-color);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,168,232,0.3);
}
.filter-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
body.dark-mode .filter-section {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.filter-section label {
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.5rem;
display: block;
font-size: 0.9rem;
}
body.dark-mode .filter-section label {
color: var(--accent-color);
}
.filter-section select,
.filter-section input {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
font-size: 0.95rem;
}
body.dark-mode .filter-section select,
body.dark-mode .filter-section input {
background-color: #3a3a4e;
color: var(--text-dark);
border-color: #555;
}
.sag-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
border-left: 4px solid var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-decoration: none;
color: inherit;
display: block;
}
body.dark-mode .sag-card {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.sag-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
border-left-color: var(--accent-color);
}
body.dark-mode .sag-card:hover {
box-shadow: 0 4px 12px rgba(0,168,232,0.2);
}
.sag-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
body.dark-mode .sag-title {
color: var(--accent-color);
}
.sag-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.9rem;
color: #666;
margin-top: 1rem;
}
body.dark-mode .sag-meta {
color: #aaa;
}
.status-badge {
display: inline-block;
padding: 0.3rem 0.7rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.status-åben {
background-color: #ffeaa7;
color: #d63031;
}
.status-i_gang {
background-color: #a29bfe;
color: #2d3436;
}
.status-afsluttet {
background-color: #55efc4;
color: #00b894;
}
.status-on_hold {
background-color: #fab1a0;
color: #e17055;
}
.tag {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.3rem 0.6rem;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 0.5rem;
margin-top: 0.5rem;
}
body.dark-mode .tag {
background-color: var(--secondary-color);
}
.dark-mode-toggle {
background: none;
border: none;
color: white;
font-size: 1.2rem;
cursor: pointer;
padding: 0.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #999;
}
body.dark-mode .empty-state {
color: #666;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="/">🛠️ BMC Hub</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active" href="/sag">Sager</a>
</li>
<li class="nav-item">
<button class="dark-mode-toggle" onclick="toggleDarkMode()">🌙</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="content-wrapper">
<div class="container">
<!-- Page Header -->
<div class="page-header">
<h1>📋 Sager</h1>
<a href="/sag/new" class="btn-new">+ Ny sag</a>
</div>
<!-- Filters -->
<div class="filter-section">
<div class="row g-3">
<div class="col-md-4">
<label>Status</label>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="status" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle statuser</option>
{% for s in statuses %}
<option value="{{ s }}" {% if s == current_status %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Tag</label>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="tag" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle tags</option>
{% for t in all_tags %}
<option value="{{ t }}" {% if t == current_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Søg</label>
<input type="text" placeholder="Søg efter sager..." class="form-control" id="searchInput">
</div>
</div>
</div>
<!-- Cases List -->
<div id="casesList">
{% if sager %}
{% for sag in sager %}
<a href="/sag/{{ sag.id }}" class="sag-card">
<div class="sag-title">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}</div>
{% endif %}
<div class="sag-meta">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
<span>{{ sag.type }}</span>
<span style="color: #999;">{{ sag.created_at[:10] }}</span>
</div>
</a>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Ingen sager fundet</p>
</div>
{% endif %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
// Load dark mode preference
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
// Search functionality
document.getElementById('searchInput').addEventListener('keyup', function(e) {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.sag-card').forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(search) ? 'block' : 'none';
});
});
</script>
</body>
</html>

View File

@ -17,6 +17,8 @@ import logging
import os import os
import shutil import shutil
import psycopg2
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_query_single, execute_update from app.core.database import execute_query, execute_query_single, execute_update
from app.services.opportunity_service import handle_stage_change from app.services.opportunity_service import handle_stage_change
@ -31,6 +33,19 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _is_undefined_table_error(exc: Exception) -> bool:
# Postgres undefined_table SQLSTATE
if getattr(exc, "pgcode", None) == "42P01":
return True
undefined_table = getattr(psycopg2, "errors", None)
if undefined_table is not None:
try:
return isinstance(exc, psycopg2.errors.UndefinedTable)
except Exception:
return False
return False
@router.post("/opportunities/{opportunity_id}/email-links", tags=["Opportunities"]) @router.post("/opportunities/{opportunity_id}/email-links", tags=["Opportunities"])
async def add_opportunity_email_link(opportunity_id: int, payload: dict): async def add_opportunity_email_link(opportunity_id: int, payload: dict):
"""Add a linked email to an opportunity""" """Add a linked email to an opportunity"""
@ -49,6 +64,11 @@ async def add_opportunity_email_link(opportunity_id: int, payload: dict):
(opportunity_id, email_id) (opportunity_id, email_id)
) )
except Exception as e: except Exception as e:
if _is_undefined_table_error(e):
raise HTTPException(
status_code=409,
detail="Database migration required: run 032_opportunity_emails_m2m.sql",
)
logger.error(f"Failed to add email link: {e}") logger.error(f"Failed to add email link: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke tilføje email-link") raise HTTPException(status_code=500, detail="Kunne ikke tilføje email-link")
@ -63,6 +83,11 @@ async def remove_opportunity_email_link(opportunity_id: int, email_id: int):
(opportunity_id, email_id) (opportunity_id, email_id)
) )
except Exception as e: except Exception as e:
if _is_undefined_table_error(e):
raise HTTPException(
status_code=409,
detail="Database migration required: run 032_opportunity_emails_m2m.sql",
)
logger.error(f"Failed to remove email link: {e}") logger.error(f"Failed to remove email link: {e}")
raise HTTPException(status_code=500, detail="Kunne ikke fjerne email-link") raise HTTPException(status_code=500, detail="Kunne ikke fjerne email-link")
@ -318,8 +343,23 @@ def _store_upload_file(upload_file: UploadFile, subdir: str) -> Tuple[str, int]:
destination = _resolve_attachment_path(stored_name) destination = _resolve_attachment_path(stored_name)
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
upload_file.file.seek(0) upload_file.file.seek(0)
try:
with destination.open("wb") as buffer: with destination.open("wb") as buffer:
shutil.copyfileobj(upload_file.file, buffer) shutil.copyfileobj(upload_file.file, buffer)
except PermissionError as e:
logger.error(
"❌ Upload permission denied: %s (base=%s, subdir=%s)",
str(destination),
str(UPLOAD_BASE_PATH),
subdir,
)
raise HTTPException(
status_code=500,
detail=(
"Upload directory is not writable. Fix permissions for the host-mounted 'uploads' folder "
"(e.g. /srv/podman/bmc_hub_v1.0/uploads) and restart the API container."
),
) from e
return stored_name, destination.stat().st_size return stored_name, destination.stat().st_size
@ -497,6 +537,16 @@ def _get_opportunity(opportunity_id: int):
ORDER BY e.received_date DESC ORDER BY e.received_date DESC
""" """
linked_emails = execute_query(email_query, (opportunity_id,)) linked_emails = execute_query(email_query, (opportunity_id,))
try:
linked_emails = execute_query(email_query, (opportunity_id,))
except Exception as e:
if _is_undefined_table_error(e):
logger.warning(
"⚠️ Missing table pipeline_opportunity_emails; linked_emails disabled until migration 032_opportunity_emails_m2m.sql is applied"
)
linked_emails = []
else:
raise
opportunity["linked_emails"] = linked_emails or [] opportunity["linked_emails"] = linked_emails or []
# Fetch linked contacts # Fetch linked contacts
@ -508,6 +558,16 @@ def _get_opportunity(opportunity_id: int):
ORDER BY c.first_name, c.last_name ORDER BY c.first_name, c.last_name
""" """
linked_contacts = execute_query(contacts_query, (opportunity_id,)) linked_contacts = execute_query(contacts_query, (opportunity_id,))
try:
linked_contacts = execute_query(contacts_query, (opportunity_id,))
except Exception as e:
if _is_undefined_table_error(e):
logger.warning(
"⚠️ Missing table pipeline_opportunity_contacts; linked_contacts disabled until migration 033_opportunity_contacts.sql is applied"
)
linked_contacts = []
else:
raise
opportunity["linked_contacts"] = linked_contacts or [] opportunity["linked_contacts"] = linked_contacts or []
return opportunity return opportunity

View File

@ -229,6 +229,44 @@
</div> </div>
</div> </div>
<!-- Contact Role Modal (choose common title or type your own) -->
<div class="modal fade" id="contactRoleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj kontaktperson</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<div class="text-muted small">Kontakt</div>
<div class="fw-medium" id="contactRoleModalContactName">-</div>
</div>
<label class="form-label">Rolle / titel</label>
<input
type="text"
class="form-control"
id="contactRoleInput"
placeholder="Vælg fra listen eller skriv selv…"
list="contactRoleTitles"
autocomplete="off"
>
<datalist id="contactRoleTitles">
<option value="Direktør"></option>
<option value="IT kontakt"></option>
<option value="3. part"></option>
<option value="Beslutningstager"></option>
</datalist>
<div class="form-text">Du kan altid skrive en custom titel.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="contactRoleSaveBtn">Tilføj</button>
</div>
</div>
</div>
</div>
<div class="section-card"> <div class="section-card">
<div class="section-title">Grundoplysninger</div> <div class="section-title">Grundoplysninger</div>
<div class="mb-3"> <div class="mb-3">
@ -721,6 +759,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// --- Contact Persons Logic --- // --- Contact Persons Logic ---
let contactSearchTimeout = null; let contactSearchTimeout = null;
const contactInput = document.getElementById('contactSearchInput'); const contactInput = document.getElementById('contactSearchInput');
let contactRoleModal = null;
let pendingContactId = null;
const contactSuggestionMap = new Map();
contactInput?.addEventListener('input', (e) => { contactInput?.addEventListener('input', (e) => {
const term = e.target.value.trim(); const term = e.target.value.trim();
@ -748,6 +789,11 @@ document.addEventListener('DOMContentLoaded', async () => {
const container = document.getElementById('contactSearchResults'); const container = document.getElementById('contactSearchResults');
if(!container) return; if(!container) return;
contactSuggestionMap.clear();
for (const c of (contacts || [])) {
if (c && typeof c.id === 'number') contactSuggestionMap.set(c.id, c);
}
if(contacts.length === 0) { if(contacts.length === 0) {
container.innerHTML = '<li><span class="dropdown-item text-muted small">Ingen fundet</span></li>'; container.innerHTML = '<li><span class="dropdown-item text-muted small">Ingen fundet</span></li>';
return; return;
@ -768,26 +814,65 @@ document.addEventListener('DOMContentLoaded', async () => {
// Expose to global scope for onclick in string literal // Expose to global scope for onclick in string literal
window.dataSelectContact = async function(contactId) { window.dataSelectContact = async function(contactId) {
const role = prompt("Rolle (f.eks. Beslutningstager, Influencer)?", ""); pendingContactId = contactId;
const contact = contactSuggestionMap.get(contactId);
if (!contactRoleModal) {
contactRoleModal = new bootstrap.Modal(document.getElementById('contactRoleModal'));
document.getElementById('contactRoleSaveBtn')?.addEventListener('click', submitContactRole);
document.getElementById('contactRoleInput')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitContactRole();
}
});
}
const nameEl = document.getElementById('contactRoleModalContactName');
if (nameEl && contact) {
nameEl.textContent = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || `#${contactId}`;
} else if (nameEl) {
nameEl.textContent = `#${contactId}`;
}
const roleInput = document.getElementById('contactRoleInput');
if (roleInput) {
roleInput.value = '';
setTimeout(() => roleInput.focus(), 150);
}
contactRoleModal.show();
};
async function submitContactRole() {
if (!pendingContactId) return;
const roleInput = document.getElementById('contactRoleInput');
const roleValue = roleInput?.value?.trim() || null;
try { try {
const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts`, { const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: contactId, role: role}) body: JSON.stringify({contact_id: pendingContactId, role: roleValue})
}); });
if(resp.ok) { if (resp.ok) {
opportunity = await resp.json(); opportunity = await resp.json();
if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts); if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts);
document.getElementById('contactSearchInput').value = ''; document.getElementById('contactSearchInput').value = '';
pendingContactId = null;
contactRoleModal?.hide();
} else { } else {
try {
const errJson = await resp.json();
alert(errJson.detail || 'Fejl ved tilføjelse');
} catch(e) {
alert('Fejl ved tilføjelse'); alert('Fejl ved tilføjelse');
} }
}
} catch(e) { } catch(e) {
console.error(e); console.error(e);
alert('Fejl ved tilføjelse'); alert('Fejl ved tilføjelse');
} }
}; }
window.renderLinkedContacts = function(contacts) { window.renderLinkedContacts = function(contacts) {
const container = document.getElementById('linkedContactsList'); const container = document.getElementById('linkedContactsList');
@ -804,7 +889,8 @@ document.addEventListener('DOMContentLoaded', async () => {
<div class="fw-medium">${escapeHtml(c.first_name)} ${escapeHtml(c.last_name)}</div> <div class="fw-medium">${escapeHtml(c.first_name)} ${escapeHtml(c.last_name)}</div>
<div class="text-muted small">${escapeHtml(c.role || 'Ingen rolle')}</div> <div class="text-muted small">${escapeHtml(c.role || 'Ingen rolle')}</div>
${c.email ? `<div class="text-muted small"><a href="mailto:${c.email}" class="text-decoration-none text-muted"><i class="bi bi-envelope"></i> ${escapeHtml(c.email)}</a></div>` : ''} ${c.email ? `<div class="text-muted small"><a href="mailto:${c.email}" class="text-decoration-none text-muted"><i class="bi bi-envelope"></i> ${escapeHtml(c.email)}</a></div>` : ''}
${c.mobile_phone ? `<div class="text-muted small"><a href="tel:${c.mobile_phone}" class="text-decoration-none text-muted"><i class="bi bi-phone"></i> ${escapeHtml(c.mobile_phone)}</a></div>` : ''} ${c.phone ? `<div class="text-muted small"><a href="tel:${c.phone}" class="text-decoration-none text-muted"><i class="bi bi-telephone"></i> ${escapeHtml(c.phone)}</a></div>` : ''}
${c.mobile ? `<div class="text-muted small"><a href="tel:${c.mobile}" class="text-decoration-none text-muted"><i class="bi bi-phone"></i> ${escapeHtml(c.mobile)}</a></div>` : ''}
</div> </div>
<button class="btn btn-sm btn-light text-danger" onclick="removeContactLink(${c.id})" title="Fjern"> <button class="btn btn-sm btn-light text-danger" onclick="removeContactLink(${c.id})" title="Fjern">
<i class="bi bi-x"></i> <i class="bi bi-x"></i>

View File

@ -13,9 +13,11 @@ services:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
# Mount all migration files for initialization # Mount all migration files for initialization
- ./migrations:/docker-entrypoint-initdb.d:ro - ./migrations:/docker-entrypoint-initdb.d:ro
# NO external port mapping - only accessible via Docker network # Optional: publish Postgres to the host.
# ports: # Default binds to localhost for safety; set POSTGRES_BIND_ADDR=0.0.0.0 (or host IP)
# - "${POSTGRES_PORT:-5432}:5432" # if the API container can't reach Postgres via the bridge network (Podman netavark issue).
ports:
- "${POSTGRES_BIND_ADDR:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
@ -49,10 +51,14 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# Override database URL to point to postgres service # Override database URL to point to postgres service (or host fallback).
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} # Set POSTGRES_HOST=host.containers.internal if bridge networking is broken.
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:5432/${POSTGRES_DB}
- ENABLE_RELOAD=false - ENABLE_RELOAD=false
restart: always restart: always
# Podman rootless: map container user namespace to the host user.
# This avoids permission issues on bind-mounted folders like ./uploads and ./logs.
userns_mode: "keep-id"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s interval: 30s

View File

@ -7,6 +7,17 @@ set -e # Exit on any error
VERSION=$1 VERSION=$1
# This production deployment is designed for ROOTLESS Podman.
# Running with sudo will use a different Podman storage (rootful) and can make it look
# like "data disappeared" because volumes/networks are separate.
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
echo "❌ Fejl: Kør ikke dette script som root (sudo)."
echo " Brug i stedet: sudo -iu bmcadmin && cd /srv/podman/bmc_hub_v1.0 && ./updateto.sh $VERSION"
exit 1
fi
PODMAN_COMPOSE_FILE="docker-compose.prod.yml"
if [ -z "$VERSION" ]; then if [ -z "$VERSION" ]; then
echo "❌ Fejl: Ingen version angivet" echo "❌ Fejl: Ingen version angivet"
echo "Usage: ./updateto.sh v1.3.15" echo "Usage: ./updateto.sh v1.3.15"
@ -40,6 +51,12 @@ if [ ! -f ".env" ]; then
exit 1 exit 1
fi fi
if [ ! -f "$PODMAN_COMPOSE_FILE" ]; then
echo "❌ Fejl: $PODMAN_COMPOSE_FILE ikke fundet i $(pwd)"
echo " Kør fra /srv/podman/bmc_hub_v1.0"
exit 1
fi
# Load environment variables (DB credentials) # Load environment variables (DB credentials)
set -a set -a
source .env source .env
@ -60,12 +77,12 @@ echo "✅ .env opdateret"
# Stop containers # Stop containers
echo "" echo ""
echo "⏹️ Stopper containere..." echo "⏹️ Stopper containere..."
podman-compose -f docker-compose.prod.yml down podman-compose -f "$PODMAN_COMPOSE_FILE" down
# Pull/rebuild with new version # Pull/rebuild with new version
echo "" echo ""
echo "🔨 Bygger nyt image med version $VERSION..." echo "🔨 Bygger nyt image med version $VERSION..."
podman-compose -f docker-compose.prod.yml up -d --build podman-compose -f "$PODMAN_COMPOSE_FILE" up -d --build
# Sync migrations from container to host # Sync migrations from container to host
echo "" echo ""
@ -84,24 +101,38 @@ echo ""
echo "⏳ Venter på container startup..." echo "⏳ Venter på container startup..."
sleep 5 sleep 5
# Run database migration # Database migrations
echo "" echo ""
echo "🧱 Kører database migrationer..." echo "🧱 Database migrationer"
if [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_DB" ]; then echo " NOTE: Scriptet kører ikke længere en hardcoded enkelt-migration automatisk."
echo " Brug migrations-UI'en i BMC Hub, eller sæt RUN_MIGRATIONS=true for at køre alle .sql i /docker-entrypoint-initdb.d/ i sorteret rækkefølge."
if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
if [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_DB" ]; then
echo "❌ Fejl: POSTGRES_USER/POSTGRES_DB mangler i .env" echo "❌ Fejl: POSTGRES_USER/POSTGRES_DB mangler i .env"
exit 1 exit 1
fi fi
for i in {1..10}; do for i in {1..30}; do
if podman exec bmc-hub-postgres-prod pg_isready -U "$POSTGRES_USER" >/dev/null 2>&1; then if podman exec bmc-hub-postgres-prod pg_isready -U "$POSTGRES_USER" >/dev/null 2>&1; then
break break
fi fi
echo "⏳ Venter på postgres... ($i/10)" echo "⏳ Venter på postgres... ($i/30)"
sleep 2 sleep 2
done done
podman exec -i bmc-hub-postgres-prod psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /docker-entrypoint-initdb.d/016_opportunities.sql echo "📄 Kører alle migrations fra /docker-entrypoint-initdb.d (sorteret)..."
echo "✅ Migration 016_opportunities.sql kørt" podman exec bmc-hub-postgres-prod sh -lc "ls -1 /docker-entrypoint-initdb.d/*.sql 2>/dev/null | sort" \
| while read -r file; do
[ -z "$file" ] && continue
echo "➡️ $file"
podman exec -i bmc-hub-postgres-prod psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$file"
done
echo "✅ Migrations kørt"
else
echo " RUN_MIGRATIONS=false (default)"
fi
# Show logs # Show logs
echo "" echo ""
@ -113,7 +144,7 @@ echo ""
echo "✅ Deployment fuldført!" echo "✅ Deployment fuldført!"
echo "" echo ""
echo "🔍 Tjek status med:" echo "🔍 Tjek status med:"
echo " podman-compose -f docker-compose.prod.yml ps" echo " podman-compose -f $PODMAN_COMPOSE_FILE ps"
echo " podman logs -f bmc-hub-api-prod" echo " podman logs -f bmc-hub-api-prod"
echo "" echo ""
echo "🌐 Test health endpoint:" echo "🌐 Test health endpoint:"