feat: Add reminder system for sag cases with user preferences and notification channels
- Implemented user notification preferences table for managing default notification settings. - Created sag_reminders table to define reminder rules with various trigger types and recipient configurations. - Developed sag_reminder_queue for processing reminder events triggered by status changes or scheduled times. - Added sag_reminder_logs to track reminder notifications and user interactions. - Introduced frontend notification system using Bootstrap 5 Toast for displaying reminders. - Created email template for sending reminders with case details and action links. - Implemented rate limiting for user notifications to prevent spamming. - Added triggers and functions for automatic updates and reminder processing.
This commit is contained in:
parent
b06ff693df
commit
b43e9f797d
459
# GitHub Copilot Instructions - BMC Webs.prompt.md
Normal file
459
# GitHub Copilot Instructions - BMC Webs.prompt.md
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
# GitHub Copilot Instructions - BMC Webshop (Frontend)
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
BMC Webshop er en kunde-styret webshop løsning, hvor **BMC Hub** ejer indholdet, **API Gateway** (`apigateway.bmcnetworks.dk`) styrer logikken, og **Webshoppen** (dette projekt) kun viser og indsamler input.
|
||||||
|
|
||||||
|
**Tech Stack**: React/Next.js/Vue.js (vælg én), TypeScript, Tailwind CSS eller Bootstrap 5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-Lags Arkitektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TIER 1: BMC HUB (Admin System) │
|
||||||
|
│ - Administrerer webshop-opsætning │
|
||||||
|
│ - Pusher data til Gateway │
|
||||||
|
│ - Poller Gateway for nye ordrer │
|
||||||
|
│ https://hub.bmcnetworks.dk │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
▼ (Push config)
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TIER 2: API GATEWAY (Forretningslogik + Database) │
|
||||||
|
│ - Modtager og gemmer webshop-config fra Hub │
|
||||||
|
│ - Ejer PostgreSQL database med produkter, priser, ordrer│
|
||||||
|
│ - Håndterer email/OTP login │
|
||||||
|
│ - Beregner priser og filtrerer varer │
|
||||||
|
│ - Leverer sikre API'er til Webshoppen │
|
||||||
|
│ https://apigateway.bmcnetworks.dk │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
▲ (API calls)
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TIER 3: WEBSHOP (Dette projekt - Kun Frontend) │
|
||||||
|
│ - Viser logo, tekster, produkter, priser │
|
||||||
|
│ - Shopping cart (kun i frontend state) │
|
||||||
|
│ - Sender ordre som payload til Gateway │
|
||||||
|
│ - INGEN forretningslogik eller datapersistering │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webshoppens Ansvar
|
||||||
|
|
||||||
|
### ✅ Hvad Webshoppen GØR
|
||||||
|
- Viser kundens logo, header-tekst, intro-tekst (fra Gateway)
|
||||||
|
- Viser produktkatalog med navn, beskrivelse, pris (fra Gateway)
|
||||||
|
- Samler kurv i browser state (localStorage/React state)
|
||||||
|
- Sender ordre til Gateway ved checkout
|
||||||
|
- Email/OTP login flow (kalder Gateway's auth-endpoint)
|
||||||
|
|
||||||
|
### ❌ Hvad Webshoppen IKKE GØR
|
||||||
|
- Gemmer INGEN data (hverken kurv, produkter, eller ordrer)
|
||||||
|
- Beregner INGEN priser eller avance
|
||||||
|
- Håndterer INGEN produkt-filtrering (Gateway leverer klar liste)
|
||||||
|
- Snakker IKKE direkte med Hub eller e-conomic
|
||||||
|
- Håndterer IKKE betalingsgateway (Gateway's ansvar)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Gateway Kontrakt
|
||||||
|
|
||||||
|
Base URL: `https://apigateway.bmcnetworks.dk`
|
||||||
|
|
||||||
|
### 1. Login med Email + Engangskode
|
||||||
|
|
||||||
|
**Step 1: Anmod om engangskode**
|
||||||
|
```http
|
||||||
|
POST /webshop/auth/request-code
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "kunde@firma.dk"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Engangskode sendt til kunde@firma.dk"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verificer kode og få JWT token**
|
||||||
|
```http
|
||||||
|
POST /webshop/auth/verify-code
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "kunde@firma.dk",
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"customer_id": 42,
|
||||||
|
"expires_at": "2026-01-13T15:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Hent Webshop Context (Komplet Webshop-Data)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /webshop/{customer_id}/context
|
||||||
|
Authorization: Bearer {jwt_token}
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
{
|
||||||
|
"customer_id": 42,
|
||||||
|
"company_name": "Advokatfirma A/S",
|
||||||
|
"config_version": "2026-01-13T12:00:00Z",
|
||||||
|
"branding": {
|
||||||
|
"logo_url": "https://apigateway.bmcnetworks.dk/assets/logos/42.png",
|
||||||
|
"header_text": "Velkommen til vores webshop",
|
||||||
|
"intro_text": "Bestil nemt og hurtigt direkte her.",
|
||||||
|
"primary_color": "#0f4c75",
|
||||||
|
"accent_color": "#3282b8"
|
||||||
|
},
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"ean": "5711045071324",
|
||||||
|
"product_number": "FIRE-001",
|
||||||
|
"name": "Cisco Firewall ASA 5506-X",
|
||||||
|
"description": "Next-generation firewall med 8 porte",
|
||||||
|
"unit": "stk",
|
||||||
|
"base_price": 8500.00,
|
||||||
|
"calculated_price": 9350.00,
|
||||||
|
"margin_percent": 10.0,
|
||||||
|
"currency": "DKK",
|
||||||
|
"stock_available": true,
|
||||||
|
"category": "Network Security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"ean": "5704174801740",
|
||||||
|
"product_number": "SW-024",
|
||||||
|
"name": "TP-Link 24-Port Gigabit Switch",
|
||||||
|
"description": "Managed switch med VLAN support",
|
||||||
|
"unit": "stk",
|
||||||
|
"base_price": 2100.00,
|
||||||
|
"calculated_price": 2310.00,
|
||||||
|
"margin_percent": 10.0,
|
||||||
|
"currency": "DKK",
|
||||||
|
"stock_available": true,
|
||||||
|
"category": "Switches"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allowed_payment_methods": ["invoice", "card"],
|
||||||
|
"min_order_amount": 500.00,
|
||||||
|
"shipping_cost": 0.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Opret Ordre
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /webshop/orders
|
||||||
|
Authorization: Bearer {jwt_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"customer_id": 42,
|
||||||
|
"order_items": [
|
||||||
|
{
|
||||||
|
"product_id": 101,
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 9350.00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product_id": 102,
|
||||||
|
"quantity": 5,
|
||||||
|
"unit_price": 2310.00
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shipping_address": {
|
||||||
|
"company_name": "Advokatfirma A/S",
|
||||||
|
"street": "Hovedgaden 1",
|
||||||
|
"postal_code": "1000",
|
||||||
|
"city": "København K",
|
||||||
|
"country": "DK"
|
||||||
|
},
|
||||||
|
"delivery_note": "Levering til bagsiden, ring på døren",
|
||||||
|
"total_amount": 30250.00
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 201:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"order_id": "ORD-2026-00123",
|
||||||
|
"status": "pending",
|
||||||
|
"total_amount": 30250.00,
|
||||||
|
"created_at": "2026-01-13T14:30:00Z",
|
||||||
|
"message": "Ordre modtaget. Du vil modtage en bekræftelse på email."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Hent Mine Ordrer (Optional)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /webshop/orders?customer_id=42
|
||||||
|
Authorization: Bearer {jwt_token}
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"order_id": "ORD-2026-00123",
|
||||||
|
"created_at": "2026-01-13T14:30:00Z",
|
||||||
|
"status": "pending",
|
||||||
|
"total_amount": 30250.00,
|
||||||
|
"item_count": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Krav
|
||||||
|
|
||||||
|
### Mandatory Features
|
||||||
|
|
||||||
|
1. **Responsive Design**
|
||||||
|
- Mobile-first approach
|
||||||
|
- Breakpoints: 576px (mobile), 768px (tablet), 992px (desktop)
|
||||||
|
- Brug CSS Grid/Flexbox eller framework grid system
|
||||||
|
|
||||||
|
2. **Dark Mode Support**
|
||||||
|
- Toggle mellem light/dark theme
|
||||||
|
- Gem præference i localStorage
|
||||||
|
- CSS Variables for farver
|
||||||
|
|
||||||
|
3. **Shopping Cart**
|
||||||
|
- Gem kurv i localStorage (persist ved page reload)
|
||||||
|
- Vis antal varer i header badge
|
||||||
|
- Real-time opdatering af total pris
|
||||||
|
- Slet/rediger varer i kurv
|
||||||
|
|
||||||
|
4. **Login Flow**
|
||||||
|
- Email input → Send kode
|
||||||
|
- Vis countdown timer (5 minutter)
|
||||||
|
- Verificer kode → Få JWT token
|
||||||
|
- Gem token i localStorage
|
||||||
|
- Auto-logout ved token expiry
|
||||||
|
|
||||||
|
5. **Product Catalog**
|
||||||
|
- Vis produkter i grid layout (3-4 kolonner på desktop)
|
||||||
|
- Filtrer produkter efter kategori (hvis Gateway leverer kategorier)
|
||||||
|
- Søgning i produktnavn/beskrivelse
|
||||||
|
- "Tilføj til kurv" knap med antal-vælger
|
||||||
|
|
||||||
|
6. **Checkout Flow**
|
||||||
|
- Vis kurv-oversigt
|
||||||
|
- Leveringsadresse (kan være pre-udfyldt fra Gateway)
|
||||||
|
- Leveringsnotat (textarea)
|
||||||
|
- "Bekræft ordre" knap
|
||||||
|
- Loading state under ordre-oprettelse
|
||||||
|
- Success/error feedback
|
||||||
|
|
||||||
|
### Design Guidelines
|
||||||
|
|
||||||
|
**Stil**: Minimalistisk, clean, "Nordic" æstetik (inspireret af BMC Hub's Nordic Top design)
|
||||||
|
|
||||||
|
**Farver** (kan overskrives af Gateway's branding config):
|
||||||
|
- Primary: `#0f4c75` (Deep Blue)
|
||||||
|
- Accent: `#3282b8` (Bright Blue)
|
||||||
|
- Success: `#27ae60`
|
||||||
|
- Warning: `#f39c12`
|
||||||
|
- Danger: `#e74c3c`
|
||||||
|
|
||||||
|
**Typografi**:
|
||||||
|
- Font: System font stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ...`)
|
||||||
|
- Headings: 500-600 weight
|
||||||
|
- Body: 400 weight
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
- Cards med subtil shadow/border
|
||||||
|
- Buttons med hover states
|
||||||
|
- Input fields med focus outline
|
||||||
|
- Loading spinners (ikke lange tekst-beskeder)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Local Storage Keys
|
||||||
|
```javascript
|
||||||
|
// Authentication
|
||||||
|
webshop_jwt_token // JWT token fra Gateway
|
||||||
|
webshop_customer_id // Customer ID
|
||||||
|
webshop_token_expires_at // ISO timestamp
|
||||||
|
|
||||||
|
// Shopping Cart
|
||||||
|
webshop_cart // JSON array af cart items
|
||||||
|
webshop_theme // "light" eller "dark"
|
||||||
|
|
||||||
|
// Cache (optional)
|
||||||
|
webshop_context // Cached webshop context (TTL: 5 minutter)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cart Item Format
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
product_id: 101,
|
||||||
|
ean: "5711045071324",
|
||||||
|
name: "Cisco Firewall ASA 5506-X",
|
||||||
|
unit_price: 9350.00,
|
||||||
|
quantity: 2,
|
||||||
|
total: 18700.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Gateway API Errors
|
||||||
|
```javascript
|
||||||
|
// Eksempel på error response fra Gateway
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "invalid_code",
|
||||||
|
"message": "Ugyldig engangskode. Prøv igen."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Codes** (forventet fra Gateway):
|
||||||
|
- `invalid_email` - Email ikke fundet eller ikke whitelisted
|
||||||
|
- `invalid_code` - Forkert engangskode
|
||||||
|
- `code_expired` - Engangskode udløbet (>5 min)
|
||||||
|
- `token_expired` - JWT token udløbet
|
||||||
|
- `unauthorized` - Manglende/ugyldig Authorization header
|
||||||
|
- `product_not_found` - Produkt ID findes ikke
|
||||||
|
- `min_order_not_met` - Ordre under minimum beløb
|
||||||
|
- `out_of_stock` - Produkt ikke på lager
|
||||||
|
|
||||||
|
**Handling**:
|
||||||
|
- Vis brugervenlig fejlbesked i UI (ikke tekniske detaljer)
|
||||||
|
- Log tekniske fejl til console (kun i development)
|
||||||
|
- Redirect til login ved `token_expired` eller `unauthorized`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
1. **HTTPS Only**
|
||||||
|
- Al kommunikation med Gateway over HTTPS
|
||||||
|
- Ingen hardcoded credentials
|
||||||
|
|
||||||
|
2. **JWT Token**
|
||||||
|
- Gem i localStorage (ikke cookie)
|
||||||
|
- Send i `Authorization: Bearer {token}` header
|
||||||
|
- Check expiry før hver API call
|
||||||
|
- Auto-logout ved expiry
|
||||||
|
|
||||||
|
3. **Input Validation**
|
||||||
|
- Validér email format (client-side)
|
||||||
|
- Validér antal > 0 ved "Tilføj til kurv"
|
||||||
|
- Validér leveringsadresse udfyldt ved checkout
|
||||||
|
- Sanitize input (brug library som DOMPurify hvis nødvendigt)
|
||||||
|
|
||||||
|
4. **CORS**
|
||||||
|
- Gateway skal have `Access-Control-Allow-Origin` header
|
||||||
|
- Webshoppen kalder altid Gateway (ikke Hub direkte)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# .env.production
|
||||||
|
NEXT_PUBLIC_API_GATEWAY_URL=https://apigateway.bmcnetworks.dk
|
||||||
|
NEXT_PUBLIC_WEBSHOP_NAME="BMC Networks Webshop"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
|
||||||
|
# Docker (optional)
|
||||||
|
docker build -t bmc-webshop .
|
||||||
|
docker run -p 3000:3000 bmc-webshop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static Hosting (Anbefalet)
|
||||||
|
- Vercel, Netlify, eller Cloudflare Pages
|
||||||
|
- Deploy fra Git repository
|
||||||
|
- Automatisk HTTPS og CDN
|
||||||
|
- Environment variables i hosting provider UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
- [ ] Login med email/OTP virker
|
||||||
|
- [ ] Token gemmes og bruges i efterfølgende API calls
|
||||||
|
- [ ] Webshop context hentes og vises korrekt
|
||||||
|
- [ ] Produkter vises i grid
|
||||||
|
- [ ] "Tilføj til kurv" opdaterer cart badge
|
||||||
|
- [ ] Cart viser korrekte varer og total pris
|
||||||
|
- [ ] Checkout sender korrekt payload til Gateway
|
||||||
|
- [ ] Success message vises ved succesfuld ordre
|
||||||
|
- [ ] Error handling virker (test med ugyldig kode, udløbet token)
|
||||||
|
- [ ] Dark mode toggle virker
|
||||||
|
- [ ] Responsive design på mobil/tablet/desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls to Avoid
|
||||||
|
|
||||||
|
1. **Gem IKKE data i Webshoppen** - alt kommer fra Gateway
|
||||||
|
2. **Beregn IKKE priser selv** - Gateway leverer `calculated_price`
|
||||||
|
3. **Snakker IKKE direkte med Hub** - kun via Gateway
|
||||||
|
4. **Gem IKKE kurv i database** - kun localStorage
|
||||||
|
5. **Hardcode IKKE customer_id** - hent fra JWT token
|
||||||
|
6. **Valider IKKE produkter selv** - Gateway filtrerer allerede
|
||||||
|
7. **Implementer IKKE betalingsgateway** - Gateway's ansvar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
```
|
||||||
|
POST /webshop/auth/request-code # Anmod engangskode
|
||||||
|
POST /webshop/auth/verify-code # Verificer kode → JWT
|
||||||
|
GET /webshop/{customer_id}/context # Hent webshop data
|
||||||
|
POST /webshop/orders # Opret ordre
|
||||||
|
GET /webshop/orders?customer_id={id} # Hent mine ordrer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typical Flow
|
||||||
|
```
|
||||||
|
1. User indtaster email → POST /auth/request-code
|
||||||
|
2. User indtaster kode → POST /auth/verify-code → Gem JWT token
|
||||||
|
3. App henter webshop context → GET /context (med JWT header)
|
||||||
|
4. User browser produkter, tilføjer til kurv (localStorage)
|
||||||
|
5. User går til checkout → POST /orders (med cart data)
|
||||||
|
6. Gateway behandler ordre → Success message vises
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Documentation
|
||||||
|
|
||||||
|
**Hub Repository**: `/Users/christianthomas/DEV/bmc_hub_dev`
|
||||||
|
**Hub API Docs**: `https://hub.bmcnetworks.dk/api/docs`
|
||||||
|
**Gateway API Docs**: `https://apigateway.bmcnetworks.dk/docs` (når implementeret)
|
||||||
|
|
||||||
|
**Kontakt**: ct@bmcnetworks.dk
|
||||||
386
REMINDER_SYSTEM_IMPLEMENTATION.md
Normal file
386
REMINDER_SYSTEM_IMPLEMENTATION.md
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
# Reminder System Implementation - BMC Hub
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Reminder System for BMC Hub's Sag (Case) module provides flexible, multi-channel notification delivery with support for:
|
||||||
|
|
||||||
|
- **Time-based reminders**: Scheduled at specific times or recurring (daily, weekly, monthly)
|
||||||
|
- **Status-change triggered reminders**: Automatically triggered when case status changes
|
||||||
|
- **Multi-channel delivery**: Mattermost, Email, Frontend popup notifications
|
||||||
|
- **User preferences**: Global defaults with per-reminder overrides
|
||||||
|
- **Rate limiting**: Max 5 notifications per user per hour (global)
|
||||||
|
- **Smart scheduling**: Database triggers for status changes, APScheduler for time-based
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**4 Main Tables** (created in `migrations/096_reminder_system.sql`):
|
||||||
|
|
||||||
|
1. **`user_notification_preferences`** - User default notification settings
|
||||||
|
- Default channels (mattermost, email, frontend)
|
||||||
|
- Quiet hours configuration
|
||||||
|
- Email override option
|
||||||
|
|
||||||
|
2. **`sag_reminders`** - Reminder rules/templates
|
||||||
|
- Trigger configuration (status_change, deadline_approaching, time_based)
|
||||||
|
- Recipient configuration (user IDs or email addresses)
|
||||||
|
- Recurrence setup (once, daily, weekly, monthly)
|
||||||
|
- Scheduling info (scheduled_at, next_check_at)
|
||||||
|
|
||||||
|
3. **`sag_reminder_queue`** - Event queue from database triggers
|
||||||
|
- Holds events generated by status-change trigger
|
||||||
|
- Processing status tracking (pending, processing, sent, failed, rate_limited)
|
||||||
|
- Batch processed by scheduler job
|
||||||
|
|
||||||
|
4. **`sag_reminder_logs`** - Execution log
|
||||||
|
- Every notification sent/failed is logged
|
||||||
|
- User interactions (snooze, dismiss, acknowledge)
|
||||||
|
- Used for rate limiting verification
|
||||||
|
|
||||||
|
**Database Triggers**:
|
||||||
|
- `sag_status_change_reminder_trigger()` - Fires on status UPDATE, queues relevant reminders
|
||||||
|
|
||||||
|
**Helper Functions**:
|
||||||
|
- `check_reminder_rate_limit(user_id)` - Verifies user hasn't exceeded 5 per hour
|
||||||
|
|
||||||
|
**Helper Views**:
|
||||||
|
- `v_pending_reminders` - Time-based reminders ready to send
|
||||||
|
- `v_pending_reminder_queue` - Queued events ready for processing
|
||||||
|
|
||||||
|
### Backend Services
|
||||||
|
|
||||||
|
**`app/services/reminder_notification_service.py`**:
|
||||||
|
- Unified notification delivery via Mattermost, Email, Frontend
|
||||||
|
- Merges user preferences with per-reminder overrides
|
||||||
|
- Rate limit checking
|
||||||
|
- Event logging
|
||||||
|
- Email template rendering (Jinja2)
|
||||||
|
|
||||||
|
**`app/services/email_service.py`** (extended):
|
||||||
|
- Added `send_email()` async method using `aiosmtplib`
|
||||||
|
- SMTP configuration from `.env`
|
||||||
|
- Supports plain text + HTML bodies
|
||||||
|
- Safety flag: `REMINDERS_DRY_RUN=true` logs without sending
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
**User Preferences** (in `app/modules/sag/backend/reminders.py`):
|
||||||
|
```
|
||||||
|
GET /api/v1/users/me/notification-preferences
|
||||||
|
PATCH /api/v1/users/me/notification-preferences
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reminder CRUD**:
|
||||||
|
```
|
||||||
|
GET /api/v1/sag/{sag_id}/reminders - List reminders for case
|
||||||
|
POST /api/v1/sag/{sag_id}/reminders - Create new reminder
|
||||||
|
PATCH /api/v1/sag/reminders/{reminder_id} - Update reminder
|
||||||
|
DELETE /api/v1/sag/reminders/{reminder_id} - Soft-delete reminder
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reminder Interactions**:
|
||||||
|
```
|
||||||
|
POST /api/v1/sag/reminders/{reminder_id}/snooze - Snooze for X minutes
|
||||||
|
POST /api/v1/sag/reminders/{reminder_id}/dismiss - Permanently dismiss
|
||||||
|
GET /api/v1/reminders/pending/me - Get pending (for polling)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduler Job
|
||||||
|
|
||||||
|
**`app/jobs/check_reminders.py`**:
|
||||||
|
- Processes time-based reminders (`next_check_at <= NOW()`)
|
||||||
|
- Processes queued status-change events
|
||||||
|
- Calculates next recurrence (`daily` +24h, `weekly` +7d, `monthly` +30d)
|
||||||
|
- Respects rate limiting
|
||||||
|
|
||||||
|
**Registration in `main.py`**:
|
||||||
|
```python
|
||||||
|
backup_scheduler.scheduler.add_job(
|
||||||
|
func=check_reminders,
|
||||||
|
trigger=IntervalTrigger(minutes=5),
|
||||||
|
id='check_reminders',
|
||||||
|
name='Check Reminders',
|
||||||
|
max_instances=1
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs **every 5 minutes** (configurable via `REMINDERS_CHECK_INTERVAL_MINUTES`)
|
||||||
|
|
||||||
|
### Frontend Notifications
|
||||||
|
|
||||||
|
**`static/js/notifications.js`**:
|
||||||
|
- Bootstrap 5 Toast-based notification popups
|
||||||
|
- Polls `/api/v1/reminders/pending/me` every 30 seconds
|
||||||
|
- Snooze presets: 15min, 30min, 1h, 4h, 1day, custom
|
||||||
|
- Dismiss action
|
||||||
|
- Auto-removes when hidden
|
||||||
|
- Pauses polling when tab not visible
|
||||||
|
|
||||||
|
**Integration**:
|
||||||
|
- Loaded in `app/shared/frontend/base.html`
|
||||||
|
- Auto-initializes on page load if user authenticated
|
||||||
|
- User ID extracted from JWT token
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Master switches (default: disabled for safety)
|
||||||
|
REMINDERS_ENABLED=false
|
||||||
|
REMINDERS_EMAIL_ENABLED=false
|
||||||
|
REMINDERS_MATTERMOST_ENABLED=false
|
||||||
|
REMINDERS_DRY_RUN=true # Log without sending if true
|
||||||
|
|
||||||
|
# Scheduler settings
|
||||||
|
REMINDERS_CHECK_INTERVAL_MINUTES=5 # Frequency of reminder checks
|
||||||
|
REMINDERS_MAX_PER_USER_PER_HOUR=5 # Rate limit
|
||||||
|
REMINDERS_QUEUE_BATCH_SIZE=10 # Batch size for queue processing
|
||||||
|
|
||||||
|
# SMTP Configuration (for email reminders)
|
||||||
|
EMAIL_SMTP_HOST=smtp.gmail.com
|
||||||
|
EMAIL_SMTP_PORT=587
|
||||||
|
EMAIL_SMTP_USER=noreply@bmcnetworks.dk
|
||||||
|
EMAIL_SMTP_PASSWORD=<secret>
|
||||||
|
EMAIL_SMTP_USE_TLS=true
|
||||||
|
EMAIL_SMTP_FROM_ADDRESS=noreply@bmcnetworks.dk
|
||||||
|
EMAIL_SMTP_FROM_NAME=BMC Hub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pydantic Configuration
|
||||||
|
|
||||||
|
Added to `app/core/config.py`:
|
||||||
|
```python
|
||||||
|
REMINDERS_ENABLED: bool = False
|
||||||
|
REMINDERS_EMAIL_ENABLED: bool = False
|
||||||
|
REMINDERS_MATTERMOST_ENABLED: bool = False
|
||||||
|
REMINDERS_DRY_RUN: bool = True
|
||||||
|
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
|
||||||
|
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
|
||||||
|
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
||||||
|
|
||||||
|
EMAIL_SMTP_HOST: str = ""
|
||||||
|
EMAIL_SMTP_PORT: int = 587
|
||||||
|
EMAIL_SMTP_USER: str = ""
|
||||||
|
EMAIL_SMTP_PASSWORD: str = ""
|
||||||
|
EMAIL_SMTP_USE_TLS: bool = True
|
||||||
|
EMAIL_SMTP_FROM_ADDRESS: str = "noreply@bmcnetworks.dk"
|
||||||
|
EMAIL_SMTP_FROM_NAME: str = "BMC Hub"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety Features
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
- **Global per user**: Max 5 notifications per hour
|
||||||
|
- Checked before sending via `check_reminder_rate_limit(user_id)`
|
||||||
|
- Queued events marked as `rate_limited` if limit exceeded
|
||||||
|
|
||||||
|
### Dry Run Mode
|
||||||
|
- `REMINDERS_DRY_RUN=true` (default)
|
||||||
|
- All operations logged to console/logs
|
||||||
|
- No emails actually sent
|
||||||
|
- No Mattermost webhooks fired
|
||||||
|
- Useful for testing
|
||||||
|
|
||||||
|
### Soft Delete
|
||||||
|
- Reminders never hard-deleted from DB
|
||||||
|
- `deleted_at` timestamp + `is_active=false`
|
||||||
|
- Full audit trail preserved
|
||||||
|
|
||||||
|
### Per-Reminder Override
|
||||||
|
- Reminder can override user's default channels
|
||||||
|
- `override_user_preferences` flag
|
||||||
|
- Useful for critical reminders (urgent priority)
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Create a Status-Change Reminder
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/sag/123/reminders \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d {
|
||||||
|
"title": "Case entered In Progress",
|
||||||
|
"message": "Case #123 has moved to 'i_gang' status",
|
||||||
|
"priority": "high",
|
||||||
|
"trigger_type": "status_change",
|
||||||
|
"trigger_config": {"target_status": "i_gang"},
|
||||||
|
"recipient_user_ids": [1, 2],
|
||||||
|
"notify_mattermost": true,
|
||||||
|
"notify_email": true,
|
||||||
|
"recurrence_type": "once"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a Scheduled Reminder
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/sag/123/reminders \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d {
|
||||||
|
"title": "Follow up needed",
|
||||||
|
"message": "Check in with customer",
|
||||||
|
"priority": "normal",
|
||||||
|
"trigger_type": "time_based",
|
||||||
|
"trigger_config": {},
|
||||||
|
"scheduled_at": "2026-02-10T14:30:00",
|
||||||
|
"recipient_user_ids": [1],
|
||||||
|
"recurrence_type": "once"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a Daily Recurring Reminder
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/sag/123/reminders \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d {
|
||||||
|
"title": "Daily status check",
|
||||||
|
"priority": "low",
|
||||||
|
"trigger_type": "time_based",
|
||||||
|
"trigger_config": {},
|
||||||
|
"scheduled_at": "2026-02-04T09:00:00",
|
||||||
|
"recipient_user_ids": [1],
|
||||||
|
"recurrence_type": "daily"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update User Preferences
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:8000/api/v1/users/me/notification-preferences \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d {
|
||||||
|
"notify_mattermost": true,
|
||||||
|
"notify_email": false,
|
||||||
|
"notify_frontend": true,
|
||||||
|
"quiet_hours_enabled": true,
|
||||||
|
"quiet_hours_start": "18:00",
|
||||||
|
"quiet_hours_end": "08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- [ ] Run migration: `docker-compose exec -T postgres psql -U bmc_hub -d bmc_hub -f /migrations/096_reminder_system.sql`
|
||||||
|
- [ ] Verify tables created: `SELECT * FROM sag_reminders LIMIT 0;`
|
||||||
|
- [ ] Verify trigger exists: `SELECT * FROM information_schema.triggers WHERE trigger_name LIKE 'sag%reminder%';`
|
||||||
|
|
||||||
|
### API
|
||||||
|
- [ ] Test create reminder endpoint
|
||||||
|
- [ ] Test list reminders endpoint
|
||||||
|
- [ ] Test update reminder endpoint
|
||||||
|
- [ ] Test snooze endpoint
|
||||||
|
- [ ] Test dismiss endpoint
|
||||||
|
- [ ] Test user preferences endpoints
|
||||||
|
|
||||||
|
### Scheduler
|
||||||
|
- [ ] Enable `REMINDERS_ENABLED=true` in `.env`
|
||||||
|
- [ ] Restart container
|
||||||
|
- [ ] Check logs for "Reminder job scheduled" message
|
||||||
|
- [ ] Verify job runs every 5 minutes: `✅ Checking for pending reminders...`
|
||||||
|
|
||||||
|
### Status Change Trigger
|
||||||
|
- [ ] Create reminder with `trigger_type: status_change`
|
||||||
|
- [ ] Change case status
|
||||||
|
- [ ] Verify event inserted in `sag_reminder_queue`
|
||||||
|
- [ ] Wait for scheduler to process
|
||||||
|
- [ ] Verify log entry in `sag_reminder_logs`
|
||||||
|
|
||||||
|
### Email Sending
|
||||||
|
- [ ] Configure SMTP in `.env`
|
||||||
|
- [ ] Set `REMINDERS_EMAIL_ENABLED=true`
|
||||||
|
- [ ] Set `REMINDERS_DRY_RUN=false`
|
||||||
|
- [ ] Create reminder with `notify_email=true`
|
||||||
|
- [ ] Verify email sent or check logs
|
||||||
|
|
||||||
|
### Frontend Popup
|
||||||
|
- [ ] Ensure `static/js/notifications.js` included in base.html
|
||||||
|
- [ ] Open browser console
|
||||||
|
- [ ] Log in to system
|
||||||
|
- [ ] Should see "✅ Reminder system initialized"
|
||||||
|
- [ ] Create a pending reminder
|
||||||
|
- [ ] Should see popup toast within 30 seconds
|
||||||
|
- [ ] Test snooze dropdown
|
||||||
|
- [ ] Test dismiss button
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
- [ ] Create 6 reminders for user with trigger_type=time_based
|
||||||
|
- [ ] Manually trigger scheduler job
|
||||||
|
- [ ] Verify only 5 sent, 1 marked `rate_limited`
|
||||||
|
- [ ] Check `sag_reminder_logs` for status
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
- All safety switches OFF by default (`_ENABLED=false`, `DRY_RUN=true`)
|
||||||
|
- No SMTP configured - reminders won't send
|
||||||
|
- No Mattermost webhook - notifications go to logs only
|
||||||
|
- Test via dry-run mode
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
1. Configure SMTP credentials in `.env`
|
||||||
|
2. Set `REMINDERS_ENABLED=true`
|
||||||
|
3. Set `REMINDERS_EMAIL_ENABLED=true` if using email
|
||||||
|
4. Set `REMINDERS_MATTERMOST_ENABLED=true` if using Mattermost
|
||||||
|
5. Set `REMINDERS_DRY_RUN=false` to actually send
|
||||||
|
6. Deploy with `docker-compose -f docker-compose.prod.yml up -d --build`
|
||||||
|
7. Monitor logs for errors: `docker-compose logs -f api`
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `migrations/096_reminder_system.sql` - Database schema
|
||||||
|
- `app/services/reminder_notification_service.py` - Notification service
|
||||||
|
- `app/jobs/check_reminders.py` - Scheduler job
|
||||||
|
- `app/modules/sag/backend/reminders.py` - API endpoints
|
||||||
|
- `static/js/notifications.js` - Frontend notification system
|
||||||
|
- `templates/emails/reminder.html` - Email template
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
- `app/core/config.py` - Added reminder settings
|
||||||
|
- `app/services/email_service.py` - Added `send_email()` method
|
||||||
|
- `main.py` - Imported reminders router, registered scheduler job
|
||||||
|
- `app/shared/frontend/base.html` - Added notifications.js script tag
|
||||||
|
- `requirements.txt` - Added `aiosmtplib` dependency
|
||||||
|
- `.env` - Added reminder configuration
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Reminders not sending
|
||||||
|
1. Check `REMINDERS_ENABLED=true` in `.env`
|
||||||
|
2. Check scheduler logs: "Reminder check complete"
|
||||||
|
3. Verify `next_check_at` <= NOW() for reminders
|
||||||
|
4. Check rate limit: count in `sag_reminder_logs` last hour
|
||||||
|
|
||||||
|
### Frontend popups not showing
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Verify JWT token contains `sub` (user_id)
|
||||||
|
3. Check `GET /api/v1/reminders/pending/me` returns data
|
||||||
|
4. Ensure `static/js/notifications.js` loaded
|
||||||
|
|
||||||
|
### Email not sending
|
||||||
|
1. Verify SMTP credentials in `.env`
|
||||||
|
2. Check `REMINDERS_EMAIL_ENABLED=true`
|
||||||
|
3. Check `REMINDERS_DRY_RUN=false`
|
||||||
|
4. Review application logs for SMTP errors
|
||||||
|
5. Test SMTP connection separately
|
||||||
|
|
||||||
|
### Database trigger not working
|
||||||
|
1. Verify migration applied successfully
|
||||||
|
2. Check `sag_status_change_reminder_trigger_exec` trigger exists
|
||||||
|
3. Update case status manually
|
||||||
|
4. Check `sag_reminder_queue` for new events
|
||||||
|
5. Review PostgreSQL logs if needed
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Escalation rules (auto-escalate if not acknowledged)
|
||||||
|
- [ ] SMS/WhatsApp integration (Twilio)
|
||||||
|
- [ ] Calendar integration (iCal export)
|
||||||
|
- [ ] User notification history/statistics
|
||||||
|
- [ ] Webhook support for external services
|
||||||
|
- [ ] AI-powered reminder suggestions
|
||||||
|
- [ ] Mobile app push notifications
|
||||||
285
REMINDER_SYSTEM_QUICKSTART.md
Normal file
285
REMINDER_SYSTEM_QUICKSTART.md
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# Reminder System Quick Start
|
||||||
|
|
||||||
|
## 1. Apply Database Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to database and run migration
|
||||||
|
docker-compose exec -T postgres psql -U bmc_hub -d bmc_hub << EOF
|
||||||
|
$(cat migrations/096_reminder_system.sql)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via psql client:
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U bmc_hub -d bmc_hub -f migrations/096_reminder_system.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify tables created:
|
||||||
|
```sql
|
||||||
|
\d sag_reminders
|
||||||
|
\d sag_reminder_logs
|
||||||
|
\d user_notification_preferences
|
||||||
|
\d sag_reminder_queue
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aiosmtplib==3.0.2
|
||||||
|
# Or
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Configure Environment
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ✅ Keep these disabled for local development
|
||||||
|
REMINDERS_ENABLED=false
|
||||||
|
REMINDERS_EMAIL_ENABLED=false
|
||||||
|
REMINDERS_DRY_RUN=true
|
||||||
|
|
||||||
|
# 📧 SMTP Configuration (optional for local testing)
|
||||||
|
EMAIL_SMTP_HOST=smtp.gmail.com
|
||||||
|
EMAIL_SMTP_PORT=587
|
||||||
|
EMAIL_SMTP_USER=your-email@gmail.com
|
||||||
|
EMAIL_SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
|
# 💬 Mattermost (optional)
|
||||||
|
MATTERMOST_ENABLED=true
|
||||||
|
MATTERMOST_WEBHOOK_URL=https://mattermost.example.com/hooks/xxxxx
|
||||||
|
MATTERMOST_CHANNEL=reminders
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Restart Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose restart api
|
||||||
|
```
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
Should see:
|
||||||
|
```
|
||||||
|
✅ Reminder job scheduled (every 5 minutes)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Test Frontend Notification System
|
||||||
|
|
||||||
|
1. Open http://localhost:8000/
|
||||||
|
2. Log in
|
||||||
|
3. Open browser console (F12)
|
||||||
|
4. Should see: `✅ Reminder system initialized`
|
||||||
|
5. Create a test reminder via database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO sag_reminders (
|
||||||
|
sag_id, title, message, priority,
|
||||||
|
trigger_type, trigger_config,
|
||||||
|
recipient_user_ids, recipient_emails,
|
||||||
|
recurrence_type, is_active, created_by_user_id,
|
||||||
|
scheduled_at, next_check_at
|
||||||
|
) VALUES (
|
||||||
|
1, -- Replace with actual case ID
|
||||||
|
'Test Reminder',
|
||||||
|
'This is a test reminder',
|
||||||
|
'high',
|
||||||
|
'time_based',
|
||||||
|
'{}',
|
||||||
|
'{1}', -- Replace with actual user ID
|
||||||
|
'{}',
|
||||||
|
'once',
|
||||||
|
true,
|
||||||
|
1, -- Replace with your user ID
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Wait ~30 seconds or manually call: `GET /api/v1/reminders/pending/me?user_id=1`
|
||||||
|
7. Should see popup toast notification
|
||||||
|
|
||||||
|
## 6. Test API Endpoints
|
||||||
|
|
||||||
|
### Get User Preferences
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/v1/users/me/notification-preferences?user_id=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update User Preferences
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:8000/api/v1/users/me/notification-preferences?user_id=1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d {
|
||||||
|
"notify_frontend": true,
|
||||||
|
"notify_email": false,
|
||||||
|
"notify_mattermost": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Reminder
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/sag/1/reminders?user_id=1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d {
|
||||||
|
"title": "Test Reminder",
|
||||||
|
"message": "This is a test",
|
||||||
|
"priority": "normal",
|
||||||
|
"trigger_type": "time_based",
|
||||||
|
"trigger_config": {},
|
||||||
|
"recipient_user_ids": [1],
|
||||||
|
"recurrence_type": "once"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Reminders
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/v1/sag/1/reminders
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snooze Reminder
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/sag/reminders/1/snooze?user_id=1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d { "duration_minutes": 30 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dismiss Reminder
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/sag/reminders/1/dismiss?user_id=1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d { "reason": "Already handled" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Test Status Change Trigger
|
||||||
|
|
||||||
|
1. Create a reminder with status_change trigger:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO sag_reminders (
|
||||||
|
sag_id, title, message, priority,
|
||||||
|
trigger_type, trigger_config,
|
||||||
|
recipient_user_ids, recipient_emails,
|
||||||
|
recurrence_type, is_active, created_by_user_id
|
||||||
|
) VALUES (
|
||||||
|
1, -- Your test case
|
||||||
|
'Case entered In Progress',
|
||||||
|
'Case has been moved to "i_gang" status',
|
||||||
|
'high',
|
||||||
|
'status_change',
|
||||||
|
'{"target_status": "i_gang"}', -- Trigger when status changes to "i_gang"
|
||||||
|
'{1}',
|
||||||
|
'{}',
|
||||||
|
'once',
|
||||||
|
true,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update case status:
|
||||||
|
```sql
|
||||||
|
UPDATE sag_sager SET status = 'i_gang' WHERE id = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check queue:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM sag_reminder_queue WHERE status = 'pending';
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Should see pending event. Wait for scheduler to process (next 5-min interval)
|
||||||
|
|
||||||
|
5. Check logs:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM sag_reminder_logs ORDER BY triggered_at DESC LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Enable Production Features (When Ready)
|
||||||
|
|
||||||
|
To actually send reminders:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REMINDERS_ENABLED=true
|
||||||
|
REMINDERS_DRY_RUN=false
|
||||||
|
|
||||||
|
# Enable channels you want
|
||||||
|
REMINDERS_EMAIL_ENABLED=true
|
||||||
|
REMINDERS_MATTERMOST_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart and test again.
|
||||||
|
|
||||||
|
## 9. Monitor Reminder Execution
|
||||||
|
|
||||||
|
### View Pending Reminders
|
||||||
|
```sql
|
||||||
|
SELECT * FROM v_pending_reminders LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Queue Status
|
||||||
|
```sql
|
||||||
|
SELECT id, reminder_id, status, error_message
|
||||||
|
FROM sag_reminder_queue
|
||||||
|
WHERE status IN ('pending', 'failed')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Notification Logs
|
||||||
|
```sql
|
||||||
|
SELECT id, reminder_id, sag_id, status, triggered_at, channels_used
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Rate Limiting
|
||||||
|
```sql
|
||||||
|
SELECT user_id, COUNT(*) as count, MAX(triggered_at) as last_sent
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
WHERE status = 'sent' AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY count DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Reminder system not initialized"
|
||||||
|
- User not authenticated
|
||||||
|
- Check that JWT token is valid
|
||||||
|
- Check browser console for auth errors
|
||||||
|
|
||||||
|
### Reminders not appearing
|
||||||
|
- Check `REMINDERS_ENABLED=true`
|
||||||
|
- Check `next_check_at <= NOW()`
|
||||||
|
- Check `recipient_user_ids` includes current user
|
||||||
|
- Verify polling API returns data: `GET /api/v1/reminders/pending/me`
|
||||||
|
|
||||||
|
### Email not sending
|
||||||
|
- Check `REMINDERS_EMAIL_ENABLED=true`
|
||||||
|
- Check SMTP credentials in `.env`
|
||||||
|
- Check `REMINDERS_DRY_RUN=false`
|
||||||
|
- Review application logs for SMTP errors
|
||||||
|
- Try sending with `REMINDERS_DRY_RUN=true` first (logs only)
|
||||||
|
|
||||||
|
### Status trigger not firing
|
||||||
|
- Verify case ID exists
|
||||||
|
- Check trigger_config matches: `{"target_status": "expected_status"}`
|
||||||
|
- Manually check: `UPDATE sag_sager SET status = 'target_status'`
|
||||||
|
- Query `sag_reminder_queue` for new events
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Database migration applied
|
||||||
|
2. ✅ Environment configured
|
||||||
|
3. ✅ Frontend notifications working
|
||||||
|
4. ✅ API endpoints tested
|
||||||
|
5. → Configure email/Mattermost credentials
|
||||||
|
6. → Enable production features
|
||||||
|
7. → Monitor logs and metrics
|
||||||
|
8. → Set up alerting for failures
|
||||||
|
|
||||||
|
See [REMINDER_SYSTEM_IMPLEMENTATION.md](REMINDER_SYSTEM_IMPLEMENTATION.md) for detailed documentation.
|
||||||
@ -23,6 +23,18 @@ class ContactCreate(BaseModel):
|
|||||||
company_id: Optional[int] = None
|
company_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(BaseModel):
|
||||||
|
"""Schema for updating a contact"""
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
department: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class ContactCompanyLink(BaseModel):
|
class ContactCompanyLink(BaseModel):
|
||||||
customer_id: int
|
customer_id: int
|
||||||
is_primary: bool = True
|
is_primary: bool = True
|
||||||
@ -232,6 +244,47 @@ async def get_contact(contact_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/contacts/{contact_id}")
|
||||||
|
async def update_contact(contact_id: int, contact_data: ContactUpdate):
|
||||||
|
"""Update a contact"""
|
||||||
|
try:
|
||||||
|
# Ensure contact exists
|
||||||
|
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
# Build update query dynamically
|
||||||
|
update_fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
for field, value in contact_data.model_dump(exclude_unset=True).items():
|
||||||
|
update_fields.append(f"{field} = %s")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
# No fields to update
|
||||||
|
return await get_contact(contact_id)
|
||||||
|
|
||||||
|
params.append(contact_id)
|
||||||
|
|
||||||
|
update_query = f"""
|
||||||
|
UPDATE contacts
|
||||||
|
SET {', '.join(update_fields)}, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_query(update_query, tuple(params))
|
||||||
|
|
||||||
|
return await get_contact(contact_id)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update contact {contact_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/contacts/{contact_id}/companies")
|
@router.post("/contacts/{contact_id}/companies")
|
||||||
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
||||||
"""Link a contact to a company"""
|
"""Link a contact to a company"""
|
||||||
|
|||||||
@ -287,6 +287,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Contact Modal -->
|
||||||
|
<div class="modal fade" id="editContactModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Rediger Kontakt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="editContactForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Fornavn <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="editFirstNameInput" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Efternavn <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="editLastNameInput" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="editEmailInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Telefon</label>
|
||||||
|
<input type="text" class="form-control" id="editPhoneInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Mobil</label>
|
||||||
|
<input type="text" class="form-control" id="editMobileInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Titel</label>
|
||||||
|
<input type="text" class="form-control" id="editTitleInput" placeholder="CEO, CTO, Manager...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Afdeling</label>
|
||||||
|
<input type="text" class="form-control" id="editDepartmentInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editIsActiveInput">
|
||||||
|
<label class="form-check-label" for="editIsActiveInput">
|
||||||
|
Aktiv kontakt
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEditContact()">
|
||||||
|
<i class="bi bi-check-lg me-2"></i>Gem Ændringer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -496,8 +563,64 @@ async function removeCompany(customerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editContact() {
|
function editContact() {
|
||||||
// TODO: Open edit modal with pre-filled data
|
// Fill form with current contact data
|
||||||
console.log('Edit contact:', contactId);
|
if (contactData) {
|
||||||
|
document.getElementById('editFirstNameInput').value = contactData.first_name || '';
|
||||||
|
document.getElementById('editLastNameInput').value = contactData.last_name || '';
|
||||||
|
document.getElementById('editEmailInput').value = contactData.email || '';
|
||||||
|
document.getElementById('editPhoneInput').value = contactData.phone || '';
|
||||||
|
document.getElementById('editMobileInput').value = contactData.mobile || '';
|
||||||
|
document.getElementById('editTitleInput').value = contactData.title || '';
|
||||||
|
document.getElementById('editDepartmentInput').value = contactData.department || '';
|
||||||
|
document.getElementById('editIsActiveInput').checked = contactData.is_active || false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editContactModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditContact() {
|
||||||
|
const firstName = document.getElementById('editFirstNameInput').value.trim();
|
||||||
|
const lastName = document.getElementById('editLastNameInput').value.trim();
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
alert('Fornavn og efternavn er påkrævet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/contacts/${contactId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
email: document.getElementById('editEmailInput').value || null,
|
||||||
|
phone: document.getElementById('editPhoneInput').value || null,
|
||||||
|
mobile: document.getElementById('editMobileInput').value || null,
|
||||||
|
title: document.getElementById('editTitleInput').value || null,
|
||||||
|
department: document.getElementById('editDepartmentInput').value || null,
|
||||||
|
is_active: document.getElementById('editIsActiveInput').checked
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Kunne ikke gemme kontakt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editContactModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Reload contact
|
||||||
|
await loadContact();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save contact:', error);
|
||||||
|
alert('Fejl: ' + error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(firstName, lastName) {
|
function getInitials(firstName, lastName) {
|
||||||
|
|||||||
@ -215,6 +215,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Contact Modal -->
|
||||||
|
<div class="modal fade" id="editContactModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Rediger Kontakt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="editContactForm">
|
||||||
|
<input type="hidden" id="editContactId">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Fornavn <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="editFirstNameInput" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Efternavn <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="editLastNameInput" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="editEmailInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Telefon</label>
|
||||||
|
<input type="text" class="form-control" id="editPhoneInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Mobil</label>
|
||||||
|
<input type="text" class="form-control" id="editMobileInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Titel</label>
|
||||||
|
<input type="text" class="form-control" id="editTitleInput" placeholder="CEO, CTO, Manager...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Afdeling</label>
|
||||||
|
<input type="text" class="form-control" id="editDepartmentInput">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editIsActiveInput">
|
||||||
|
<label class="form-check-label" for="editIsActiveInput">
|
||||||
|
Aktiv kontakt
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEditContact()">
|
||||||
|
<i class="bi bi-check-lg me-2"></i>Gem Ændringer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -379,8 +447,78 @@ function viewContact(contactId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editContact(contactId) {
|
function editContact(contactId) {
|
||||||
// TODO: Open edit modal
|
// Load contact data and open edit modal
|
||||||
console.log('Edit contact:', contactId);
|
loadContactForEdit(contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContactForEdit(contactId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/contacts/${contactId}`);
|
||||||
|
if (!response.ok) throw new Error('Kunne ikke indlæse kontakt');
|
||||||
|
|
||||||
|
const contact = await response.json();
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
document.getElementById('editContactId').value = contactId;
|
||||||
|
document.getElementById('editFirstNameInput').value = contact.first_name || '';
|
||||||
|
document.getElementById('editLastNameInput').value = contact.last_name || '';
|
||||||
|
document.getElementById('editEmailInput').value = contact.email || '';
|
||||||
|
document.getElementById('editPhoneInput').value = contact.phone || '';
|
||||||
|
document.getElementById('editMobileInput').value = contact.mobile || '';
|
||||||
|
document.getElementById('editTitleInput').value = contact.title || '';
|
||||||
|
document.getElementById('editDepartmentInput').value = contact.department || '';
|
||||||
|
document.getElementById('editIsActiveInput').checked = contact.is_active || false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editContactModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load contact:', error);
|
||||||
|
alert('Fejl: Kunne ikke indlæse kontakt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditContact() {
|
||||||
|
const contactId = document.getElementById('editContactId').value;
|
||||||
|
const firstName = document.getElementById('editFirstNameInput').value.trim();
|
||||||
|
const lastName = document.getElementById('editLastNameInput').value.trim();
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
alert('Fornavn og efternavn er påkrævet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/contacts/${contactId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
email: document.getElementById('editEmailInput').value || null,
|
||||||
|
phone: document.getElementById('editPhoneInput').value || null,
|
||||||
|
mobile: document.getElementById('editMobileInput').value || null,
|
||||||
|
title: document.getElementById('editTitleInput').value || null,
|
||||||
|
department: document.getElementById('editDepartmentInput').value || null,
|
||||||
|
is_active: document.getElementById('editIsActiveInput').checked
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Kunne ikke gemme kontakt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and reload
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editContactModal'));
|
||||||
|
modal.hide();
|
||||||
|
loadContacts();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save contact:', error);
|
||||||
|
alert('Fejl: ' + error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCompaniesForSelect() {
|
async function loadCompaniesForSelect() {
|
||||||
|
|||||||
@ -161,6 +161,24 @@ class Settings(BaseSettings):
|
|||||||
MATTERMOST_ENABLED: bool = False
|
MATTERMOST_ENABLED: bool = False
|
||||||
MATTERMOST_CHANNEL: str = ""
|
MATTERMOST_CHANNEL: str = ""
|
||||||
|
|
||||||
|
# Email Sending (SMTP) Configuration
|
||||||
|
EMAIL_SMTP_HOST: str = ""
|
||||||
|
EMAIL_SMTP_PORT: int = 587
|
||||||
|
EMAIL_SMTP_USER: str = ""
|
||||||
|
EMAIL_SMTP_PASSWORD: str = ""
|
||||||
|
EMAIL_SMTP_USE_TLS: bool = True
|
||||||
|
EMAIL_SMTP_FROM_ADDRESS: str = "noreply@bmcnetworks.dk"
|
||||||
|
EMAIL_SMTP_FROM_NAME: str = "BMC Hub"
|
||||||
|
|
||||||
|
# Reminder System Configuration
|
||||||
|
REMINDERS_ENABLED: bool = False
|
||||||
|
REMINDERS_EMAIL_ENABLED: bool = False
|
||||||
|
REMINDERS_MATTERMOST_ENABLED: bool = False
|
||||||
|
REMINDERS_DRY_RUN: bool = True # SAFETY: Log without sending if true
|
||||||
|
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
|
||||||
|
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
|
||||||
|
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
||||||
|
|
||||||
# Deployment Configuration (used by Docker/Podman)
|
# Deployment Configuration (used by Docker/Podman)
|
||||||
POSTGRES_USER: str = "bmc_hub"
|
POSTGRES_USER: str = "bmc_hub"
|
||||||
POSTGRES_PASSWORD: str = "bmc_hub"
|
POSTGRES_PASSWORD: str = "bmc_hub"
|
||||||
|
|||||||
277
app/jobs/check_reminders.py
Normal file
277
app/jobs/check_reminders.py
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
Reminder Scheduler Job
|
||||||
|
Processes pending time-based reminders and queue-based trigger events
|
||||||
|
Runs every 5 minutes (configurable)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query, execute_insert
|
||||||
|
from app.services.reminder_notification_service import reminder_notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_reminders():
|
||||||
|
"""
|
||||||
|
Main job: Check for pending reminders and trigger notifications
|
||||||
|
- Process time-based reminders (scheduled_at or next_check_at <= NOW())
|
||||||
|
- Process queued trigger events from database triggers
|
||||||
|
- Handle recurring reminders (calculate next_check_at)
|
||||||
|
- Respect rate limiting (max 5 per user per hour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not settings.REMINDERS_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("🔔 Checking for pending reminders...")
|
||||||
|
|
||||||
|
# Step 1: Process queued trigger events (status changes)
|
||||||
|
queue_count = await _process_reminder_queue()
|
||||||
|
|
||||||
|
# Step 2: Process time-based reminders
|
||||||
|
time_based_count = await _process_time_based_reminders()
|
||||||
|
|
||||||
|
logger.info(f"✅ Reminder check complete: {queue_count} queue events, {time_based_count} time-based")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Reminder check failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_reminder_queue():
|
||||||
|
"""Process queued reminder events from status change triggers"""
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
batch_size = settings.REMINDERS_QUEUE_BATCH_SIZE
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get pending queue events
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
q.id, q.reminder_id, q.sag_id, q.event_data,
|
||||||
|
r.title, r.message, r.priority,
|
||||||
|
r.recipient_user_ids, r.recipient_emails,
|
||||||
|
r.notify_mattermost, r.notify_email, r.notify_frontend,
|
||||||
|
r.override_user_preferences,
|
||||||
|
s.titel as case_title, c.name as customer_name,
|
||||||
|
s.status as case_status, s.deadline, s.ansvarlig_bruger_id
|
||||||
|
FROM v_pending_reminder_queue q
|
||||||
|
JOIN sag_reminders r ON q.reminder_id = r.id
|
||||||
|
JOIN sag_sager s ON q.sag_id = s.id
|
||||||
|
JOIN customers c ON s.customer_id = c.id
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
events = execute_query(query, (batch_size,))
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
try:
|
||||||
|
# Update queue status to processing
|
||||||
|
update_query = "UPDATE sag_reminder_queue SET status = 'processing' WHERE id = %s"
|
||||||
|
execute_insert(update_query, (event['id'],))
|
||||||
|
|
||||||
|
# Get assigned user name
|
||||||
|
assigned_user = None
|
||||||
|
if event['ansvarlig_bruger_id']:
|
||||||
|
user_query = "SELECT full_name FROM users WHERE id = %s"
|
||||||
|
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
|
||||||
|
assigned_user = user[0]['full_name'] if user else None
|
||||||
|
|
||||||
|
# Send reminder
|
||||||
|
result = await reminder_notification_service.send_reminder(
|
||||||
|
reminder_id=event['reminder_id'],
|
||||||
|
sag_id=event['sag_id'],
|
||||||
|
case_title=event['case_title'],
|
||||||
|
customer_name=event['customer_name'],
|
||||||
|
reminder_title=event['title'],
|
||||||
|
reminder_message=event['message'],
|
||||||
|
recipient_user_ids=event['recipient_user_ids'] or [],
|
||||||
|
recipient_emails=event['recipient_emails'] or [],
|
||||||
|
priority=event['priority'],
|
||||||
|
notify_mattermost=event['notify_mattermost'],
|
||||||
|
notify_email=event['notify_email'],
|
||||||
|
notify_frontend=event['notify_frontend'],
|
||||||
|
override_user_preferences=event['override_user_preferences'],
|
||||||
|
case_status=event['case_status'],
|
||||||
|
deadline=event['deadline'].isoformat() if event['deadline'] else None,
|
||||||
|
assigned_user=assigned_user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update queue status
|
||||||
|
if result['success']:
|
||||||
|
status = 'sent'
|
||||||
|
log_msg = None
|
||||||
|
elif result['rate_limited_users']:
|
||||||
|
status = 'rate_limited'
|
||||||
|
log_msg = f"Rate limited: {len(result['rate_limited_users'])} users"
|
||||||
|
else:
|
||||||
|
status = 'failed'
|
||||||
|
log_msg = ', '.join(result['errors'])[:500]
|
||||||
|
|
||||||
|
update_query = """
|
||||||
|
UPDATE sag_reminder_queue
|
||||||
|
SET status = %s, processed_at = CURRENT_TIMESTAMP, error_message = %s
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
execute_insert(update_query, (status, log_msg, event['id']))
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
logger.info(f"✅ Processed queue event {event['id']} (reminder {event['reminder_id']})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to process queue event {event['id']}: {e}")
|
||||||
|
update_query = """
|
||||||
|
UPDATE sag_reminder_queue
|
||||||
|
SET status = 'failed', error_message = %s, processed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
execute_insert(update_query, (str(e)[:500], event['id']))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error processing reminder queue: {e}")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_time_based_reminders():
|
||||||
|
"""Process time-based reminders with scheduling"""
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
batch_size = settings.REMINDERS_QUEUE_BATCH_SIZE
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get pending time-based reminders
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
r.id, r.sag_id, r.title, r.message, r.priority,
|
||||||
|
r.recipient_user_ids, r.recipient_emails,
|
||||||
|
r.notify_mattermost, r.notify_email, r.notify_frontend,
|
||||||
|
r.override_user_preferences,
|
||||||
|
r.recurrence_type, r.recurrence_day_of_week, r.recurrence_day_of_month,
|
||||||
|
r.next_check_at,
|
||||||
|
s.titel as case_title, c.name as customer_name,
|
||||||
|
s.status as case_status, s.deadline, s.ansvarlig_bruger_id
|
||||||
|
FROM sag_reminders r
|
||||||
|
JOIN sag_sager s ON r.sag_id = s.id
|
||||||
|
JOIN customers c ON s.customer_id = c.id
|
||||||
|
WHERE r.is_active = true
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND r.trigger_type = 'time_based'
|
||||||
|
AND r.next_check_at IS NOT NULL
|
||||||
|
AND r.next_check_at <= CURRENT_TIMESTAMP
|
||||||
|
ORDER BY r.priority DESC, r.next_check_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
reminders = execute_query(query, (batch_size,))
|
||||||
|
|
||||||
|
for reminder in reminders:
|
||||||
|
try:
|
||||||
|
# Get assigned user name
|
||||||
|
assigned_user = None
|
||||||
|
if reminder['ansvarlig_bruger_id']:
|
||||||
|
user_query = "SELECT full_name FROM users WHERE id = %s"
|
||||||
|
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
|
||||||
|
assigned_user = user[0]['full_name'] if user else None
|
||||||
|
|
||||||
|
# Send reminder
|
||||||
|
result = await reminder_notification_service.send_reminder(
|
||||||
|
reminder_id=reminder['id'],
|
||||||
|
sag_id=reminder['sag_id'],
|
||||||
|
case_title=reminder['case_title'],
|
||||||
|
customer_name=reminder['customer_name'],
|
||||||
|
reminder_title=reminder['title'],
|
||||||
|
reminder_message=reminder['message'],
|
||||||
|
recipient_user_ids=reminder['recipient_user_ids'] or [],
|
||||||
|
recipient_emails=reminder['recipient_emails'] or [],
|
||||||
|
priority=reminder['priority'],
|
||||||
|
notify_mattermost=reminder['notify_mattermost'],
|
||||||
|
notify_email=reminder['notify_email'],
|
||||||
|
notify_frontend=reminder['notify_frontend'],
|
||||||
|
override_user_preferences=reminder['override_user_preferences'],
|
||||||
|
case_status=reminder['case_status'],
|
||||||
|
deadline=reminder['deadline'].isoformat() if reminder['deadline'] else None,
|
||||||
|
assigned_user=assigned_user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate next check time for recurring reminders
|
||||||
|
next_check_at = _calculate_next_check(
|
||||||
|
reminder['recurrence_type'],
|
||||||
|
reminder['recurrence_day_of_week'],
|
||||||
|
reminder['recurrence_day_of_month']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update reminder
|
||||||
|
update_query = """
|
||||||
|
UPDATE sag_reminders
|
||||||
|
SET last_sent_at = CURRENT_TIMESTAMP,
|
||||||
|
next_check_at = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
execute_insert(update_query, (next_check_at, reminder['id']))
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
logger.info(f"✅ Processed reminder {reminder['id']} (next: {next_check_at})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to process reminder {reminder['id']}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error processing time-based reminders: {e}")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_next_check(recurrence_type: str, day_of_week: int = None, day_of_month: int = None):
|
||||||
|
"""Calculate when reminder should be checked next"""
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if recurrence_type == 'once':
|
||||||
|
# One-time reminder - no next check
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif recurrence_type == 'daily':
|
||||||
|
# Next day at same time
|
||||||
|
return now + timedelta(days=1)
|
||||||
|
|
||||||
|
elif recurrence_type == 'weekly':
|
||||||
|
# Same day next week
|
||||||
|
if day_of_week is not None:
|
||||||
|
# If specific day set, calculate days until that day
|
||||||
|
days_ahead = day_of_week - now.weekday()
|
||||||
|
if days_ahead <= 0: # Target day already happened this week
|
||||||
|
days_ahead += 7
|
||||||
|
return now + timedelta(days=days_ahead)
|
||||||
|
else:
|
||||||
|
# Next week same day
|
||||||
|
return now + timedelta(days=7)
|
||||||
|
|
||||||
|
elif recurrence_type == 'monthly':
|
||||||
|
# Same day next month
|
||||||
|
if day_of_month is not None:
|
||||||
|
try:
|
||||||
|
# Try to set day in next month
|
||||||
|
if now.month == 12:
|
||||||
|
next_month = now.replace(year=now.year + 1, month=1, day=min(day_of_month, 28))
|
||||||
|
else:
|
||||||
|
next_month = now.replace(month=now.month + 1, day=min(day_of_month, 28))
|
||||||
|
|
||||||
|
if next_month <= now:
|
||||||
|
# Already passed this month, go to next
|
||||||
|
next_month = next_month + timedelta(days=28)
|
||||||
|
|
||||||
|
return next_month
|
||||||
|
except ValueError:
|
||||||
|
# Invalid date (e.g., Feb 30), use last day of month
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: 30 days from now
|
||||||
|
return now + timedelta(days=30)
|
||||||
|
|
||||||
|
return None
|
||||||
614
app/modules/sag/backend/reminders.py
Normal file
614
app/modules/sag/backend/reminders.py
Normal file
@ -0,0 +1,614 @@
|
|||||||
|
"""
|
||||||
|
Reminder API Endpoints for Sag Module
|
||||||
|
CRUD operations, user preferences, snooze/dismiss functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.database import execute_query, execute_insert
|
||||||
|
from app.services.reminder_notification_service import reminder_notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _get_user_id_from_request(request: Request) -> int:
|
||||||
|
"""Extract user_id from request query params or raise 401"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Pydantic Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class UserNotificationPreferences(BaseModel):
|
||||||
|
"""User notification preferences"""
|
||||||
|
notify_mattermost: bool = True
|
||||||
|
notify_email: bool = False
|
||||||
|
notify_frontend: bool = True
|
||||||
|
email_override: Optional[str] = None
|
||||||
|
quiet_hours_enabled: bool = False
|
||||||
|
quiet_hours_start: Optional[str] = None # HH:MM format
|
||||||
|
quiet_hours_end: Optional[str] = None # HH:MM format
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderCreate(BaseModel):
|
||||||
|
"""Create reminder request"""
|
||||||
|
title: str = Field(..., min_length=3, max_length=255)
|
||||||
|
message: Optional[str] = None
|
||||||
|
priority: str = Field(default="normal", pattern="^(low|normal|high|urgent)$")
|
||||||
|
trigger_type: str = Field(pattern="^(status_change|deadline_approaching|time_based)$")
|
||||||
|
trigger_config: dict # JSON config for trigger
|
||||||
|
recipient_user_ids: List[int] = []
|
||||||
|
recipient_emails: List[str] = []
|
||||||
|
notify_mattermost: Optional[bool] = None
|
||||||
|
notify_email: Optional[bool] = None
|
||||||
|
notify_frontend: Optional[bool] = None
|
||||||
|
override_user_preferences: bool = False
|
||||||
|
recurrence_type: str = Field(default="once", pattern="^(once|daily|weekly|monthly)$")
|
||||||
|
recurrence_day_of_week: Optional[int] = None # 0-6 for weekly
|
||||||
|
recurrence_day_of_month: Optional[int] = None # 1-31 for monthly
|
||||||
|
scheduled_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderUpdate(BaseModel):
|
||||||
|
"""Update reminder request"""
|
||||||
|
title: Optional[str] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
notify_mattermost: Optional[bool] = None
|
||||||
|
notify_email: Optional[bool] = None
|
||||||
|
notify_frontend: Optional[bool] = None
|
||||||
|
override_user_preferences: Optional[bool] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderResponse(BaseModel):
|
||||||
|
"""Reminder response"""
|
||||||
|
id: int
|
||||||
|
sag_id: int
|
||||||
|
title: str
|
||||||
|
message: Optional[str]
|
||||||
|
priority: str
|
||||||
|
trigger_type: str
|
||||||
|
recurrence_type: str
|
||||||
|
is_active: bool
|
||||||
|
next_check_at: Optional[datetime]
|
||||||
|
last_sent_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderProfileResponse(BaseModel):
|
||||||
|
"""Reminder response for profile list"""
|
||||||
|
id: int
|
||||||
|
sag_id: int
|
||||||
|
title: str
|
||||||
|
message: Optional[str]
|
||||||
|
priority: str
|
||||||
|
trigger_type: str
|
||||||
|
recurrence_type: str
|
||||||
|
is_active: bool
|
||||||
|
next_check_at: Optional[datetime]
|
||||||
|
last_sent_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
case_title: Optional[str]
|
||||||
|
customer_name: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderLogResponse(BaseModel):
|
||||||
|
"""Reminder execution log"""
|
||||||
|
id: int
|
||||||
|
reminder_id: Optional[int]
|
||||||
|
sag_id: int
|
||||||
|
status: str
|
||||||
|
triggered_at: datetime
|
||||||
|
channels_used: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SnoozeRequest(BaseModel):
|
||||||
|
"""Snooze reminder request"""
|
||||||
|
duration_minutes: int = Field(..., ge=15, le=1440) # 15 min to 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
class DismissRequest(BaseModel):
|
||||||
|
"""Dismiss reminder request"""
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# User Preferences Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/api/v1/users/me/notification-preferences", response_model=UserNotificationPreferences)
|
||||||
|
async def get_user_notification_preferences(request: Request):
|
||||||
|
"""Get current user's notification preferences"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
notify_mattermost, notify_email, notify_frontend,
|
||||||
|
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
|
||||||
|
FROM user_notification_preferences
|
||||||
|
WHERE user_id = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_query(query, (user_id,))
|
||||||
|
|
||||||
|
if result:
|
||||||
|
r = result[0]
|
||||||
|
return UserNotificationPreferences(
|
||||||
|
notify_mattermost=r.get('notify_mattermost', True),
|
||||||
|
notify_email=r.get('notify_email', False),
|
||||||
|
notify_frontend=r.get('notify_frontend', True),
|
||||||
|
email_override=r.get('email_override'),
|
||||||
|
quiet_hours_enabled=r.get('quiet_hours_enabled', False),
|
||||||
|
quiet_hours_start=r.get('quiet_hours_start'),
|
||||||
|
quiet_hours_end=r.get('quiet_hours_end')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return defaults
|
||||||
|
return UserNotificationPreferences()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/users/me/notification-preferences")
|
||||||
|
async def update_user_notification_preferences(
|
||||||
|
request: Request,
|
||||||
|
preferences: UserNotificationPreferences
|
||||||
|
):
|
||||||
|
"""Update user notification preferences"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
# Check if preferences exist
|
||||||
|
check_query = "SELECT id FROM user_notification_preferences WHERE user_id = %s"
|
||||||
|
exists = execute_query(check_query, (user_id,))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if exists:
|
||||||
|
# Update existing
|
||||||
|
query = """
|
||||||
|
UPDATE user_notification_preferences
|
||||||
|
SET notify_mattermost = %s,
|
||||||
|
notify_email = %s,
|
||||||
|
notify_frontend = %s,
|
||||||
|
email_override = %s,
|
||||||
|
quiet_hours_enabled = %s,
|
||||||
|
quiet_hours_start = %s,
|
||||||
|
quiet_hours_end = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = %s
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_insert(query, (
|
||||||
|
preferences.notify_mattermost,
|
||||||
|
preferences.notify_email,
|
||||||
|
preferences.notify_frontend,
|
||||||
|
preferences.email_override,
|
||||||
|
preferences.quiet_hours_enabled,
|
||||||
|
preferences.quiet_hours_start,
|
||||||
|
preferences.quiet_hours_end,
|
||||||
|
user_id
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Create new
|
||||||
|
query = """
|
||||||
|
INSERT INTO user_notification_preferences (
|
||||||
|
user_id, notify_mattermost, notify_email, notify_frontend,
|
||||||
|
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_insert(query, (
|
||||||
|
user_id,
|
||||||
|
preferences.notify_mattermost,
|
||||||
|
preferences.notify_email,
|
||||||
|
preferences.notify_frontend,
|
||||||
|
preferences.email_override,
|
||||||
|
preferences.quiet_hours_enabled,
|
||||||
|
preferences.quiet_hours_start,
|
||||||
|
preferences.quiet_hours_end
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.info(f"✅ Updated notification preferences for user {user_id}")
|
||||||
|
return {"success": True, "message": "Preferences updated"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating preferences: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Reminder CRUD Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/api/v1/sag/{sag_id}/reminders", response_model=List[ReminderResponse])
|
||||||
|
async def list_sag_reminders(sag_id: int):
|
||||||
|
"""List all reminders for a case"""
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, sag_id, title, message, priority, trigger_type,
|
||||||
|
recurrence_type, is_active, next_check_at, last_sent_at, created_at
|
||||||
|
FROM sag_reminders
|
||||||
|
WHERE sag_id = %s AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = execute_query(query, (sag_id,))
|
||||||
|
|
||||||
|
return [
|
||||||
|
ReminderResponse(
|
||||||
|
id=r['id'],
|
||||||
|
sag_id=r['sag_id'],
|
||||||
|
title=r['title'],
|
||||||
|
message=r['message'],
|
||||||
|
priority=r['priority'],
|
||||||
|
trigger_type=r['trigger_type'],
|
||||||
|
recurrence_type=r['recurrence_type'],
|
||||||
|
is_active=r['is_active'],
|
||||||
|
next_check_at=r['next_check_at'],
|
||||||
|
last_sent_at=r['last_sent_at'],
|
||||||
|
created_at=r['created_at']
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/reminders/my", response_model=List[ReminderProfileResponse])
|
||||||
|
async def list_my_reminders(request: Request):
|
||||||
|
"""List reminders for the authenticated user"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.trigger_type,
|
||||||
|
r.recurrence_type, r.is_active, r.next_check_at, r.last_sent_at, r.created_at,
|
||||||
|
s.titel as case_title, c.name as customer_name
|
||||||
|
FROM sag_reminders r
|
||||||
|
LEFT JOIN sag_sager s ON s.id = r.sag_id
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE r.deleted_at IS NULL
|
||||||
|
AND %s = ANY(r.recipient_user_ids)
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = execute_query(query, (user_id,))
|
||||||
|
|
||||||
|
return [
|
||||||
|
ReminderProfileResponse(
|
||||||
|
id=r['id'],
|
||||||
|
sag_id=r['sag_id'],
|
||||||
|
title=r['title'],
|
||||||
|
message=r['message'],
|
||||||
|
priority=r['priority'],
|
||||||
|
trigger_type=r['trigger_type'],
|
||||||
|
recurrence_type=r['recurrence_type'],
|
||||||
|
is_active=r['is_active'],
|
||||||
|
next_check_at=r['next_check_at'],
|
||||||
|
last_sent_at=r['last_sent_at'],
|
||||||
|
created_at=r['created_at'],
|
||||||
|
case_title=r.get('case_title'),
|
||||||
|
customer_name=r.get('customer_name')
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/sag/{sag_id}/reminders", response_model=ReminderResponse)
|
||||||
|
async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate):
|
||||||
|
"""Create a new reminder for a case"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
# Verify case exists
|
||||||
|
case_query = "SELECT id FROM sag_sager WHERE id = %s"
|
||||||
|
case = execute_query(case_query, (sag_id,))
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Case #{sag_id} not found")
|
||||||
|
|
||||||
|
# Calculate next_check_at based on trigger type
|
||||||
|
next_check_at = None
|
||||||
|
if reminder.trigger_type == 'time_based' and reminder.scheduled_at:
|
||||||
|
next_check_at = reminder.scheduled_at
|
||||||
|
elif reminder.trigger_type == 'deadline_approaching':
|
||||||
|
next_check_at = datetime.now() + timedelta(days=1) # Check daily
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
query = """
|
||||||
|
INSERT INTO sag_reminders (
|
||||||
|
sag_id, title, message, priority, trigger_type, trigger_config,
|
||||||
|
recipient_user_ids, recipient_emails,
|
||||||
|
notify_mattermost, notify_email, notify_frontend,
|
||||||
|
override_user_preferences,
|
||||||
|
recurrence_type, recurrence_day_of_week, recurrence_day_of_month,
|
||||||
|
scheduled_at, next_check_at,
|
||||||
|
is_active, created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, %s,
|
||||||
|
true, %s
|
||||||
|
)
|
||||||
|
RETURNING id, sag_id, title, message, priority, trigger_type,
|
||||||
|
recurrence_type, is_active, next_check_at, last_sent_at, created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_insert(query, (
|
||||||
|
sag_id, reminder.title, reminder.message, reminder.priority,
|
||||||
|
reminder.trigger_type, json.dumps(reminder.trigger_config),
|
||||||
|
reminder.recipient_user_ids, reminder.recipient_emails,
|
||||||
|
reminder.notify_mattermost, reminder.notify_email, reminder.notify_frontend,
|
||||||
|
reminder.override_user_preferences,
|
||||||
|
reminder.recurrence_type, reminder.recurrence_day_of_week, reminder.recurrence_day_of_month,
|
||||||
|
reminder.scheduled_at, next_check_at,
|
||||||
|
user_id
|
||||||
|
))
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create reminder")
|
||||||
|
|
||||||
|
raw_row = result[0] if isinstance(result, list) else result
|
||||||
|
if isinstance(raw_row, dict):
|
||||||
|
r = raw_row
|
||||||
|
else:
|
||||||
|
reminder_id = int(raw_row)
|
||||||
|
fetch_query = """
|
||||||
|
SELECT id, sag_id, title, message, priority, trigger_type,
|
||||||
|
recurrence_type, is_active, next_check_at, last_sent_at, created_at
|
||||||
|
FROM sag_reminders
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
fetched = execute_query(fetch_query, (reminder_id,))
|
||||||
|
if not fetched:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to fetch reminder after creation")
|
||||||
|
r = fetched[0]
|
||||||
|
|
||||||
|
logger.info(f"✅ Reminder created for case #{sag_id} by user {user_id}")
|
||||||
|
|
||||||
|
return ReminderResponse(
|
||||||
|
id=r['id'],
|
||||||
|
sag_id=r['sag_id'],
|
||||||
|
title=r['title'],
|
||||||
|
message=r['message'],
|
||||||
|
priority=r['priority'],
|
||||||
|
trigger_type=r['trigger_type'],
|
||||||
|
recurrence_type=r['recurrence_type'],
|
||||||
|
is_active=r['is_active'],
|
||||||
|
next_check_at=r['next_check_at'],
|
||||||
|
last_sent_at=r['last_sent_at'],
|
||||||
|
created_at=r['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating reminder: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/sag/reminders/{reminder_id}")
|
||||||
|
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
||||||
|
"""Update a reminder"""
|
||||||
|
|
||||||
|
# Build update query dynamically
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if update.title is not None:
|
||||||
|
updates.append("title = %s")
|
||||||
|
params.append(update.title)
|
||||||
|
|
||||||
|
if update.message is not None:
|
||||||
|
updates.append("message = %s")
|
||||||
|
params.append(update.message)
|
||||||
|
|
||||||
|
if update.priority is not None:
|
||||||
|
updates.append("priority = %s")
|
||||||
|
params.append(update.priority)
|
||||||
|
|
||||||
|
if update.notify_mattermost is not None:
|
||||||
|
updates.append("notify_mattermost = %s")
|
||||||
|
params.append(update.notify_mattermost)
|
||||||
|
|
||||||
|
if update.notify_email is not None:
|
||||||
|
updates.append("notify_email = %s")
|
||||||
|
params.append(update.notify_email)
|
||||||
|
|
||||||
|
if update.notify_frontend is not None:
|
||||||
|
updates.append("notify_frontend = %s")
|
||||||
|
params.append(update.notify_frontend)
|
||||||
|
|
||||||
|
if update.override_user_preferences is not None:
|
||||||
|
updates.append("override_user_preferences = %s")
|
||||||
|
params.append(update.override_user_preferences)
|
||||||
|
|
||||||
|
if update.is_active is not None:
|
||||||
|
updates.append("is_active = %s")
|
||||||
|
params.append(update.is_active)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||||
|
params.append(reminder_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = f"""
|
||||||
|
UPDATE sag_reminders
|
||||||
|
SET {', '.join(updates)}
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_insert(query, tuple(params))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
logger.info(f"✅ Reminder {reminder_id} updated")
|
||||||
|
return {"success": True, "message": "Reminder updated"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating reminder: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/sag/reminders/{reminder_id}")
|
||||||
|
async def delete_sag_reminder(reminder_id: int):
|
||||||
|
"""Soft-delete a reminder"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
UPDATE sag_reminders
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP, is_active = false
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_insert(query, (reminder_id,))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
logger.info(f"✅ Reminder {reminder_id} deleted")
|
||||||
|
return {"success": True, "message": "Reminder deleted"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting reminder: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Reminder Interaction Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/api/v1/sag/reminders/{reminder_id}/snooze")
|
||||||
|
async def snooze_reminder(reminder_id: int, request: Request, snooze_request: SnoozeRequest):
|
||||||
|
"""Snooze a reminder for specified minutes"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
snooze_until = datetime.now() + timedelta(minutes=snooze_request.duration_minutes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
INSERT INTO sag_reminder_logs (
|
||||||
|
reminder_id, sag_id, user_id, status, snoozed_until, snoozed_by_user_id, triggered_at
|
||||||
|
)
|
||||||
|
SELECT id, sag_id, %s, 'snoozed', %s, %s, CURRENT_TIMESTAMP
|
||||||
|
FROM sag_reminders
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_insert(query, (user_id, snooze_until, user_id, reminder_id))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
logger.info(f"✅ Reminder {reminder_id} snoozed until {snooze_until}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Reminder snoozed for {snooze_request.duration_minutes} minutes",
|
||||||
|
"snoozed_until": snooze_until
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error snoozing reminder: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/sag/reminders/{reminder_id}/dismiss")
|
||||||
|
async def dismiss_reminder(reminder_id: int, request: Request, dismiss_request: DismissRequest):
|
||||||
|
"""Dismiss a reminder"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
INSERT INTO sag_reminder_logs (
|
||||||
|
reminder_id, sag_id, user_id, status, dismissed_at, dismissed_by_user_id, triggered_at
|
||||||
|
)
|
||||||
|
SELECT id, sag_id, %s, 'dismissed', CURRENT_TIMESTAMP, %s, CURRENT_TIMESTAMP
|
||||||
|
FROM sag_reminders
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_insert(query, (user_id, user_id, reminder_id))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||||
|
|
||||||
|
logger.info(f"✅ Reminder {reminder_id} dismissed by user {user_id}")
|
||||||
|
return {"success": True, "message": "Reminder dismissed"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error dismissing reminder: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/reminders/pending/me")
|
||||||
|
async def get_pending_reminders(request: Request):
|
||||||
|
"""Get pending reminders for current user (for frontend polling)"""
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
r.id, r.sag_id, r.title, r.message, r.priority,
|
||||||
|
s.titel as case_title, c.name as customer_name,
|
||||||
|
l.id as log_id, l.snoozed_until, l.status as log_status
|
||||||
|
FROM sag_reminders r
|
||||||
|
JOIN sag_sager s ON r.sag_id = s.id
|
||||||
|
JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, snoozed_until, status, triggered_at
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
WHERE reminder_id = r.id AND user_id = %s
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) l ON true
|
||||||
|
WHERE r.is_active = true
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND r.next_check_at <= CURRENT_TIMESTAMP
|
||||||
|
AND %s = ANY(r.recipient_user_ids)
|
||||||
|
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
|
||||||
|
AND (l.status IS NULL OR l.status != 'dismissed')
|
||||||
|
ORDER BY r.priority DESC, r.next_check_at ASC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = execute_query(query, (user_id, user_id))
|
||||||
|
return [{
|
||||||
|
'id': r['id'],
|
||||||
|
'sag_id': r['sag_id'],
|
||||||
|
'title': r['title'],
|
||||||
|
'message': r['message'],
|
||||||
|
'priority': r['priority'],
|
||||||
|
'case_title': r['case_title'],
|
||||||
|
'customer_name': r['customer_name']
|
||||||
|
} for r in results]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching pending reminders: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@ -132,8 +132,8 @@ async def create_sag(data: dict):
|
|||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_sager
|
INSERT INTO sag_sager
|
||||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline)
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
params = (
|
params = (
|
||||||
@ -145,6 +145,9 @@ async def create_sag(data: dict):
|
|||||||
data.get("ansvarlig_bruger_id"),
|
data.get("ansvarlig_bruger_id"),
|
||||||
data.get("created_by_user_id", 1),
|
data.get("created_by_user_id", 1),
|
||||||
data.get("deadline"),
|
data.get("deadline"),
|
||||||
|
data.get("deferred_until"),
|
||||||
|
data.get("deferred_until_case_id"),
|
||||||
|
data.get("deferred_until_status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
@ -171,6 +174,44 @@ async def get_sag(sag_id: int):
|
|||||||
logger.error("❌ Error getting case: %s", e)
|
logger.error("❌ Error getting case: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to get case")
|
raise HTTPException(status_code=500, detail="Failed to get case")
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}")
|
@router.patch("/sag/{sag_id}")
|
||||||
async def update_sag(sag_id: int, updates: dict):
|
async def update_sag(sag_id: int, updates: dict):
|
||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
@ -181,7 +222,7 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
# Build dynamic update query
|
# Build dynamic update query
|
||||||
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline"]
|
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"]
|
||||||
set_clauses = []
|
set_clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
@ -473,9 +514,25 @@ async def list_case_contacts(sag_id: int):
|
|||||||
"""List contacts associated with a case."""
|
"""List contacts associated with a case."""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT sk.*, c.first_name, c.last_name, c.email, c.phone
|
SELECT
|
||||||
|
sk.*,
|
||||||
|
c.first_name,
|
||||||
|
c.last_name,
|
||||||
|
c.email,
|
||||||
|
c.phone,
|
||||||
|
c.mobile,
|
||||||
|
c.title,
|
||||||
|
company.customer_name
|
||||||
FROM sag_kontakter sk
|
FROM sag_kontakter sk
|
||||||
JOIN contacts c ON sk.contact_id = c.id
|
JOIN contacts c ON sk.contact_id = c.id
|
||||||
|
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
|
||||||
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (sag_id,))
|
result = execute_query(query, (sag_id,))
|
||||||
@ -492,6 +549,7 @@ async def add_case_contact(sag_id: int, data: dict):
|
|||||||
raise HTTPException(status_code=400, detail="contact_id is required")
|
raise HTTPException(status_code=400, detail="contact_id is required")
|
||||||
|
|
||||||
role = data.get("role", "Kontakt")
|
role = data.get("role", "Kontakt")
|
||||||
|
is_primary = bool(data.get("is_primary", False))
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
check = execute_query(
|
check = execute_query(
|
||||||
@ -501,12 +559,18 @@ async def add_case_contact(sag_id: int, data: dict):
|
|||||||
if check:
|
if check:
|
||||||
return check[0] # Already linked
|
return check[0] # Already linked
|
||||||
|
|
||||||
|
if is_primary:
|
||||||
|
execute_update(
|
||||||
|
"UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s",
|
||||||
|
(sag_id,)
|
||||||
|
)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_kontakter (sag_id, contact_id, role)
|
INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
|
||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (sag_id, data["contact_id"], role))
|
result = execute_query(query, (sag_id, data["contact_id"], role, is_primary))
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("✅ Contact added to case %s: %s", sag_id, data["contact_id"])
|
logger.info("✅ Contact added to case %s: %s", sag_id, data["contact_id"])
|
||||||
@ -536,6 +600,58 @@ async def remove_case_contact(sag_id: int, contact_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to remove case contact")
|
raise HTTPException(status_code=500, detail="Failed to remove case contact")
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# HARDWARE - Placeholder endpoints for frontend compatibility
|
# HARDWARE - Placeholder endpoints for frontend compatibility
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1187,8 +1303,12 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
|||||||
return saved_files
|
return saved_files
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/files/{file_id}")
|
@router.get("/sag/{sag_id}/files/{file_id}")
|
||||||
async def download_sag_file(sag_id: int, file_id: int):
|
async def download_sag_file(sag_id: int, file_id: int, download: bool = False):
|
||||||
"""Download a specific file."""
|
"""Download or preview a specific file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download: If True, force download. If False (default), display inline in browser.
|
||||||
|
"""
|
||||||
query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s"
|
query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s"
|
||||||
result = execute_query(query, (file_id, sag_id))
|
result = execute_query(query, (file_id, sag_id))
|
||||||
|
|
||||||
@ -1201,10 +1321,18 @@ async def download_sag_file(sag_id: int, file_id: int):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="File lost on server")
|
raise HTTPException(status_code=404, detail="File lost on server")
|
||||||
|
|
||||||
|
# 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"]}"'
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=path,
|
path=path,
|
||||||
filename=file_data["filename"],
|
filename=file_data["filename"],
|
||||||
media_type=file_data.get("content_type", "application/octet-stream")
|
media_type=file_data.get("content_type", "application/octet-stream"),
|
||||||
|
headers=headers
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.delete("/sag/{sag_id}/files/{file_id}")
|
@router.delete("/sag/{sag_id}/files/{file_id}")
|
||||||
|
|||||||
@ -17,6 +17,7 @@ async def sager_liste(
|
|||||||
status: str = Query(None),
|
status: str = Query(None),
|
||||||
tag: str = Query(None),
|
tag: str = Query(None),
|
||||||
customer_id: int = Query(None),
|
customer_id: int = Query(None),
|
||||||
|
include_deferred: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""Display list of all cases."""
|
"""Display list of all cases."""
|
||||||
try:
|
try:
|
||||||
@ -34,10 +35,15 @@ async def sager_liste(
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) cc_first ON true
|
) cc_first ON true
|
||||||
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
|
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
|
||||||
|
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
|
||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
if not include_deferred:
|
||||||
|
query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
|
||||||
|
query += " AND (s.deferred_until_case_id IS NULL OR s.deferred_until_status IS NULL OR ds.status = s.deferred_until_status)"
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " AND s.status = %s"
|
query += " AND s.status = %s"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
@ -95,6 +101,10 @@ async def sager_liste(
|
|||||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
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", ())
|
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
|
||||||
|
|
||||||
|
toggle_include_deferred_url = str(
|
||||||
|
request.url.include_query_params(include_deferred="0" if include_deferred else "1")
|
||||||
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse("modules/sag/templates/index.html", {
|
return templates.TemplateResponse("modules/sag/templates/index.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"sager": sager,
|
"sager": sager,
|
||||||
@ -104,6 +114,8 @@ async def sager_liste(
|
|||||||
"all_tags": [t['tag_navn'] for t in all_tags],
|
"all_tags": [t['tag_navn'] for t in all_tags],
|
||||||
"current_status": status,
|
"current_status": status,
|
||||||
"current_tag": tag,
|
"current_tag": tag,
|
||||||
|
"include_deferred": include_deferred,
|
||||||
|
"toggle_include_deferred_url": toggle_include_deferred_url,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error displaying case list: %s", e)
|
logger.error("❌ Error displaying case list: %s", e)
|
||||||
@ -258,22 +270,35 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
if customer_result:
|
if customer_result:
|
||||||
customer = customer_result[0]
|
customer = customer_result[0]
|
||||||
|
|
||||||
# Fetch hovedkontakt (primary contact) for customer via contact_companies
|
# Fetch hovedkontakt (primary contact) for case via sag_kontakter
|
||||||
kontakt_query = """
|
kontakt_query = """
|
||||||
SELECT c.*
|
SELECT c.*
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
JOIN contact_companies cc ON c.id = cc.contact_id
|
JOIN sag_kontakter sk ON c.id = sk.contact_id
|
||||||
WHERE cc.customer_id = %s
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL AND sk.is_primary = TRUE
|
||||||
ORDER BY cc.is_primary DESC, c.id ASC
|
LIMIT 1
|
||||||
LIMIT 1
|
"""
|
||||||
"""
|
kontakt_result = execute_query(kontakt_query, (sag_id,))
|
||||||
kontakt_result = execute_query(kontakt_query, (sag['customer_id'],))
|
if kontakt_result:
|
||||||
if kontakt_result:
|
hovedkontakt = kontakt_result[0]
|
||||||
hovedkontakt = kontakt_result[0]
|
else:
|
||||||
|
fallback_query = """
|
||||||
|
SELECT c.*
|
||||||
|
FROM contacts c
|
||||||
|
JOIN sag_kontakter sk ON c.id = sk.contact_id
|
||||||
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
||||||
|
ORDER BY sk.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
fallback_result = execute_query(fallback_query, (sag_id,))
|
||||||
|
if fallback_result:
|
||||||
|
hovedkontakt = fallback_result[0]
|
||||||
|
|
||||||
# Fetch prepaid cards for customer
|
# Fetch prepaid cards for customer
|
||||||
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
|
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
|
||||||
# DEBUG: Logging customer ID
|
# DEBUG: Logging customer ID
|
||||||
|
prepaid_cards = []
|
||||||
|
if sag.get('customer_id'):
|
||||||
cid = sag.get('customer_id')
|
cid = sag.get('customer_id')
|
||||||
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
|
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
|
||||||
|
|
||||||
@ -287,8 +312,6 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"""
|
"""
|
||||||
prepaid_cards = execute_query(pc_query, (cid,))
|
prepaid_cards = execute_query(pc_query, (cid,))
|
||||||
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
|
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
|
||||||
else:
|
|
||||||
prepaid_cards = []
|
|
||||||
|
|
||||||
# Fetch Nextcloud Instance for this customer
|
# Fetch Nextcloud Instance for this customer
|
||||||
nextcloud_instance = None
|
nextcloud_instance = None
|
||||||
@ -300,9 +323,24 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
|
|
||||||
# Fetch linked contacts
|
# Fetch linked contacts
|
||||||
contacts_query = """
|
contacts_query = """
|
||||||
SELECT sk.*, c.first_name || ' ' || c.last_name as contact_name, c.email as contact_email
|
SELECT
|
||||||
|
sk.*,
|
||||||
|
c.first_name || ' ' || c.last_name as contact_name,
|
||||||
|
c.email as contact_email,
|
||||||
|
c.phone,
|
||||||
|
c.mobile,
|
||||||
|
c.title,
|
||||||
|
company.customer_name
|
||||||
FROM sag_kontakter sk
|
FROM sag_kontakter sk
|
||||||
JOIN contacts c ON sk.contact_id = c.id
|
JOIN contacts c ON sk.contact_id = c.id
|
||||||
|
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
|
||||||
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
contacts = execute_query(contacts_query, (sag_id,))
|
contacts = execute_query(contacts_query, (sag_id,))
|
||||||
@ -334,6 +372,23 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
|
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
|
||||||
logger.info(f"is_nextcloud result: {is_nextcloud}")
|
logger.info(f"is_nextcloud result: {is_nextcloud}")
|
||||||
|
|
||||||
|
related_case_options = []
|
||||||
|
try:
|
||||||
|
related_ids = set()
|
||||||
|
for rel in relationer or []:
|
||||||
|
related_ids.add(rel["kilde_sag_id"])
|
||||||
|
related_ids.add(rel["målsag_id"])
|
||||||
|
related_ids.discard(sag_id)
|
||||||
|
if related_ids:
|
||||||
|
placeholders = ",".join(["%s"] * len(related_ids))
|
||||||
|
related_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders}) AND deleted_at IS NULL"
|
||||||
|
related_case_options = execute_query(related_query, tuple(related_ids))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error building related case options: %s", e)
|
||||||
|
related_case_options = []
|
||||||
|
|
||||||
|
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
||||||
|
|
||||||
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"case": sag,
|
"case": sag,
|
||||||
@ -351,6 +406,8 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"time_entries": time_entries,
|
"time_entries": time_entries,
|
||||||
"is_nextcloud": is_nextcloud,
|
"is_nextcloud": is_nextcloud,
|
||||||
"nextcloud_instance": nextcloud_instance,
|
"nextcloud_instance": nextcloud_instance,
|
||||||
|
"related_case_options": related_case_options,
|
||||||
|
"status_options": [s["status"] for s in statuses],
|
||||||
})
|
})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -298,6 +298,9 @@
|
|||||||
<option value="all">Alle typer</option>
|
<option value="all">Alle typer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
||||||
|
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
@ -312,6 +315,7 @@
|
|||||||
<th style="width: 180px;">Kunde</th>
|
<th style="width: 180px;">Kunde</th>
|
||||||
<th style="width: 150px;">Hovedkontakt</th>
|
<th style="width: 150px;">Hovedkontakt</th>
|
||||||
<th style="width: 100px;">Status</th>
|
<th style="width: 100px;">Status</th>
|
||||||
|
<th style="width: 120px;">Udsat start</th>
|
||||||
<th style="width: 120px;">Oprettet</th>
|
<th style="width: 120px;">Oprettet</th>
|
||||||
<th style="width: 120px;">Opdateret</th>
|
<th style="width: 120px;">Opdateret</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -348,6 +352,9 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||||
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||||
|
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
|
||||||
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||||
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
||||||
</td>
|
</td>
|
||||||
@ -387,6 +394,9 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||||
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||||
|
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
|
||||||
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||||
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ Backend endpoints for webshop administration og konfiguration
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -67,6 +67,13 @@ class WebshopProductCreate(BaseModel):
|
|||||||
visible: bool = True
|
visible: bool = True
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
|
||||||
|
@field_validator('base_price')
|
||||||
|
@classmethod
|
||||||
|
def validate_base_price(cls, v):
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError('Basispris må ikke være 0 eller negativ')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# WEBSHOP CONFIG ENDPOINTS
|
# WEBSHOP CONFIG ENDPOINTS
|
||||||
@ -387,6 +394,13 @@ async def add_webshop_product(product: WebshopProductCreate):
|
|||||||
Tilføj produkt til webshop whitelist
|
Tilføj produkt til webshop whitelist
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Validate price is not zero
|
||||||
|
if product.base_price <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Basispris må ikke være 0 eller negativ. Angiv en gyldig pris."
|
||||||
|
)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO webshop_products (
|
INSERT INTO webshop_products (
|
||||||
webshop_config_id, product_number, ean, name, description,
|
webshop_config_id, product_number, ean, name, description,
|
||||||
|
|||||||
@ -2,17 +2,29 @@
|
|||||||
Email Service
|
Email Service
|
||||||
Handles email fetching from IMAP or Microsoft Graph API
|
Handles email fetching from IMAP or Microsoft Graph API
|
||||||
Based on OmniSync architecture - READ-ONLY mode for safety
|
Based on OmniSync architecture - READ-ONLY mode for safety
|
||||||
|
Also handles outbound SMTP email sending for reminders and notifications
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import imaplib
|
import imaplib
|
||||||
import email
|
import email
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
from typing import List, Dict, Optional, Tuple
|
from typing import List, Dict, Optional, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
# Try to import aiosmtplib, but don't fail if not available
|
||||||
|
try:
|
||||||
|
import aiosmtplib
|
||||||
|
HAS_AIOSMTPLIB = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_AIOSMTPLIB = False
|
||||||
|
aiosmtplib = None
|
||||||
|
|
||||||
from aiohttp import ClientSession, BasicAuth
|
from aiohttp import ClientSession, BasicAuth
|
||||||
import msal
|
import msal
|
||||||
|
|
||||||
@ -852,3 +864,92 @@ class EmailService:
|
|||||||
logger.error(f"❌ Failed to save uploaded email: {e}")
|
logger.error(f"❌ Failed to save uploaded email: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
self,
|
||||||
|
to_addresses: List[str],
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: Optional[str] = None,
|
||||||
|
cc: Optional[List[str]] = None,
|
||||||
|
bcc: Optional[List[str]] = None,
|
||||||
|
reply_to: Optional[str] = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Send email via SMTP to one or more recipients
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_addresses: List of recipient email addresses
|
||||||
|
subject: Email subject
|
||||||
|
body_text: Plain text body
|
||||||
|
body_html: Optional HTML body
|
||||||
|
cc: Optional list of CC addresses
|
||||||
|
bcc: Optional list of BCC addresses
|
||||||
|
reply_to: Optional reply-to address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Safety check
|
||||||
|
if settings.REMINDERS_DRY_RUN:
|
||||||
|
logger.warning(f"🔒 DRY RUN MODE: Would send email to {to_addresses} with subject '{subject}'")
|
||||||
|
return True, "Dry run mode - email not actually sent"
|
||||||
|
|
||||||
|
# Check if aiosmtplib is available
|
||||||
|
if not HAS_AIOSMTPLIB:
|
||||||
|
logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib")
|
||||||
|
return False, "aiosmtplib not installed"
|
||||||
|
|
||||||
|
# Validate SMTP configuration
|
||||||
|
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
|
||||||
|
logger.error("❌ SMTP not configured - cannot send email")
|
||||||
|
return False, "SMTP not configured"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build message
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
|
||||||
|
msg['To'] = ', '.join(to_addresses)
|
||||||
|
|
||||||
|
if cc:
|
||||||
|
msg['Cc'] = ', '.join(cc)
|
||||||
|
|
||||||
|
if reply_to:
|
||||||
|
msg['Reply-To'] = reply_to
|
||||||
|
|
||||||
|
# Attach plain text
|
||||||
|
msg.attach(MIMEText(body_text, 'plain'))
|
||||||
|
|
||||||
|
# Attach HTML if provided
|
||||||
|
if body_html:
|
||||||
|
msg.attach(MIMEText(body_html, 'html'))
|
||||||
|
|
||||||
|
# Send via SMTP
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.EMAIL_SMTP_HOST,
|
||||||
|
port=settings.EMAIL_SMTP_PORT,
|
||||||
|
use_tls=settings.EMAIL_SMTP_USE_TLS
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
|
||||||
|
|
||||||
|
# Combine all recipients
|
||||||
|
all_recipients = to_addresses.copy()
|
||||||
|
if cc:
|
||||||
|
all_recipients.extend(cc)
|
||||||
|
if bcc:
|
||||||
|
all_recipients.extend(bcc)
|
||||||
|
|
||||||
|
await smtp.sendmail(
|
||||||
|
settings.EMAIL_SMTP_FROM_ADDRESS,
|
||||||
|
all_recipients,
|
||||||
|
msg.as_string()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}")
|
||||||
|
return True, f"Email sent to {len(to_addresses)} recipient(s)"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Failed to send email: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|||||||
411
app/services/reminder_notification_service.py
Normal file
411
app/services/reminder_notification_service.py
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
Reminder Notification Service
|
||||||
|
Handles multi-channel delivery of reminders (Mattermost, Email, Frontend)
|
||||||
|
Includes rate limiting and user preference merging
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query, execute_insert
|
||||||
|
from app.services.email_service import EmailService
|
||||||
|
from app.backups.backend.notifications import MattermostNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderNotificationService:
|
||||||
|
"""Service for sending reminders via multiple notification channels"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.email_service = EmailService()
|
||||||
|
self.mattermost_service = MattermostNotification()
|
||||||
|
self.dry_run = settings.REMINDERS_DRY_RUN
|
||||||
|
self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR
|
||||||
|
|
||||||
|
async def send_reminder(
|
||||||
|
self,
|
||||||
|
reminder_id: int,
|
||||||
|
sag_id: int,
|
||||||
|
case_title: str,
|
||||||
|
customer_name: str,
|
||||||
|
reminder_title: str,
|
||||||
|
reminder_message: Optional[str],
|
||||||
|
recipient_user_ids: List[int],
|
||||||
|
recipient_emails: List[str],
|
||||||
|
priority: str = "normal",
|
||||||
|
notify_mattermost: Optional[bool] = None,
|
||||||
|
notify_email: Optional[bool] = None,
|
||||||
|
notify_frontend: Optional[bool] = None,
|
||||||
|
override_user_preferences: bool = False,
|
||||||
|
additional_info: Optional[str] = None,
|
||||||
|
case_status: Optional[str] = None,
|
||||||
|
deadline: Optional[str] = None,
|
||||||
|
assigned_user: Optional[str] = None
|
||||||
|
) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
Send reminder via configured notification channels
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
{
|
||||||
|
'success': bool,
|
||||||
|
'channels_used': List[str],
|
||||||
|
'errors': List[str],
|
||||||
|
'rate_limited_users': List[int],
|
||||||
|
'logged_id': int
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'success': False,
|
||||||
|
'channels_used': [],
|
||||||
|
'errors': [],
|
||||||
|
'rate_limited_users': [],
|
||||||
|
'logged_id': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
|
||||||
|
|
||||||
|
# Get case details
|
||||||
|
case_query = "SELECT id, titel FROM sag_sager WHERE id = %s"
|
||||||
|
case = execute_query(case_query, (sag_id,))
|
||||||
|
if not case:
|
||||||
|
logger.error(f"❌ Case #{sag_id} not found")
|
||||||
|
result['errors'].append(f"Case #{sag_id} not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Process each recipient user
|
||||||
|
for user_id in recipient_user_ids:
|
||||||
|
try:
|
||||||
|
# Check rate limit
|
||||||
|
if not await self._check_rate_limit(user_id):
|
||||||
|
logger.warning(f"⚠️ Rate limit exceeded for user {user_id}")
|
||||||
|
result['rate_limited_users'].append(user_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get user preferences
|
||||||
|
user_prefs = await self._get_user_preferences(user_id)
|
||||||
|
|
||||||
|
# Merge with reminder-specific overrides
|
||||||
|
channels = self._determine_channels(
|
||||||
|
user_prefs,
|
||||||
|
notify_mattermost,
|
||||||
|
notify_email,
|
||||||
|
notify_frontend,
|
||||||
|
override_user_preferences
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user email
|
||||||
|
user_query = "SELECT email FROM users WHERE id = %s"
|
||||||
|
user = execute_query(user_query, (user_id,))
|
||||||
|
user_email = user[0]['email'] if user else None
|
||||||
|
|
||||||
|
# Send via channels
|
||||||
|
for channel in channels:
|
||||||
|
try:
|
||||||
|
if channel == 'mattermost' and settings.REMINDERS_MATTERMOST_ENABLED:
|
||||||
|
await self._send_mattermost(
|
||||||
|
reminder_title, reminder_message, sag_id, case_title,
|
||||||
|
priority, additional_info
|
||||||
|
)
|
||||||
|
result['channels_used'].append('mattermost')
|
||||||
|
|
||||||
|
elif channel == 'email' and settings.REMINDERS_EMAIL_ENABLED and user_email:
|
||||||
|
await self._send_email(
|
||||||
|
user_email, reminder_title, reminder_message,
|
||||||
|
sag_id, case_title, customer_name, priority,
|
||||||
|
case_status, deadline, assigned_user, additional_info
|
||||||
|
)
|
||||||
|
result['channels_used'].append('email')
|
||||||
|
|
||||||
|
elif channel == 'frontend':
|
||||||
|
# Frontend notifications are handled by polling, no action needed here
|
||||||
|
result['channels_used'].append('frontend')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Failed to send via {channel}: {str(e)}"
|
||||||
|
logger.error(f"❌ {error}")
|
||||||
|
result['errors'].append(error)
|
||||||
|
|
||||||
|
# Log notification
|
||||||
|
log_id = await self._log_reminder(
|
||||||
|
reminder_id, sag_id, user_id, result['channels_used'],
|
||||||
|
{
|
||||||
|
'title': reminder_title,
|
||||||
|
'message': reminder_message,
|
||||||
|
'case': case_title,
|
||||||
|
'priority': priority
|
||||||
|
},
|
||||||
|
'sent' if result['channels_used'] else 'failed'
|
||||||
|
)
|
||||||
|
result['logged_id'] = log_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error sending reminder to user {user_id}: {e}")
|
||||||
|
result['errors'].append(f"User {user_id}: {str(e)}")
|
||||||
|
|
||||||
|
# Process additional email addresses
|
||||||
|
for email_addr in recipient_emails:
|
||||||
|
try:
|
||||||
|
if settings.REMINDERS_EMAIL_ENABLED:
|
||||||
|
await self._send_email(
|
||||||
|
email_addr, reminder_title, reminder_message,
|
||||||
|
sag_id, case_title, customer_name, priority,
|
||||||
|
case_status, deadline, assigned_user, additional_info
|
||||||
|
)
|
||||||
|
result['channels_used'].append('email')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Failed to send email to {email_addr}: {str(e)}"
|
||||||
|
logger.error(f"❌ {error}")
|
||||||
|
result['errors'].append(error)
|
||||||
|
|
||||||
|
result['success'] = len(result['errors']) == 0 or len(result['channels_used']) > 0
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _check_rate_limit(self, user_id: int) -> bool:
|
||||||
|
"""Check if user has exceeded notification limit (max 5 per hour)"""
|
||||||
|
query = """
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
|
||||||
|
AND status = 'sent'
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (user_id,))
|
||||||
|
count = result[0]['count'] if result else 0
|
||||||
|
|
||||||
|
if count >= self.max_per_hour:
|
||||||
|
logger.warning(f"⚠️ Rate limit reached for user {user_id}: {count}/{self.max_per_hour} notifications")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _get_user_preferences(self, user_id: int) -> Dict:
|
||||||
|
"""Get user notification preferences"""
|
||||||
|
query = """
|
||||||
|
SELECT notify_mattermost, notify_email, notify_frontend
|
||||||
|
FROM user_notification_preferences
|
||||||
|
WHERE user_id = %s
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (user_id,))
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
'mattermost': result[0].get('notify_mattermost', True),
|
||||||
|
'email': result[0].get('notify_email', False),
|
||||||
|
'frontend': result[0].get('notify_frontend', True)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default preferences
|
||||||
|
return {
|
||||||
|
'mattermost': True,
|
||||||
|
'email': False,
|
||||||
|
'frontend': True
|
||||||
|
}
|
||||||
|
|
||||||
|
def _determine_channels(
|
||||||
|
self,
|
||||||
|
user_prefs: Dict,
|
||||||
|
notify_mattermost: Optional[bool],
|
||||||
|
notify_email: Optional[bool],
|
||||||
|
notify_frontend: Optional[bool],
|
||||||
|
override: bool
|
||||||
|
) -> List[str]:
|
||||||
|
"""Determine which channels to use (merge user prefs with reminder overrides)"""
|
||||||
|
channels = []
|
||||||
|
|
||||||
|
# Mattermost
|
||||||
|
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
|
||||||
|
if mm:
|
||||||
|
channels.append('mattermost')
|
||||||
|
|
||||||
|
# Email
|
||||||
|
em = notify_email if notify_email is not None else user_prefs.get('email', False)
|
||||||
|
if em:
|
||||||
|
channels.append('email')
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
fe = notify_frontend if notify_frontend is not None else user_prefs.get('frontend', True)
|
||||||
|
if fe:
|
||||||
|
channels.append('frontend')
|
||||||
|
|
||||||
|
return channels
|
||||||
|
|
||||||
|
async def _send_mattermost(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
message: Optional[str],
|
||||||
|
case_id: int,
|
||||||
|
case_title: str,
|
||||||
|
priority: str,
|
||||||
|
additional_info: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
"""Send reminder via Mattermost"""
|
||||||
|
if self.dry_run:
|
||||||
|
logger.warning(f"🔒 DRY RUN: Mattermost reminder '{title}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
color_map = {
|
||||||
|
'low': '#6c757d',
|
||||||
|
'normal': '#0f4c75',
|
||||||
|
'high': '#ffc107',
|
||||||
|
'urgent': '#dc3545'
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'text': f'🔔 **{title}**',
|
||||||
|
'attachments': [{
|
||||||
|
'title': case_title,
|
||||||
|
'title_link': f"http://localhost:8000/sag/{case_id}",
|
||||||
|
'text': message or additional_info or 'Se reminder i systemet',
|
||||||
|
'color': color_map.get(priority, color_map['normal']),
|
||||||
|
'fields': [
|
||||||
|
{
|
||||||
|
'title': 'Prioritet',
|
||||||
|
'value': priority.capitalize(),
|
||||||
|
'short': True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Sag ID',
|
||||||
|
'value': f'#{case_id}',
|
||||||
|
'short': True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'actions': [{
|
||||||
|
'name': 'Åbn sag',
|
||||||
|
'type': 'button',
|
||||||
|
'text': 'Se mere',
|
||||||
|
'url': f"http://localhost:8000/sag/{case_id}"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification')
|
||||||
|
if success:
|
||||||
|
logger.info(f"✅ Mattermost reminder sent: {title}")
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ Mattermost error: {msg}")
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Mattermost send failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _send_email(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
title: str,
|
||||||
|
message: Optional[str],
|
||||||
|
case_id: int,
|
||||||
|
case_title: str,
|
||||||
|
customer_name: str,
|
||||||
|
priority: str,
|
||||||
|
case_status: Optional[str],
|
||||||
|
deadline: Optional[str],
|
||||||
|
assigned_user: Optional[str],
|
||||||
|
additional_info: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
"""Send reminder via email"""
|
||||||
|
if self.dry_run:
|
||||||
|
logger.warning(f"🔒 DRY RUN: Email reminder to {to_email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load email template
|
||||||
|
with open('templates/emails/reminder.html', 'r') as f:
|
||||||
|
template_html = f.read()
|
||||||
|
|
||||||
|
# Prepare context
|
||||||
|
context = {
|
||||||
|
'header_title': 'Reminder: ' + title,
|
||||||
|
'reminder_title': title,
|
||||||
|
'reminder_message': message or '',
|
||||||
|
'case_id': case_id,
|
||||||
|
'case_title': case_title,
|
||||||
|
'customer_name': customer_name,
|
||||||
|
'case_status': case_status or 'Unknown',
|
||||||
|
'priority': priority,
|
||||||
|
'deadline': deadline,
|
||||||
|
'assigned_user': assigned_user or 'Ikke tildelt',
|
||||||
|
'additional_info': additional_info or '',
|
||||||
|
'action_url': f"http://localhost:8000/sag/{case_id}",
|
||||||
|
'footer_date': datetime.now().strftime("%d. %B %Y")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render template
|
||||||
|
template = Template(template_html)
|
||||||
|
body_html = template.render(context)
|
||||||
|
body_text = f"{title}\n\n{message or ''}\n\nSag: {case_title} (#{case_id})\nKunde: {customer_name}"
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
success, msg = await self.email_service.send_email(
|
||||||
|
to_addresses=[to_email],
|
||||||
|
subject=f"[{priority.upper()}] Reminder: {title}",
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
reply_to=settings.EMAIL_SMTP_FROM_ADDRESS
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"✅ Email reminder sent to {to_email}")
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ Email error: {msg}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Email send failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _log_reminder(
|
||||||
|
self,
|
||||||
|
reminder_id: int,
|
||||||
|
sag_id: int,
|
||||||
|
user_id: int,
|
||||||
|
channels: List[str],
|
||||||
|
payload: Dict,
|
||||||
|
status: str = 'sent'
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Log reminder execution"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
INSERT INTO sag_reminder_logs (
|
||||||
|
reminder_id, sag_id, user_id,
|
||||||
|
channels_used, notification_payload,
|
||||||
|
status, triggered_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = execute_insert(query, (
|
||||||
|
reminder_id,
|
||||||
|
sag_id,
|
||||||
|
user_id,
|
||||||
|
channels,
|
||||||
|
json.dumps(payload),
|
||||||
|
status
|
||||||
|
))
|
||||||
|
|
||||||
|
if result:
|
||||||
|
log_id = result[0]['id'] if isinstance(result[0], dict) else result[0]
|
||||||
|
logger.info(f"📝 Reminder logged (ID: {log_id})")
|
||||||
|
return log_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to log reminder: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
reminder_notification_service = ReminderNotificationService()
|
||||||
@ -309,7 +309,7 @@
|
|||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
||||||
<li><a class="dropdown-item py-2" href="#">Profil</a></li>
|
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
|
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
|
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
|
||||||
@ -516,6 +516,7 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/static/js/tag-picker.js?v=2.0"></script>
|
<script src="/static/js/tag-picker.js?v=2.0"></script>
|
||||||
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
@ -1046,6 +1047,226 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Profile Modal -->
|
||||||
|
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content" style="border-radius: 16px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-person-circle me-2"></i>Profil</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ul class="nav nav-tabs mb-3" id="profileTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="profile-overview-tab" data-bs-toggle="tab" data-bs-target="#profile-overview" type="button" role="tab">
|
||||||
|
Overblik
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="profile-reminders-tab" data-bs-toggle="tab" data-bs-target="#profile-reminders" type="button" role="tab">
|
||||||
|
Reminders
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" id="profileTabsContent">
|
||||||
|
<div class="tab-pane fade show active" id="profile-overview" role="tabpanel" tabindex="0">
|
||||||
|
<div class="alert alert-info small mb-0">
|
||||||
|
Profilinformation hentes fra din konto. Flere felter kan tilføjes her senere.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="profile-reminders" role="tabpanel" tabindex="0">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-sliders me-2"></i>Notifikationsindstillinger</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="pref_notify_frontend">
|
||||||
|
<label class="form-check-label" for="pref_notify_frontend">Popup</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="pref_notify_email">
|
||||||
|
<label class="form-check-label" for="pref_notify_email">Email</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="pref_notify_mattermost">
|
||||||
|
<label class="form-check-label" for="pref_notify_mattermost">Mattermost</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email override</label>
|
||||||
|
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-bell me-2"></i>Dine reminders</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadProfileReminders()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush" id="profileRemindersList">
|
||||||
|
<div class="p-4 text-center text-muted">Indlæser reminders...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function loadReminderPreferences() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users/me/notification-preferences', { credentials: 'include' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const prefs = await res.json();
|
||||||
|
document.getElementById('pref_notify_frontend').checked = !!prefs.notify_frontend;
|
||||||
|
document.getElementById('pref_notify_email').checked = !!prefs.notify_email;
|
||||||
|
document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost;
|
||||||
|
document.getElementById('pref_email_override').value = prefs.email_override || '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load reminder preferences', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReminderPreferences() {
|
||||||
|
const payload = {
|
||||||
|
notify_frontend: document.getElementById('pref_notify_frontend').checked,
|
||||||
|
notify_email: document.getElementById('pref_notify_email').checked,
|
||||||
|
notify_mattermost: document.getElementById('pref_notify_mattermost').checked,
|
||||||
|
email_override: document.getElementById('pref_email_override').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users/me/notification-preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || 'Kunne ikke gemme indstillinger');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fejl: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfileReminders() {
|
||||||
|
const list = document.getElementById('profileRemindersList');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<div class="p-4 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter reminders...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/reminders/my', { credentials: 'include' });
|
||||||
|
if (!res.ok) {
|
||||||
|
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke hente reminders.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reminders = await res.json();
|
||||||
|
renderProfileReminders(reminders || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load reminders', e);
|
||||||
|
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke hente reminders.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfileReminders(reminders) {
|
||||||
|
const list = document.getElementById('profileRemindersList');
|
||||||
|
if (!list) return;
|
||||||
|
if (!reminders.length) {
|
||||||
|
list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders fundet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = reminders.map(reminder => {
|
||||||
|
const statusBadge = reminder.is_active
|
||||||
|
? '<span class="badge bg-success">Aktiv</span>'
|
||||||
|
: '<span class="badge bg-secondary">Inaktiv</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="me-3">
|
||||||
|
<div class="fw-bold">${reminder.title}</div>
|
||||||
|
<div class="small text-muted">Sag #${reminder.sag_id} · ${reminder.case_title || '-'}</div>
|
||||||
|
<div class="small text-muted">${reminder.message || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-end gap-2">
|
||||||
|
${statusBadge}
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button class="btn btn-outline-secondary" onclick="toggleReminderActive(${reminder.id}, ${reminder.is_active ? 'false' : 'true'})">
|
||||||
|
${reminder.is_active ? 'Pause' : 'Aktivér'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="deleteProfileReminder(${reminder.id})">
|
||||||
|
Slet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleReminderActive(reminderId, isActive) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ is_active: isActive })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Kunne ikke opdatere reminder');
|
||||||
|
loadProfileReminders();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fejl: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProfileReminder(reminderId) {
|
||||||
|
if (!confirm('Vil du slette denne reminder?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Kunne ikke slette reminder');
|
||||||
|
loadProfileReminders();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fejl: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const profileModalEl = document.getElementById('profileModal');
|
||||||
|
if (profileModalEl) {
|
||||||
|
profileModalEl.addEventListener('shown.bs.modal', () => {
|
||||||
|
loadReminderPreferences();
|
||||||
|
loadProfileReminders();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Maintenance Mode Overlay -->
|
<!-- Maintenance Mode Overlay -->
|
||||||
<div id="maintenance-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 9999; backdrop-filter: blur(5px);">
|
<div id="maintenance-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 9999; backdrop-filter: blur(5px);">
|
||||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: white; max-width: 500px; padding: 2rem;">
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: white; max-width: 500px; padding: 2rem;">
|
||||||
|
|||||||
@ -7,6 +7,26 @@ from datetime import datetime
|
|||||||
|
|
||||||
# Tag types
|
# Tag types
|
||||||
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
|
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
|
||||||
|
TagGroupBehavior = Literal['multi', 'single', 'toggle']
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroupBase(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
behavior: TagGroupBehavior = 'multi'
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroupCreate(TagGroupBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroup(TagGroupBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
class TagBase(BaseModel):
|
class TagBase(BaseModel):
|
||||||
"""Base tag model"""
|
"""Base tag model"""
|
||||||
@ -16,6 +36,7 @@ class TagBase(BaseModel):
|
|||||||
color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$') # Hex color
|
color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$') # Hex color
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
tag_group_id: Optional[int] = None
|
||||||
|
|
||||||
class TagCreate(TagBase):
|
class TagCreate(TagBase):
|
||||||
"""Tag creation model"""
|
"""Tag creation model"""
|
||||||
@ -37,6 +58,7 @@ class TagUpdate(BaseModel):
|
|||||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
tag_group_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class EntityTagBase(BaseModel):
|
class EntityTagBase(BaseModel):
|
||||||
|
|||||||
@ -7,12 +7,31 @@ from app.tags.backend.models import (
|
|||||||
Tag, TagCreate, TagUpdate,
|
Tag, TagCreate, TagUpdate,
|
||||||
EntityTag, EntityTagCreate,
|
EntityTag, EntityTagCreate,
|
||||||
TagWorkflow, TagWorkflowCreate,
|
TagWorkflow, TagWorkflowCreate,
|
||||||
TagType
|
TagType,
|
||||||
|
TagGroup, TagGroupCreate
|
||||||
)
|
)
|
||||||
from app.core.database import execute_query, execute_query_single, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_update
|
||||||
|
|
||||||
router = APIRouter(prefix="/tags")
|
router = APIRouter(prefix="/tags")
|
||||||
|
|
||||||
|
# ============= TAG GROUPS =============
|
||||||
|
|
||||||
|
@router.get("/groups", response_model=List[TagGroup])
|
||||||
|
async def list_tag_groups():
|
||||||
|
results = execute_query("SELECT * FROM tag_groups ORDER BY name")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/groups", response_model=TagGroup)
|
||||||
|
async def create_tag_group(group: TagGroupCreate):
|
||||||
|
query = """
|
||||||
|
INSERT INTO tag_groups (name, description, behavior)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query_single(query, (group.name, group.description, group.behavior))
|
||||||
|
return result
|
||||||
|
|
||||||
# ============= TAG CRUD =============
|
# ============= TAG CRUD =============
|
||||||
|
|
||||||
@router.get("", response_model=List[Tag])
|
@router.get("", response_model=List[Tag])
|
||||||
@ -52,13 +71,13 @@ async def get_tag(tag_id: int):
|
|||||||
async def create_tag(tag: TagCreate):
|
async def create_tag(tag: TagCreate):
|
||||||
"""Create new tag"""
|
"""Create new tag"""
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO tags (name, type, description, color, icon, is_active)
|
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query_single(
|
result = execute_query_single(
|
||||||
query,
|
query,
|
||||||
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active)
|
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -84,6 +103,9 @@ async def update_tag(tag_id: int, tag: TagUpdate):
|
|||||||
if tag.is_active is not None:
|
if tag.is_active is not None:
|
||||||
updates.append("is_active = %s")
|
updates.append("is_active = %s")
|
||||||
params.append(tag.is_active)
|
params.append(tag.is_active)
|
||||||
|
if tag.tag_group_id is not None:
|
||||||
|
updates.append("tag_group_id = %s")
|
||||||
|
params.append(tag.tag_group_id)
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
@ -120,6 +142,30 @@ async def delete_tag(tag_id: int):
|
|||||||
@router.post("/entity", response_model=EntityTag)
|
@router.post("/entity", response_model=EntityTag)
|
||||||
async def add_tag_to_entity(entity_tag: EntityTagCreate):
|
async def add_tag_to_entity(entity_tag: EntityTagCreate):
|
||||||
"""Add tag to any entity (ticket, customer, time_entry, etc.)"""
|
"""Add tag to any entity (ticket, customer, time_entry, etc.)"""
|
||||||
|
# Enforce toggle/single groups
|
||||||
|
group = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT tg.behavior, t.tag_group_id
|
||||||
|
FROM tags t
|
||||||
|
JOIN tag_groups tg ON t.tag_group_id = tg.id
|
||||||
|
WHERE t.id = %s
|
||||||
|
""",
|
||||||
|
(entity_tag.tag_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if group and group.get("behavior") in ("single", "toggle"):
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
DELETE FROM entity_tags
|
||||||
|
WHERE entity_type = %s
|
||||||
|
AND entity_id = %s
|
||||||
|
AND tag_id IN (
|
||||||
|
SELECT id FROM tags WHERE tag_group_id = %s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(entity_tag.entity_type, entity_tag.entity_id, group["tag_group_id"])
|
||||||
|
)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by)
|
INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by)
|
||||||
VALUES (%s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s)
|
||||||
|
|||||||
25
main.py
25
main.py
@ -67,6 +67,7 @@ from app.auth.backend import admin as auth_admin_api
|
|||||||
from app.modules.webshop.backend import router as webshop_api
|
from app.modules.webshop.backend import router as webshop_api
|
||||||
from app.modules.webshop.frontend import views as webshop_views
|
from app.modules.webshop.frontend import views as webshop_views
|
||||||
from app.modules.sag.backend import router as sag_api
|
from app.modules.sag.backend import router as sag_api
|
||||||
|
from app.modules.sag.backend import reminders as sag_reminders_api
|
||||||
from app.modules.sag.frontend import views as sag_views
|
from app.modules.sag.frontend import views as sag_views
|
||||||
from app.modules.hardware.backend import router as hardware_module_api
|
from app.modules.hardware.backend import router as hardware_module_api
|
||||||
from app.modules.hardware.frontend import views as hardware_module_views
|
from app.modules.hardware.frontend import views as hardware_module_views
|
||||||
@ -96,9 +97,23 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# Start unified scheduler (handles backups + email fetch)
|
# Start unified scheduler (handles backups + email fetch + reminders)
|
||||||
backup_scheduler.start()
|
backup_scheduler.start()
|
||||||
|
|
||||||
|
# Register reminder scheduler job
|
||||||
|
from app.jobs.check_reminders import check_reminders
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
|
backup_scheduler.scheduler.add_job(
|
||||||
|
func=check_reminders,
|
||||||
|
trigger=IntervalTrigger(minutes=5),
|
||||||
|
id='check_reminders',
|
||||||
|
name='Check Reminders',
|
||||||
|
max_instances=1,
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
logger.info("✅ Reminder job scheduled (every 5 minutes)")
|
||||||
|
|
||||||
logger.info("✅ System initialized successfully")
|
logger.info("✅ System initialized successfully")
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
@ -165,6 +180,13 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
)
|
)
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
user_id_value = payload.get("sub") or payload.get("user_id")
|
||||||
|
if user_id_value is not None:
|
||||||
|
try:
|
||||||
|
request.state.user_id = int(user_id_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
request.state.user_id = None
|
||||||
|
|
||||||
if path.startswith("/api") and not payload.get("shadow_admin"):
|
if path.startswith("/api") and not payload.get("shadow_admin"):
|
||||||
if not payload.get("sub"):
|
if not payload.get("sub"):
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@ -220,6 +242,7 @@ app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
|
|||||||
# Module Routers
|
# Module Routers
|
||||||
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
||||||
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
|
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
|
||||||
|
app.include_router(sag_reminders_api.router, tags=["Reminders"]) # No prefix - endpoints have full path
|
||||||
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
|
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
|
||||||
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
||||||
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
|
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
|
||||||
|
|||||||
16
migrations/091_sag_contact_roles.sql
Normal file
16
migrations/091_sag_contact_roles.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- Migration 091: Sag contacts primary flag
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'sag_kontakter' AND column_name = 'is_primary'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sag_kontakter ADD COLUMN is_primary BOOLEAN DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Ensure only one primary contact per case
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sag_kontakter_primary
|
||||||
|
ON sag_kontakter (sag_id)
|
||||||
|
WHERE is_primary = TRUE;
|
||||||
55
migrations/092_tag_groups.sql
Normal file
55
migrations/092_tag_groups.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- Migration 092: Tag groups and behaviors
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
behavior VARCHAR(20) NOT NULL DEFAULT 'multi', -- multi, single, toggle
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tags' AND column_name = 'tag_group_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tags ADD COLUMN tag_group_id INTEGER REFERENCES tag_groups(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_group ON tags(tag_group_id);
|
||||||
|
|
||||||
|
-- Trigger to auto-update updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_tag_groups_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_tag_groups_updated_at ON tag_groups;
|
||||||
|
CREATE TRIGGER update_tag_groups_updated_at
|
||||||
|
BEFORE UPDATE ON tag_groups
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_tag_groups_updated_at();
|
||||||
|
|
||||||
|
-- Seed tag groups
|
||||||
|
INSERT INTO tag_groups (name, description, behavior) VALUES
|
||||||
|
('View', 'Styrer visning/layout', 'toggle'),
|
||||||
|
('Status', 'Sag status', 'toggle')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed view tags
|
||||||
|
INSERT INTO tags (name, type, description, color, tag_group_id)
|
||||||
|
SELECT v.name, 'category', v.description, v.color, tg.id
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('Pipeline', 'Pipeline view', '#0f4c75'),
|
||||||
|
('Kundevisning', 'Kundevisning view', '#0f4c75'),
|
||||||
|
('Sag-detalje', 'Sag-detalje view', '#0f4c75')
|
||||||
|
) AS v(name, description, color)
|
||||||
|
JOIN tag_groups tg ON tg.name = 'View'
|
||||||
|
ON CONFLICT (name, type) DO NOTHING;
|
||||||
27
migrations/093_sag_module_prefs.sql
Normal file
27
migrations/093_sag_module_prefs.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- Migration 093: Case module visibility preferences
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_module_prefs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
module_key VARCHAR(50) NOT NULL,
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (sag_id, module_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_module_prefs_sag ON sag_module_prefs(sag_id);
|
||||||
|
|
||||||
|
-- Trigger to auto-update updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_sag_module_prefs_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_sag_module_prefs_updated_at ON sag_module_prefs;
|
||||||
|
CREATE TRIGGER update_sag_module_prefs_updated_at
|
||||||
|
BEFORE UPDATE ON sag_module_prefs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_sag_module_prefs_updated_at();
|
||||||
9
migrations/094_sag_deferred_until.sql
Normal file
9
migrations/094_sag_deferred_until.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 094: Add deferred_until to sag_sager
|
||||||
|
-- Dato: 3. februar 2026
|
||||||
|
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD COLUMN IF NOT EXISTS deferred_until TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_sager_deferred_until
|
||||||
|
ON sag_sager(deferred_until)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
12
migrations/095_sag_deferred_until_case.sql
Normal file
12
migrations/095_sag_deferred_until_case.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Migration 095: Add deferred until case/status to sag_sager
|
||||||
|
-- Dato: 3. februar 2026
|
||||||
|
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD COLUMN IF NOT EXISTS deferred_until_case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD COLUMN IF NOT EXISTS deferred_until_status VARCHAR;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_sager_deferred_case
|
||||||
|
ON sag_sager(deferred_until_case_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
350
migrations/096_reminder_system.sql
Normal file
350
migrations/096_reminder_system.sql
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
-- Migration 096: Reminder System for Sag (Cases)
|
||||||
|
-- Dato: 3. februar 2026
|
||||||
|
--
|
||||||
|
-- Features:
|
||||||
|
-- - Time-based reminders (specific datetime or cron-like scheduling)
|
||||||
|
-- - Status-change triggered reminders (via database trigger)
|
||||||
|
-- - Recurring reminders (once, daily, weekly, monthly)
|
||||||
|
-- - Multi-channel notifications (mattermost, email, frontend popup)
|
||||||
|
-- - User preferences with per-case overrides
|
||||||
|
-- - Global rate limiting (max 5 notifications per user per hour)
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- User Notification Preferences Table
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Default notification channels (can be overridden per reminder)
|
||||||
|
notify_mattermost BOOLEAN DEFAULT true,
|
||||||
|
notify_email BOOLEAN DEFAULT false,
|
||||||
|
notify_frontend BOOLEAN DEFAULT true,
|
||||||
|
|
||||||
|
-- Email recipient (if different from user account)
|
||||||
|
email_override VARCHAR(255),
|
||||||
|
|
||||||
|
-- Quiet hours (no notifications outside these hours)
|
||||||
|
quiet_hours_enabled BOOLEAN DEFAULT false,
|
||||||
|
quiet_hours_start TIME, -- e.g., 18:00
|
||||||
|
quiet_hours_end TIME, -- e.g., 08:00 (next day)
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT email_format CHECK (email_override IS NULL OR email_override ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Reminder Rules Table
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_reminders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Trigger Configuration
|
||||||
|
trigger_type VARCHAR(50) NOT NULL CHECK (trigger_type IN
|
||||||
|
('status_change', 'deadline_approaching', 'time_based')),
|
||||||
|
trigger_config JSONB NOT NULL,
|
||||||
|
-- Examples:
|
||||||
|
-- status_change: {"target_status": "i_gang", "when_changed_to": "i_gang"}
|
||||||
|
-- deadline_approaching: {"days_before": 3}
|
||||||
|
-- time_based: {}
|
||||||
|
|
||||||
|
-- Reminder Details
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN
|
||||||
|
('low', 'normal', 'high', 'urgent')),
|
||||||
|
|
||||||
|
-- Notification Delivery
|
||||||
|
notify_mattermost BOOLEAN, -- NULL = use user default
|
||||||
|
notify_email BOOLEAN, -- NULL = use user default
|
||||||
|
notify_frontend BOOLEAN, -- NULL = use user default
|
||||||
|
override_user_preferences BOOLEAN DEFAULT false,
|
||||||
|
|
||||||
|
-- Recipient Configuration
|
||||||
|
recipient_user_ids INTEGER[], -- Array of user IDs to notify
|
||||||
|
recipient_emails TEXT[], -- Additional email addresses
|
||||||
|
|
||||||
|
-- Recurrence Configuration
|
||||||
|
recurrence_type VARCHAR(20) NOT NULL DEFAULT 'once' CHECK (recurrence_type IN
|
||||||
|
('once', 'daily', 'weekly', 'monthly')),
|
||||||
|
recurrence_day_of_week INTEGER, -- 0-6 (0=Sunday) for weekly
|
||||||
|
recurrence_day_of_month INTEGER, -- 1-31 for monthly
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
scheduled_at TIMESTAMP, -- When reminder should first trigger
|
||||||
|
next_check_at TIMESTAMP, -- When to check/send next
|
||||||
|
last_sent_at TIMESTAMP,
|
||||||
|
|
||||||
|
-- State
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT has_recipients CHECK (
|
||||||
|
(recipient_user_ids IS NOT NULL AND array_length(recipient_user_ids, 1) > 0) OR
|
||||||
|
(recipient_emails IS NOT NULL AND array_length(recipient_emails, 1) > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminders_sag_id
|
||||||
|
ON sag_reminders(sag_id) WHERE is_active = true AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminders_next_check
|
||||||
|
ON sag_reminders(next_check_at)
|
||||||
|
WHERE is_active = true AND deleted_at IS NULL AND next_check_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminders_active
|
||||||
|
ON sag_reminders(is_active, deleted_at) WHERE is_active = true AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Reminder Queue Table (for trigger-based events)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_reminder_queue (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
reminder_id INTEGER NOT NULL REFERENCES sag_reminders(id) ON DELETE CASCADE,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Event that triggered this
|
||||||
|
trigger_event VARCHAR(50) NOT NULL, -- e.g., 'status_changed'
|
||||||
|
event_data JSONB, -- e.g., {"old_status": "åben", "new_status": "i_gang"}
|
||||||
|
|
||||||
|
-- Processing status
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN
|
||||||
|
('pending', 'processing', 'sent', 'failed', 'rate_limited', 'skipped')),
|
||||||
|
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT trigger_check CHECK (
|
||||||
|
trigger_event IN ('status_changed', 'deadline_reached', 'manual')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminder_queue_status
|
||||||
|
ON sag_reminder_queue(status) WHERE status IN ('pending', 'processing');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminder_queue_created
|
||||||
|
ON sag_reminder_queue(created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Reminder Execution Log Table
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_reminder_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
reminder_id INTEGER REFERENCES sag_reminders(id) ON DELETE SET NULL,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER, -- User who was notified (for rate limiting)
|
||||||
|
|
||||||
|
-- Execution Details
|
||||||
|
triggered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
channels_used TEXT[], -- Array: ['mattermost', 'email', 'frontend']
|
||||||
|
notification_payload JSONB,
|
||||||
|
|
||||||
|
-- Delivery Status
|
||||||
|
status VARCHAR(20) CHECK (status IN ('sent', 'failed', 'snoozed', 'dismissed', 'rate_limited')),
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- User Actions
|
||||||
|
acknowledged_by_user_id INTEGER,
|
||||||
|
acknowledged_at TIMESTAMP,
|
||||||
|
snoozed_until TIMESTAMP,
|
||||||
|
snoozed_by_user_id INTEGER,
|
||||||
|
dismissed_at TIMESTAMP,
|
||||||
|
dismissed_by_user_id INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT action_user_check CHECK (
|
||||||
|
(acknowledged_by_user_id IS NULL AND acknowledged_at IS NULL) OR
|
||||||
|
(acknowledged_by_user_id IS NOT NULL AND acknowledged_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminder_logs_user
|
||||||
|
ON sag_reminder_logs(user_id, triggered_at)
|
||||||
|
WHERE status = 'sent';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminder_logs_sag
|
||||||
|
ON sag_reminder_logs(sag_id, triggered_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_reminder_logs_status
|
||||||
|
ON sag_reminder_logs(status, snoozed_until)
|
||||||
|
WHERE status IN ('snoozed', 'sent');
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Trigger Functions
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Function: Check rate limiting (global per user, max 5/hour)
|
||||||
|
CREATE OR REPLACE FUNCTION check_reminder_rate_limit(user_id_param INTEGER)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
notification_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO notification_count
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
WHERE user_id = user_id_param
|
||||||
|
AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
|
||||||
|
AND status = 'sent';
|
||||||
|
|
||||||
|
RETURN notification_count < 5;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Trigger: Auto-update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_sag_reminders_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER sag_reminders_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON sag_reminders
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_sag_reminders_updated_at();
|
||||||
|
|
||||||
|
-- Trigger: Auto-update user preferences updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_user_notification_preferences_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER user_notification_preferences_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON user_notification_preferences
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_user_notification_preferences_updated_at();
|
||||||
|
|
||||||
|
-- Trigger: Status change on sag_sager triggers reminders
|
||||||
|
CREATE OR REPLACE FUNCTION sag_status_change_reminder_trigger()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
reminder RECORD;
|
||||||
|
recipient_user_id INTEGER;
|
||||||
|
v_target_status VARCHAR;
|
||||||
|
BEGIN
|
||||||
|
-- Only process if status actually changed
|
||||||
|
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Find reminders with status_change trigger for this case
|
||||||
|
FOR reminder IN
|
||||||
|
SELECT *
|
||||||
|
FROM sag_reminders
|
||||||
|
WHERE sag_id = NEW.id
|
||||||
|
AND is_active = true
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND trigger_type = 'status_change'
|
||||||
|
LOOP
|
||||||
|
v_target_status := reminder.trigger_config->>'target_status';
|
||||||
|
|
||||||
|
-- Queue event if reminder targets this new status
|
||||||
|
IF v_target_status = NEW.status THEN
|
||||||
|
INSERT INTO sag_reminder_queue (
|
||||||
|
reminder_id,
|
||||||
|
sag_id,
|
||||||
|
trigger_event,
|
||||||
|
event_data,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
reminder.id,
|
||||||
|
NEW.id,
|
||||||
|
'status_changed',
|
||||||
|
jsonb_build_object(
|
||||||
|
'old_status', OLD.status,
|
||||||
|
'new_status', NEW.status,
|
||||||
|
'changed_at', CURRENT_TIMESTAMP
|
||||||
|
),
|
||||||
|
'pending'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger on sag_sager
|
||||||
|
DROP TRIGGER IF EXISTS sag_status_change_reminder_trigger_exec ON sag_sager;
|
||||||
|
CREATE TRIGGER sag_status_change_reminder_trigger_exec
|
||||||
|
AFTER UPDATE OF status ON sag_sager
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION sag_status_change_reminder_trigger();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Helper Views
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- View: Pending reminders ready to send
|
||||||
|
CREATE OR REPLACE VIEW v_pending_reminders AS
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.sag_id,
|
||||||
|
r.title,
|
||||||
|
r.message,
|
||||||
|
r.priority,
|
||||||
|
r.recipient_user_ids,
|
||||||
|
r.recipient_emails,
|
||||||
|
r.notify_mattermost,
|
||||||
|
r.notify_email,
|
||||||
|
r.notify_frontend,
|
||||||
|
r.override_user_preferences,
|
||||||
|
r.trigger_type,
|
||||||
|
r.trigger_config,
|
||||||
|
r.recurrence_type,
|
||||||
|
r.scheduled_at,
|
||||||
|
r.next_check_at
|
||||||
|
FROM sag_reminders r
|
||||||
|
WHERE r.is_active = true
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND r.next_check_at IS NOT NULL
|
||||||
|
AND r.next_check_at <= CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- View: Pending queue events
|
||||||
|
CREATE OR REPLACE VIEW v_pending_reminder_queue AS
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
q.reminder_id,
|
||||||
|
q.sag_id,
|
||||||
|
q.trigger_event,
|
||||||
|
q.event_data,
|
||||||
|
r.recipient_user_ids,
|
||||||
|
r.recipient_emails,
|
||||||
|
r.notify_mattermost,
|
||||||
|
r.notify_email,
|
||||||
|
r.notify_frontend,
|
||||||
|
r.override_user_preferences,
|
||||||
|
r.title,
|
||||||
|
r.message,
|
||||||
|
r.priority
|
||||||
|
FROM sag_reminder_queue q
|
||||||
|
JOIN sag_reminders r ON q.reminder_id = r.id
|
||||||
|
WHERE q.status = 'pending'
|
||||||
|
AND r.is_active = true
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
ORDER BY r.priority DESC, q.created_at ASC;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Comments
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE sag_reminders IS 'Defines reminder rules for cases (triggers, recipients, notifications)';
|
||||||
|
COMMENT ON TABLE sag_reminder_queue IS 'Queues reminder events from trigger functions for processing';
|
||||||
|
COMMENT ON TABLE sag_reminder_logs IS 'Logs all reminder notifications sent, including user interactions';
|
||||||
|
COMMENT ON TABLE user_notification_preferences IS 'User default notification preferences (can be overridden per reminder)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN sag_reminders.trigger_config IS 'JSON config for trigger type (e.g. {"target_status": "i_gang"})';
|
||||||
|
COMMENT ON COLUMN sag_reminders.recipient_user_ids IS 'PostgreSQL array of user IDs to notify';
|
||||||
|
COMMENT ON COLUMN sag_reminders.next_check_at IS 'When this reminder should be checked/sent next';
|
||||||
|
COMMENT ON COLUMN sag_reminder_logs.user_id IS 'User who received notification (for rate limiting)';
|
||||||
|
COMMENT ON FUNCTION check_reminder_rate_limit IS 'Returns true if user has sent <5 notifications in past hour';
|
||||||
@ -8,13 +8,4 @@ python-multipart==0.0.17
|
|||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
aiohttp==3.10.10
|
aiohttp==3.10.10
|
||||||
cryptography==42.0.8
|
aiosmtplib==3.0.2
|
||||||
msal==1.31.1
|
|
||||||
paramiko==3.4.1
|
|
||||||
apscheduler==3.10.4
|
|
||||||
pandas==2.2.3
|
|
||||||
openpyxl==3.1.2
|
|
||||||
extract-msg==0.55.0
|
|
||||||
pdfplumber==0.11.4
|
|
||||||
passlib[bcrypt]==1.7.4
|
|
||||||
pyotp==2.9.0
|
|
||||||
|
|||||||
432
static/js/notifications.js
Normal file
432
static/js/notifications.js
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
/**
|
||||||
|
* BMC Hub Reminder Notifications System
|
||||||
|
*
|
||||||
|
* Frontend system for displaying reminder popups using Bootstrap 5 Toast
|
||||||
|
* Polls API endpoint for pending reminders every 30 seconds
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ReminderNotifications {
|
||||||
|
constructor() {
|
||||||
|
this.pollingInterval = 30000; // 30 seconds
|
||||||
|
this.isPolling = false;
|
||||||
|
this.userId = this._getUserId();
|
||||||
|
this.toastContainer = null;
|
||||||
|
this.shownTtlMs = 10 * 60 * 1000; // 10 minutes
|
||||||
|
this.shownCache = new Map();
|
||||||
|
this.priorityColors = {
|
||||||
|
'low': '#6c757d',
|
||||||
|
'normal': '#0f4c75',
|
||||||
|
'high': '#ffc107',
|
||||||
|
'urgent': '#dc3545'
|
||||||
|
};
|
||||||
|
this.priorityLabels = {
|
||||||
|
'low': 'Lav',
|
||||||
|
'normal': 'Normal',
|
||||||
|
'high': 'Høj',
|
||||||
|
'urgent': 'Kritisk'
|
||||||
|
};
|
||||||
|
this.snoozeOptions = [
|
||||||
|
{ label: '15 min', minutes: 15 },
|
||||||
|
{ label: '30 min', minutes: 30 },
|
||||||
|
{ label: '1 time', minutes: 60 },
|
||||||
|
{ label: '4 timer', minutes: 240 },
|
||||||
|
{ label: '1 dag', minutes: 1440 },
|
||||||
|
{ label: 'Skjul...', minutes: 'custom' }
|
||||||
|
];
|
||||||
|
|
||||||
|
this._initToastContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the toast container on page load
|
||||||
|
*/
|
||||||
|
_initToastContainer() {
|
||||||
|
// Check if container already exists
|
||||||
|
let container = document.getElementById('reminder-toast-container');
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'reminder-toast-container';
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
|
container.setAttribute('aria-atomic', 'true');
|
||||||
|
container.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toastContainer = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for reminders
|
||||||
|
*/
|
||||||
|
startPolling() {
|
||||||
|
if (this.isPolling) return;
|
||||||
|
|
||||||
|
this.isPolling = true;
|
||||||
|
console.log('🔔 Reminder polling started');
|
||||||
|
|
||||||
|
// Check immediately first
|
||||||
|
this._checkReminders();
|
||||||
|
|
||||||
|
// Then poll at intervals
|
||||||
|
this.pollingIntervalId = setInterval(() => {
|
||||||
|
this._checkReminders();
|
||||||
|
}, this.pollingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop polling for reminders
|
||||||
|
*/
|
||||||
|
stopPolling() {
|
||||||
|
if (this.pollingIntervalId) {
|
||||||
|
clearInterval(this.pollingIntervalId);
|
||||||
|
}
|
||||||
|
this.isPolling = false;
|
||||||
|
console.log('🛑 Reminder polling stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch pending reminders from API
|
||||||
|
*/
|
||||||
|
async _checkReminders() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/reminders/pending/me?user_id=${this.userId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status !== 401) { // Don't log 401 errors
|
||||||
|
console.error(`Reminder check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminders = await response.json();
|
||||||
|
|
||||||
|
if (reminders && reminders.length > 0) {
|
||||||
|
reminders.forEach(reminder => {
|
||||||
|
if (this._shouldShowReminder(reminder)) {
|
||||||
|
this.showReminder(reminder);
|
||||||
|
this._markShown(reminder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error checking reminders:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_shouldShowReminder(reminder) {
|
||||||
|
if (!reminder || !reminder.id) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// In-memory cache check
|
||||||
|
const lastShown = this.shownCache.get(reminder.id);
|
||||||
|
if (lastShown && (now - lastShown) < this.shownTtlMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-tab/localStorage check
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`reminder_shown_${reminder.id}`);
|
||||||
|
if (stored) {
|
||||||
|
const ts = parseInt(stored, 10);
|
||||||
|
if (!Number.isNaN(ts) && (now - ts) < this.shownTtlMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_markShown(reminder) {
|
||||||
|
if (!reminder || !reminder.id) return;
|
||||||
|
const now = Date.now();
|
||||||
|
this.shownCache.set(reminder.id, now);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`reminder_shown_${reminder.id}`, String(now));
|
||||||
|
} catch (e) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a single reminder as a toast
|
||||||
|
*/
|
||||||
|
showReminder(reminder) {
|
||||||
|
const toastId = `reminder-${reminder.id}-${Date.now()}`;
|
||||||
|
const priorityColor = this.priorityColors[reminder.priority] || this.priorityColors['normal'];
|
||||||
|
const priorityLabel = this.priorityLabels[reminder.priority] || 'Info';
|
||||||
|
|
||||||
|
// Build snooze dropdown HTML
|
||||||
|
const snoozeOptionsHtml = this.snoozeOptions
|
||||||
|
.map(opt => `<button class="dropdown-item" data-snooze-minutes="${opt.minutes}">${opt.label}</button>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const toastHTML = `
|
||||||
|
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true"
|
||||||
|
style="background: white; border-left: 4px solid ${priorityColor}; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);">
|
||||||
|
|
||||||
|
<div class="toast-header" style="border-bottom: 1px solid #e9ecef; padding: 12px 16px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<strong class="me-2" style="color: ${priorityColor};">
|
||||||
|
🔔 ${reminder.title}
|
||||||
|
</strong>
|
||||||
|
<small class="text-muted">${priorityLabel}</small>
|
||||||
|
</div>
|
||||||
|
<div class="ms-2 text-muted" style="font-size: 12px;">
|
||||||
|
${new Date().toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-sm ms-2" data-bs-dismiss="toast" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-body" style="padding: 12px 16px;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="mb-1"><strong>${reminder.case_title}</strong> (#${reminder.sag_id})</p>
|
||||||
|
<p class="mb-1 text-muted" style="font-size: 14px;">${reminder.customer_name}</p>
|
||||||
|
${reminder.message ? `<p class="mb-2" style="font-size: 14px;">${reminder.message}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<a href="/sag/${reminder.sag_id}" class="btn btn-sm btn-primary" style="background: ${priorityColor}; border-color: ${priorityColor};">
|
||||||
|
➜ Åbn sag
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
💤 Slumre
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" style="min-width: 150px;">
|
||||||
|
${snoozeOptionsHtml}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><input type="number" class="form-control form-control-sm mx-2 my-1"
|
||||||
|
id="custom-snooze-${toastId}" min="1" max="1440" placeholder="Minutter"></li>
|
||||||
|
<li><button class="dropdown-item btn-custom-snooze" data-toast-id="${toastId}">
|
||||||
|
Slumre (custom)
|
||||||
|
</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-outline-danger" data-dismiss-reminder="${reminder.id}">
|
||||||
|
✕ Afvis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const toastElement = parser.parseFromString(toastHTML, 'text/html').body.firstChild;
|
||||||
|
this.toastContainer.appendChild(toastElement);
|
||||||
|
|
||||||
|
// Initialize Bootstrap toast
|
||||||
|
const bootstrapToast = new bootstrap.Toast(toastElement, {
|
||||||
|
autohide: false,
|
||||||
|
delay: 10000
|
||||||
|
});
|
||||||
|
bootstrapToast.show();
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
this._attachReminderEventListeners(toastElement, reminder.id, toastId);
|
||||||
|
|
||||||
|
// Auto-remove after dismissal
|
||||||
|
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||||
|
toastElement.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach event listeners to reminder toast
|
||||||
|
*/
|
||||||
|
_attachReminderEventListeners(toastElement, reminderId, toastId) {
|
||||||
|
// Snooze buttons
|
||||||
|
toastElement.querySelectorAll('[data-snooze-minutes]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const minutes = btn.dataset.snoozeMinutes;
|
||||||
|
|
||||||
|
if (minutes === 'custom') {
|
||||||
|
// Show custom input
|
||||||
|
toastElement.querySelector(`#custom-snooze-${toastId}`)?.focus();
|
||||||
|
} else {
|
||||||
|
await this._snoozeReminder(reminderId, parseInt(minutes), toastElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom snooze button
|
||||||
|
toastElement.querySelectorAll('.btn-custom-snooze').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = toastElement.querySelector(`#custom-snooze-${toastId}`);
|
||||||
|
const minutes = parseInt(input.value);
|
||||||
|
|
||||||
|
if (minutes >= 1 && minutes <= 1440) {
|
||||||
|
await this._snoozeReminder(reminderId, minutes, toastElement);
|
||||||
|
} else {
|
||||||
|
alert('Indtast venligst mellem 1 og 1440 minutter');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss button
|
||||||
|
toastElement.querySelectorAll('[data-dismiss-reminder]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await this._dismissReminder(reminderId, toastElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snooze a reminder
|
||||||
|
*/
|
||||||
|
async _snoozeReminder(reminderId, minutes, toastElement) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag/reminders/${reminderId}/snooze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
duration_minutes: minutes
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`✅ Reminder ${reminderId} snoozed for ${minutes} minutes`);
|
||||||
|
|
||||||
|
// Hide the toast
|
||||||
|
const toast = bootstrap.Toast.getInstance(toastElement);
|
||||||
|
if (toast) toast.hide();
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to snooze reminder: ${response.status}`);
|
||||||
|
alert('Kunne ikke slumre reminder');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error snoozing reminder:', error);
|
||||||
|
alert('Fejl ved slumre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss a reminder permanently
|
||||||
|
*/
|
||||||
|
async _dismissReminder(reminderId, toastElement) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag/reminders/${reminderId}/dismiss`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
reason: 'Dismissed by user'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`✅ Reminder ${reminderId} dismissed`);
|
||||||
|
|
||||||
|
// Hide the toast
|
||||||
|
const toast = bootstrap.Toast.getInstance(toastElement);
|
||||||
|
if (toast) toast.hide();
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to dismiss reminder: ${response.status}`);
|
||||||
|
alert('Kunne ikke afvise reminder');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error dismissing reminder:', error);
|
||||||
|
alert('Fejl ved afvisning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user ID from auth token or page context
|
||||||
|
*/
|
||||||
|
_getUserId() {
|
||||||
|
// Try to get from localStorage/sessionStorage if available
|
||||||
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
// Decode JWT payload
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.sub || payload.user_id;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not decode token for user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from page meta tag
|
||||||
|
const metaTag = document.querySelector('meta[name="user-id"]');
|
||||||
|
if (metaTag) {
|
||||||
|
return metaTag.getAttribute('content');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('⚠️ Could not determine user_id for reminders');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
let reminderNotifications = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize reminder system when DOM is ready
|
||||||
|
*/
|
||||||
|
function initReminderNotifications() {
|
||||||
|
if (!reminderNotifications) {
|
||||||
|
reminderNotifications = new ReminderNotifications();
|
||||||
|
|
||||||
|
// Only start polling if user is authenticated
|
||||||
|
if (reminderNotifications.userId) {
|
||||||
|
reminderNotifications.startPolling();
|
||||||
|
console.log('✅ Reminder system initialized');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Reminder system not initialized - user not authenticated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-init when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initReminderNotifications);
|
||||||
|
} else {
|
||||||
|
initReminderNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle page visibility (pause polling when tab not visible)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (reminderNotifications) {
|
||||||
|
if (document.hidden) {
|
||||||
|
reminderNotifications.stopPolling();
|
||||||
|
console.log('💤 Reminder polling paused (tab hidden)');
|
||||||
|
} else {
|
||||||
|
reminderNotifications.startPolling();
|
||||||
|
console.log('👀 Reminder polling resumed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for manual control if needed
|
||||||
|
window.ReminderNotifications = ReminderNotifications;
|
||||||
@ -155,6 +155,7 @@ class TagPicker {
|
|||||||
const response = await fetch('/api/v1/tags?is_active=true');
|
const response = await fetch('/api/v1/tags?is_active=true');
|
||||||
if (!response.ok) throw new Error('Failed to load tags');
|
if (!response.ok) throw new Error('Failed to load tags');
|
||||||
this.allTags = await response.json();
|
this.allTags = await response.json();
|
||||||
|
this.tagGroups = await this.loadTagGroups();
|
||||||
console.log('🏷️ Loaded tags:', this.allTags.length);
|
console.log('🏷️ Loaded tags:', this.allTags.length);
|
||||||
this.filteredTags = [...this.allTags];
|
this.filteredTags = [...this.allTags];
|
||||||
this.renderResults();
|
this.renderResults();
|
||||||
@ -163,6 +164,17 @@ class TagPicker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadTagGroups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/tags/groups');
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('🏷️ Error loading tag groups:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
filterTags(query) {
|
filterTags(query) {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
this.filteredTags = [...this.allTags];
|
this.filteredTags = [...this.allTags];
|
||||||
@ -215,6 +227,8 @@ class TagPicker {
|
|||||||
grouped[type].forEach((tag, index) => {
|
grouped[type].forEach((tag, index) => {
|
||||||
const globalIndex = this.filteredTags.indexOf(tag);
|
const globalIndex = this.filteredTags.indexOf(tag);
|
||||||
const isSelected = globalIndex === this.selectedIndex;
|
const isSelected = globalIndex === this.selectedIndex;
|
||||||
|
const group = this.tagGroups?.find(g => g.id === tag.tag_group_id);
|
||||||
|
const groupHint = group ? ` · ${group.name} (${group.behavior})` : '';
|
||||||
html += `
|
html += `
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="list-group-item list-group-item-action d-flex align-items-center ${isSelected ? 'active' : ''}"
|
class="list-group-item list-group-item-action d-flex align-items-center ${isSelected ? 'active' : ''}"
|
||||||
@ -226,7 +240,7 @@ class TagPicker {
|
|||||||
</span>
|
</span>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="fw-bold" style="font-size: 0.95rem;">${tag.name}</div>
|
<div class="fw-bold" style="font-size: 0.95rem;">${tag.name}</div>
|
||||||
${tag.description ? `<small class="${isSelected ? 'text-white-50' : 'text-muted'}" style="font-size: 0.8rem;">${tag.description}</small>` : ''}
|
${tag.description ? `<small class="${isSelected ? 'text-white-50' : 'text-muted'}" style="font-size: 0.8rem;">${tag.description}${groupHint}</small>` : groupHint ? `<small class="${isSelected ? 'text-white-50' : 'text-muted'}" style="font-size: 0.8rem;">${groupHint}</small>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${!isSelected ? `<i class="bi bi-plus-circle text-primary" style="font-size: 1.2rem;"></i>` : ''}
|
${!isSelected ? `<i class="bi bi-plus-circle text-primary" style="font-size: 1.2rem;"></i>` : ''}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
133
templates/emails/reminder.html
Normal file
133
templates/emails/reminder.html
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: #0f4c75;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
}
|
||||||
|
.reminder-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #0f4c75;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.case-info {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 12px;
|
||||||
|
border-left: 4px solid #0f4c75;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.case-info strong {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.priority-low { color: #6c757d; }
|
||||||
|
.priority-normal { color: #0f4c75; }
|
||||||
|
.priority-high { color: #ffc107; }
|
||||||
|
.priority-urgent { color: #dc3545; }
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #0f4c75;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.snooze-info {
|
||||||
|
background: #e8f4f8;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 15px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h2>{{ header_title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="reminder-title priority-{{ priority | lower }}">
|
||||||
|
{{ reminder_title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if reminder_message %}
|
||||||
|
<p>{{ reminder_message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="case-info">
|
||||||
|
<strong>Sag:</strong> {{ case_title }}<br>
|
||||||
|
<strong>ID:</strong> #{{ case_id }}<br>
|
||||||
|
<strong>Status:</strong> {{ case_status }}<br>
|
||||||
|
<strong>Kunde:</strong> {{ customer_name }}<br>
|
||||||
|
{% if deadline %}
|
||||||
|
<strong>Deadline:</strong> {{ deadline | date("d. MMM yyyy") }}<br>
|
||||||
|
{% endif %}
|
||||||
|
{% if assigned_user %}
|
||||||
|
<strong>Ansvarlig:</strong> {{ assigned_user }}<br>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if additional_info %}
|
||||||
|
<p>
|
||||||
|
<strong>Detaljer:</strong><br>
|
||||||
|
{{ additional_info }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{ action_url }}" class="button">
|
||||||
|
Åbn sag i BMC Hub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="snooze-info">
|
||||||
|
💡 <strong>Tip:</strong> Du kan slumre eller afvise denne reminder i BMC Hub-systemet eller via popup-notifikationen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>
|
||||||
|
Dette er en automatisk reminder fra BMC Hub.<br>
|
||||||
|
Du modtager denne mail fordi du er tilføjet som modtager for denne sags reminders.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
BMC Networks • {{ footer_date }}<br>
|
||||||
|
<em>Denne email blev sendt automatisk - svar venligst ikke direkte.</em>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user