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
|
||||
|
||||
|
||||
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):
|
||||
customer_id: int
|
||||
is_primary: bool = True
|
||||
@ -232,6 +244,47 @@ async def get_contact(contact_id: int):
|
||||
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")
|
||||
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
||||
"""Link a contact to a company"""
|
||||
|
||||
@ -287,6 +287,73 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
@ -496,8 +563,64 @@ async function removeCompany(customerId) {
|
||||
}
|
||||
|
||||
function editContact() {
|
||||
// TODO: Open edit modal with pre-filled data
|
||||
console.log('Edit contact:', contactId);
|
||||
// Fill form with current contact data
|
||||
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) {
|
||||
|
||||
@ -215,6 +215,74 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
@ -379,8 +447,78 @@ function viewContact(contactId) {
|
||||
}
|
||||
|
||||
function editContact(contactId) {
|
||||
// TODO: Open edit modal
|
||||
console.log('Edit contact:', contactId);
|
||||
// Load contact data and open edit modal
|
||||
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() {
|
||||
|
||||
@ -161,6 +161,24 @@ class Settings(BaseSettings):
|
||||
MATTERMOST_ENABLED: bool = False
|
||||
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)
|
||||
POSTGRES_USER: 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 = """
|
||||
INSERT INTO sag_sager
|
||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(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, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
@ -145,6 +145,9 @@ async def create_sag(data: dict):
|
||||
data.get("ansvarlig_bruger_id"),
|
||||
data.get("created_by_user_id", 1),
|
||||
data.get("deadline"),
|
||||
data.get("deferred_until"),
|
||||
data.get("deferred_until_case_id"),
|
||||
data.get("deferred_until_status"),
|
||||
)
|
||||
|
||||
result = execute_query(query, params)
|
||||
@ -171,6 +174,44 @@ async def get_sag(sag_id: int):
|
||||
logger.error("❌ Error getting case: %s", e)
|
||||
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}")
|
||||
async def update_sag(sag_id: int, updates: dict):
|
||||
"""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")
|
||||
|
||||
# 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 = []
|
||||
params = []
|
||||
|
||||
@ -473,9 +514,25 @@ async def list_case_contacts(sag_id: int):
|
||||
"""List contacts associated with a case."""
|
||||
try:
|
||||
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
|
||||
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
|
||||
"""
|
||||
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")
|
||||
|
||||
role = data.get("role", "Kontakt")
|
||||
is_primary = bool(data.get("is_primary", False))
|
||||
|
||||
# Check if already exists
|
||||
check = execute_query(
|
||||
@ -501,12 +559,18 @@ async def add_case_contact(sag_id: int, data: dict):
|
||||
if check:
|
||||
return check[0] # Already linked
|
||||
|
||||
if is_primary:
|
||||
execute_update(
|
||||
"UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s",
|
||||
(sag_id,)
|
||||
)
|
||||
|
||||
query = """
|
||||
INSERT INTO sag_kontakter (sag_id, contact_id, role)
|
||||
VALUES (%s, %s, %s)
|
||||
INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
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:
|
||||
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")
|
||||
|
||||
|
||||
@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
|
||||
# ============================================================================
|
||||
@ -1187,8 +1303,12 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
||||
return saved_files
|
||||
|
||||
@router.get("/sag/{sag_id}/files/{file_id}")
|
||||
async def download_sag_file(sag_id: int, file_id: int):
|
||||
"""Download a specific file."""
|
||||
async def download_sag_file(sag_id: int, file_id: int, download: bool = False):
|
||||
"""Download or preview a specific file.
|
||||
|
||||
Args:
|
||||
download: If True, force download. If False (default), display inline in browser.
|
||||
"""
|
||||
query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s"
|
||||
result = execute_query(query, (file_id, sag_id))
|
||||
|
||||
@ -1200,11 +1320,19 @@ async def download_sag_file(sag_id: int, file_id: int):
|
||||
|
||||
if not path.exists():
|
||||
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(
|
||||
path=path,
|
||||
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}")
|
||||
|
||||
@ -17,6 +17,7 @@ async def sager_liste(
|
||||
status: str = Query(None),
|
||||
tag: str = Query(None),
|
||||
customer_id: int = Query(None),
|
||||
include_deferred: bool = Query(False),
|
||||
):
|
||||
"""Display list of all cases."""
|
||||
try:
|
||||
@ -34,9 +35,14 @@ async def sager_liste(
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
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
|
||||
"""
|
||||
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:
|
||||
query += " AND s.status = %s"
|
||||
@ -94,6 +100,10 @@ async def sager_liste(
|
||||
# Fetch all distinct statuses and tags for filters
|
||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
||||
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
|
||||
|
||||
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", {
|
||||
"request": request,
|
||||
@ -104,6 +114,8 @@ async def sager_liste(
|
||||
"all_tags": [t['tag_navn'] for t in all_tags],
|
||||
"current_status": status,
|
||||
"current_tag": tag,
|
||||
"include_deferred": include_deferred,
|
||||
"toggle_include_deferred_url": toggle_include_deferred_url,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("❌ Error displaying case list: %s", e)
|
||||
@ -257,23 +269,36 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
customer_result = execute_query(customer_query, (sag['customer_id'],))
|
||||
if customer_result:
|
||||
customer = customer_result[0]
|
||||
|
||||
# Fetch hovedkontakt (primary contact) for customer via contact_companies
|
||||
kontakt_query = """
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON c.id = cc.contact_id
|
||||
WHERE cc.customer_id = %s
|
||||
ORDER BY cc.is_primary DESC, c.id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
kontakt_result = execute_query(kontakt_query, (sag['customer_id'],))
|
||||
if kontakt_result:
|
||||
hovedkontakt = kontakt_result[0]
|
||||
|
||||
# Fetch prepaid cards for customer
|
||||
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
|
||||
# DEBUG: Logging customer ID
|
||||
# Fetch hovedkontakt (primary contact) for case via sag_kontakter
|
||||
kontakt_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 AND sk.is_primary = TRUE
|
||||
LIMIT 1
|
||||
"""
|
||||
kontakt_result = execute_query(kontakt_query, (sag_id,))
|
||||
if kontakt_result:
|
||||
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
|
||||
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
|
||||
# DEBUG: Logging customer ID
|
||||
prepaid_cards = []
|
||||
if 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)})")
|
||||
|
||||
@ -287,8 +312,6 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"""
|
||||
prepaid_cards = execute_query(pc_query, (cid,))
|
||||
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
|
||||
else:
|
||||
prepaid_cards = []
|
||||
|
||||
# Fetch Nextcloud Instance for this customer
|
||||
nextcloud_instance = None
|
||||
@ -300,9 +323,24 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
|
||||
# Fetch linked contacts
|
||||
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
|
||||
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
|
||||
"""
|
||||
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)
|
||||
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", {
|
||||
"request": request,
|
||||
"case": sag,
|
||||
@ -351,6 +406,8 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"time_entries": time_entries,
|
||||
"is_nextcloud": is_nextcloud,
|
||||
"nextcloud_instance": nextcloud_instance,
|
||||
"related_case_options": related_case_options,
|
||||
"status_options": [s["status"] for s in statuses],
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -298,6 +298,9 @@
|
||||
<option value="all">Alle typer</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<!-- Table -->
|
||||
@ -312,6 +315,7 @@
|
||||
<th style="width: 180px;">Kunde</th>
|
||||
<th style="width: 150px;">Hovedkontakt</th>
|
||||
<th style="width: 100px;">Status</th>
|
||||
<th style="width: 120px;">Udsat start</th>
|
||||
<th style="width: 120px;">Oprettet</th>
|
||||
<th style="width: 120px;">Opdateret</th>
|
||||
</tr>
|
||||
@ -348,6 +352,9 @@
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
||||
</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);">
|
||||
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
||||
</td>
|
||||
@ -387,6 +394,9 @@
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
||||
</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);">
|
||||
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
||||
</td>
|
||||
|
||||
@ -5,7 +5,7 @@ Backend endpoints for webshop administration og konfiguration
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
@ -66,6 +66,13 @@ class WebshopProductCreate(BaseModel):
|
||||
custom_margin_percent: Optional[float] = None
|
||||
visible: bool = True
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@ -387,6 +394,13 @@ async def add_webshop_product(product: WebshopProductCreate):
|
||||
Tilføj produkt til webshop whitelist
|
||||
"""
|
||||
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 = """
|
||||
INSERT INTO webshop_products (
|
||||
webshop_config_id, product_number, ean, name, description,
|
||||
|
||||
@ -2,17 +2,29 @@
|
||||
Email Service
|
||||
Handles email fetching from IMAP or Microsoft Graph API
|
||||
Based on OmniSync architecture - READ-ONLY mode for safety
|
||||
Also handles outbound SMTP email sending for reminders and notifications
|
||||
"""
|
||||
|
||||
import logging
|
||||
import imaplib
|
||||
import email
|
||||
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 datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
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
|
||||
import msal
|
||||
|
||||
@ -852,3 +864,92 @@ class EmailService:
|
||||
logger.error(f"❌ Failed to save uploaded email: {e}")
|
||||
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>
|
||||
</a>
|
||||
<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="/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>
|
||||
@ -516,6 +516,7 @@
|
||||
|
||||
<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/notifications.js?v=1.0"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||
@ -1046,6 +1047,226 @@
|
||||
});
|
||||
</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 -->
|
||||
<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;">
|
||||
|
||||
@ -7,6 +7,26 @@ from datetime import datetime
|
||||
|
||||
# Tag types
|
||||
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):
|
||||
"""Base tag model"""
|
||||
@ -16,6 +36,7 @@ class TagBase(BaseModel):
|
||||
color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$') # Hex color
|
||||
icon: Optional[str] = None
|
||||
is_active: bool = True
|
||||
tag_group_id: Optional[int] = None
|
||||
|
||||
class TagCreate(TagBase):
|
||||
"""Tag creation model"""
|
||||
@ -37,6 +58,7 @@ class TagUpdate(BaseModel):
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
icon: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
tag_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class EntityTagBase(BaseModel):
|
||||
|
||||
@ -7,12 +7,31 @@ from app.tags.backend.models import (
|
||||
Tag, TagCreate, TagUpdate,
|
||||
EntityTag, EntityTagCreate,
|
||||
TagWorkflow, TagWorkflowCreate,
|
||||
TagType
|
||||
TagType,
|
||||
TagGroup, TagGroupCreate
|
||||
)
|
||||
from app.core.database import execute_query, execute_query_single, execute_update
|
||||
|
||||
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 =============
|
||||
|
||||
@router.get("", response_model=List[Tag])
|
||||
@ -52,13 +71,13 @@ async def get_tag(tag_id: int):
|
||||
async def create_tag(tag: TagCreate):
|
||||
"""Create new tag"""
|
||||
query = """
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
result = execute_query_single(
|
||||
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
|
||||
|
||||
@ -84,6 +103,9 @@ async def update_tag(tag_id: int, tag: TagUpdate):
|
||||
if tag.is_active is not None:
|
||||
updates.append("is_active = %s")
|
||||
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:
|
||||
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)
|
||||
async def add_tag_to_entity(entity_tag: EntityTagCreate):
|
||||
"""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 = """
|
||||
INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by)
|
||||
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.frontend import views as webshop_views
|
||||
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.hardware.backend import router as hardware_module_api
|
||||
from app.modules.hardware.frontend import views as hardware_module_views
|
||||
@ -96,9 +97,23 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
init_db()
|
||||
|
||||
# Start unified scheduler (handles backups + email fetch)
|
||||
# Start unified scheduler (handles backups + email fetch + reminders)
|
||||
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")
|
||||
yield
|
||||
# Shutdown
|
||||
@ -165,6 +180,13 @@ async def auth_middleware(request: Request, call_next):
|
||||
)
|
||||
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 not payload.get("sub"):
|
||||
from fastapi.responses import JSONResponse
|
||||
@ -220,6 +242,7 @@ app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
|
||||
# Module Routers
|
||||
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_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(locations_api, prefix="/api/v1", tags=["Locations"])
|
||||
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
|
||||
jinja2==3.1.4
|
||||
aiohttp==3.10.10
|
||||
cryptography==42.0.8
|
||||
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
|
||||
aiosmtplib==3.0.2
|
||||
|
||||
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');
|
||||
if (!response.ok) throw new Error('Failed to load tags');
|
||||
this.allTags = await response.json();
|
||||
this.tagGroups = await this.loadTagGroups();
|
||||
console.log('🏷️ Loaded tags:', this.allTags.length);
|
||||
this.filteredTags = [...this.allTags];
|
||||
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) {
|
||||
if (!query.trim()) {
|
||||
this.filteredTags = [...this.allTags];
|
||||
@ -215,6 +227,8 @@ class TagPicker {
|
||||
grouped[type].forEach((tag, index) => {
|
||||
const globalIndex = this.filteredTags.indexOf(tag);
|
||||
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 += `
|
||||
<a href="#"
|
||||
class="list-group-item list-group-item-action d-flex align-items-center ${isSelected ? 'active' : ''}"
|
||||
@ -226,7 +240,7 @@ class TagPicker {
|
||||
</span>
|
||||
<div class="flex-grow-1">
|
||||
<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>
|
||||
${!isSelected ? `<i class="bi bi-plus-circle text-primary" style="font-size: 1.2rem;"></i>` : ''}
|
||||
</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