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:
Christian 2026-02-06 10:47:14 +01:00
parent b06ff693df
commit b43e9f797d
30 changed files with 6036 additions and 533 deletions

View 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

View 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

View 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.

View File

@ -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"""

View File

@ -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) {

View File

@ -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() {

View File

@ -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
View 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

View 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))

View File

@ -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}")

View File

@ -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

View File

@ -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>

View File

@ -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,

View File

@ -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

View 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()

View File

@ -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;">

View File

@ -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):

View File

@ -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
View File

@ -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"])

View 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;

View 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;

View 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();

View 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;

View 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;

View 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';

View File

@ -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
View 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;

View File

@ -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>

View 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>