diff --git a/# GitHub Copilot Instructions - BMC Webs.prompt.md b/# GitHub Copilot Instructions - BMC Webs.prompt.md new file mode 100644 index 0000000..002f0a3 --- /dev/null +++ b/# GitHub Copilot Instructions - BMC Webs.prompt.md @@ -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 diff --git a/REMINDER_SYSTEM_IMPLEMENTATION.md b/REMINDER_SYSTEM_IMPLEMENTATION.md new file mode 100644 index 0000000..caddbf5 --- /dev/null +++ b/REMINDER_SYSTEM_IMPLEMENTATION.md @@ -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= +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 diff --git a/REMINDER_SYSTEM_QUICKSTART.md b/REMINDER_SYSTEM_QUICKSTART.md new file mode 100644 index 0000000..5d8260b --- /dev/null +++ b/REMINDER_SYSTEM_QUICKSTART.md @@ -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. diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index e4660f5..d862e7b 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -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""" diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index 906022a..12b46bf 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -287,6 +287,73 @@ + + + {% 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) { diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html index 0045c20..a4b404f 100644 --- a/app/contacts/frontend/contacts.html +++ b/app/contacts/frontend/contacts.html @@ -215,6 +215,74 @@ + + + {% 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() { diff --git a/app/core/config.py b/app/core/config.py index 1a67ddf..da79d7c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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" diff --git a/app/jobs/check_reminders.py b/app/jobs/check_reminders.py new file mode 100644 index 0000000..9740771 --- /dev/null +++ b/app/jobs/check_reminders.py @@ -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 diff --git a/app/modules/sag/backend/reminders.py b/app/modules/sag/backend/reminders.py new file mode 100644 index 0000000..5a7561a --- /dev/null +++ b/app/modules/sag/backend/reminders.py @@ -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)) diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index afadca9..0c3e734 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -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}") diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index ff5f810..2f20922 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -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 diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 7ab82ae..557fb1b 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -4,79 +4,6 @@ {% block extra_css %} + + +
+
+

{{ header_title }}

+
+
+
+ {{ reminder_title }} +
+ + {% if reminder_message %} +

{{ reminder_message }}

+ {% endif %} + +
+ Sag: {{ case_title }}
+ ID: #{{ case_id }}
+ Status: {{ case_status }}
+ Kunde: {{ customer_name }}
+ {% if deadline %} + Deadline: {{ deadline | date("d. MMM yyyy") }}
+ {% endif %} + {% if assigned_user %} + Ansvarlig: {{ assigned_user }}
+ {% endif %} +
+ + {% if additional_info %} +

+ Detaljer:
+ {{ additional_info }} +

+ {% endif %} + +
+ + Åbn sag i BMC Hub + +
+ +
+ 💡 Tip: Du kan slumre eller afvise denne reminder i BMC Hub-systemet eller via popup-notifikationen. +
+ + +
+
+ +