From 29aadfb774a089c69d6f062015a41355b6273778 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 8 Dec 2025 15:23:17 +0000 Subject: [PATCH] Added template system to notification module. --- PLAN.md | 371 +- .../app/api/routes/notification_templates.py | 148 + backend/app/core/database.py | 31 +- backend/app/main.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/notification_template.py | 90 + backend/app/schemas/notification_template.py | 166 + backend/app/services/notification_service.py | 266 +- frontend/src/api/client.ts | 53 + .../src/components/AddNotificationModal.tsx | 79 + .../components/NotificationProviderCard.tsx | 11 +- .../components/NotificationTemplateEditor.tsx | 276 + frontend/src/pages/SettingsPage.tsx | 221 +- static/assets/index-6YKRUSex.js | 8611 ----------------- static/assets/index-BP1T2ML7.css | 1 - static/assets/index-BomzirkH.css | 1 + static/assets/index-C99F9s0L.js | 8611 +++++++++++++++++ static/index.html | 4 +- 18 files changed, 9962 insertions(+), 8983 deletions(-) create mode 100644 backend/app/api/routes/notification_templates.py create mode 100644 backend/app/models/notification_template.py create mode 100644 backend/app/schemas/notification_template.py create mode 100644 frontend/src/components/NotificationTemplateEditor.tsx delete mode 100644 static/assets/index-6YKRUSex.js delete mode 100644 static/assets/index-BP1T2ML7.css create mode 100644 static/assets/index-BomzirkH.css create mode 100644 static/assets/index-C99F9s0L.js diff --git a/PLAN.md b/PLAN.md index aa1623801..6d7cdedd2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,186 +1,247 @@ -# Notifications Feature Implementation Plan +# Notification Templates Management System ## Overview -Add push notifications for print events (start, complete, fail) with support for multiple notification providers. -## Supported Providers (Initial Release) -1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key -2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST -3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token -4. **Telegram** - Free bot API, requires bot token + chat ID -5. **Email (SMTP)** - Universal fallback +Replace hardcoded notification messages with a flexible template system that allows users to customize notification content per event type, with provider-specific formatting support. -## Database Design +--- + +## Data Model + +### New Table: `notification_templates` -### New Table: `notification_providers` ```sql -CREATE TABLE notification_providers ( +CREATE TABLE notification_templates ( id INTEGER PRIMARY KEY, - name TEXT NOT NULL, -- User-defined name ("My WhatsApp") - provider_type TEXT NOT NULL, -- "callmebot", "ntfy", "pushover", "telegram", "email" - enabled BOOLEAN DEFAULT true, - - -- Provider-specific config (JSON or individual fields) - config TEXT NOT NULL, -- JSON: {"phone": "+1234", "apikey": "xxx"} - - -- Event triggers (which events send notifications) - on_print_start BOOLEAN DEFAULT false, - on_print_complete BOOLEAN DEFAULT true, - on_print_failed BOOLEAN DEFAULT true, - - -- Optional: Link to specific printer (NULL = all printers) - printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, - - -- Timestamps - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + event_type VARCHAR(50) NOT NULL, -- print_start, print_complete, etc. + name VARCHAR(100) NOT NULL, -- User-friendly name + title_template TEXT NOT NULL, -- Template for notification title + body_template TEXT NOT NULL, -- Template for notification body + is_default BOOLEAN DEFAULT 0, -- System default (non-deletable) + created_at DATETIME, + updated_at DATETIME ); ``` -### Config JSON Structure by Provider -```python -# CallMeBot/WhatsApp -{"phone": "+1234567890", "apikey": "123456"} +**Event Types:** +- `print_start` +- `print_complete` +- `print_failed` +- `print_stopped` +- `print_progress` +- `printer_offline` +- `printer_error` +- `filament_low` +- `maintenance_due` +- `test` (for test notifications) -# ntfy -{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"} +--- -# Pushover -{"user_key": "xxx", "app_token": "yyy", "priority": 0} +## Template Variables -# Telegram -{"bot_token": "123:ABC", "chat_id": "12345678"} +Variables use `{variable_name}` syntax (Python format strings). -# Email (SMTP) -{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"} +### Per-Event Variables: + +| Event | Variables | +|-------|-----------| +| `print_start` | `{printer}`, `{filename}`, `{estimated_time}` | +| `print_complete` | `{printer}`, `{filename}`, `{duration}`, `{filament_grams}` | +| `print_failed` | `{printer}`, `{filename}`, `{duration}`, `{reason}` | +| `print_stopped` | `{printer}`, `{filename}`, `{duration}` | +| `print_progress` | `{printer}`, `{filename}`, `{progress}`, `{remaining_time}` | +| `printer_offline` | `{printer}` | +| `printer_error` | `{printer}`, `{error_type}`, `{error_detail}` | +| `filament_low` | `{printer}`, `{slot}`, `{remaining_percent}`, `{color}` | +| `maintenance_due` | `{printer}`, `{items}` (formatted list) | +| `test` | `{app_name}` | + +### Common Variables (all events): +- `{timestamp}` - Current date/time +- `{app_name}` - "BambuTrack" + +--- + +## Default Templates + +Pre-seeded templates for each event (marked `is_default=True`): + +``` +print_start: + title: "Print Started" + body: "{printer}: {filename}\nEstimated: {estimated_time}" + +print_complete: + title: "Print Completed" + body: "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g" + +print_failed: + title: "Print Failed" + body: "{printer}: {filename}\nTime: {duration}\nReason: {reason}" + +print_stopped: + title: "Print Stopped" + body: "{printer}: {filename}\nTime: {duration}" + +print_progress: + title: "Print {progress}% Complete" + body: "{printer}: {filename}\nRemaining: {remaining_time}" + +printer_offline: + title: "Printer Offline" + body: "{printer} has disconnected" + +printer_error: + title: "Printer Error: {error_type}" + body: "{printer}\n{error_detail}" + +filament_low: + title: "Filament Low" + body: "{printer}: Slot {slot} at {remaining_percent}%" + +maintenance_due: + title: "Maintenance Due" + body: "{printer}:\n{items}" + +test: + title: "BambuTrack Test" + body: "This is a test notification. If you see this, notifications are working!" ``` -## Backend Implementation +--- -### 1. Model: `backend/app/models/notification.py` -- SQLAlchemy model for `notification_providers` table -- Relationship to Printer (optional, nullable) +## Provider-Specific Formatting -### 2. Schema: `backend/app/schemas/notification.py` -- `NotificationProviderBase` - Common fields -- `NotificationProviderCreate` - For creating new providers -- `NotificationProviderUpdate` - For partial updates -- `NotificationProviderResponse` - API response with id/timestamps -- `NotificationTestRequest` - For testing notifications +The template system supports provider-specific formatting via a simple approach: -### 3. Service: `backend/app/services/notification_service.py` -Core notification dispatcher with provider implementations: +1. **Plain text** (default) - Used for CallMeBot, ntfy, Pushover, Email +2. **Markdown** - Automatically applied for Telegram (wrap title in `*bold*`) -```python -class NotificationService: - async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool - async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession) - async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession) +The notification service will: +- Render the template with variables +- Apply provider-specific formatting when sending - # Provider-specific methods - async def _send_callmebot(self, config: dict, message: str) -> bool - async def _send_ntfy(self, config: dict, title: str, message: str) -> bool - async def _send_pushover(self, config: dict, title: str, message: str) -> bool - async def _send_telegram(self, config: dict, message: str) -> bool - async def _send_email(self, config: dict, subject: str, body: str) -> bool +--- + +## Implementation Steps + +### Backend + +1. **Create model** `backend/app/models/notification_template.py` + - NotificationTemplate SQLAlchemy model + +2. **Create schemas** `backend/app/schemas/notification_template.py` + - NotificationTemplateCreate, Update, Response + - TemplateVariables (documentation of available vars per event) + +3. **Add migration** in `backend/app/core/database.py` + - Create table if not exists + - Seed default templates + +4. **Create API routes** `backend/app/api/routes/notification_templates.py` + - `GET /api/v1/notification-templates` - List all templates + - `GET /api/v1/notification-templates/{id}` - Get single template + - `PUT /api/v1/notification-templates/{id}` - Update template + - `POST /api/v1/notification-templates/{id}/reset` - Reset to default + - `GET /api/v1/notification-templates/variables` - List available variables per event + - `POST /api/v1/notification-templates/preview` - Preview template with sample data + +5. **Update notification service** `backend/app/services/notification_service.py` + - Load templates from database + - Render templates with variables + - Remove hardcoded message builders + +6. **Register routes** in `backend/app/main.py` + +### Frontend + +7. **Add API client methods** `frontend/src/api/client.ts` + - getNotificationTemplates, updateNotificationTemplate, etc. + +8. **Create template editor component** `frontend/src/components/NotificationTemplateEditor.tsx` + - Template editing UI with variable insertion buttons + - Live preview with sample data + - Reset to default button + +9. **Update SettingsPage** `frontend/src/pages/SettingsPage.tsx` + - Add "Templates" sub-section in Notifications tab + - List all templates with edit capability + +--- + +## UI Design + +### Templates Section (in Settings > Notifications) + +``` ++--------------------------------------------------+ +| Message Templates | +| Customize notification messages for each event | ++--------------------------------------------------+ +| | +| +----------------+ +----------------+ | +| | Print Started | | Print Complete | ... | +| | "Print Started"| | "Print Compl..." | | +| | [Edit] | | [Edit] | | +| +----------------+ +----------------+ | +| | ++--------------------------------------------------+ ``` -### 4. Routes: `backend/app/api/routes/notifications.py` +### Template Editor Modal + ``` -GET /notifications/ - List all providers -POST /notifications/ - Create provider -GET /notifications/{id} - Get provider details -PATCH /notifications/{id} - Update provider -DELETE /notifications/{id} - Delete provider -POST /notifications/{id}/test - Send test notification -POST /notifications/test-config - Test config before saving ++--------------------------------------------------+ +| Edit Template: Print Complete [X] | ++--------------------------------------------------+ +| Title: | +| [Print Completed_________________________] | +| | +| Body: | +| +----------------------------------------------+ | +| | {printer}: {filename} | | +| | Time: {duration} | | +| | Filament: {filament_grams}g | | +| +----------------------------------------------+ | +| | +| Available Variables: | +| [+printer] [+filename] [+duration] [+filament] | +| | +| Preview: | +| +----------------------------------------------+ | +| | Title: Print Completed | | +| | Body: Bambu X1C: Benchy.3mf | | +| | Time: 1h 23m | | +| | Filament: 15.2g | | +| +----------------------------------------------+ | +| | +| [Reset to Default] [Cancel] [Save] | ++--------------------------------------------------+ ``` -### 5. Integration in `main.py` -Add calls to notification service in existing event handlers: -- `on_print_start()` - After smart_plug_manager call (line ~244) -- `on_print_complete()` - After archive update (line ~580) +--- -## Frontend Implementation +## File Changes Summary -### 1. API Client: `frontend/src/api/client.ts` -Add types and API methods for notification providers. +| File | Action | +|------|--------| +| `backend/app/models/notification_template.py` | Create | +| `backend/app/schemas/notification_template.py` | Create | +| `backend/app/api/routes/notification_templates.py` | Create | +| `backend/app/core/database.py` | Modify (add migration + seeding) | +| `backend/app/models/__init__.py` | Modify (export new model) | +| `backend/app/services/notification_service.py` | Modify (use templates) | +| `backend/app/main.py` | Modify (register routes) | +| `frontend/src/api/client.ts` | Modify (add API methods + types) | +| `frontend/src/components/NotificationTemplateEditor.tsx` | Create | +| `frontend/src/pages/SettingsPage.tsx` | Modify (add templates section) | -### 2. Components -- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle -- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms +--- -### 3. Settings Page Integration -Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section): -- List of configured providers -- Add button -- Per-provider enable/disable -- Test button -- Event toggles (start/complete/failed) +## Notes -## Message Templates - -### Print Started -``` -🖨️ Print Started -{printer_name}: {filename} -Estimated time: {est_time} -``` - -### Print Completed -``` -✅ Print Completed -{printer_name}: {filename} -Time: {actual_time} -Filament: {filament_used}g -``` - -### Print Failed -``` -❌ Print Failed -{printer_name}: {filename} -Status: {failure_reason} -Progress: {progress}% -``` - -## Implementation Order - -### Phase 1: Backend Core -1. Create notification model with migrations -2. Create notification schema -3. Create notification service with all 5 providers -4. Create notification routes (CRUD + test) -5. Register routes in main.py -6. Integrate into print event handlers - -### Phase 2: Frontend -7. Add API types and methods -8. Create NotificationProviderCard component -9. Create AddNotificationModal component -10. Add Notifications section to SettingsPage - -### Phase 3: Testing & Polish -11. Test each provider -12. Add error handling and logging -13. Handle network failures gracefully (don't block print events) - -## Technical Notes - -### Async HTTP Requests -Use `httpx` (already available) for async HTTP calls to notification APIs. - -### Error Handling -- Notifications should NEVER block print events -- Log failures but continue processing -- Store last_error and last_success timestamps for UI feedback - -### Security -- Store credentials in database (SQLite file already contains access codes) -- Consider encryption for sensitive fields in future - -### Rate Limiting -- Debounce rapid events (don't spam on quick start/stop cycles) -- Consider per-provider rate limits - -## Questions for User -None - proceeding with the 5 providers as discussed. +- Default templates cannot be deleted, only modified and reset +- Templates are language-agnostic (user writes in their preferred language) +- The existing `notification_language` setting can be removed later (templates replace i18n) +- Variables that are unavailable for an event will render as empty string +- Template rendering uses safe formatting (missing vars don't crash) diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py new file mode 100644 index 000000000..ad2487aa0 --- /dev/null +++ b/backend/app/api/routes/notification_templates.py @@ -0,0 +1,148 @@ +"""API routes for notification template management.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.database import get_db +from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES +from backend.app.schemas.notification_template import ( + NotificationTemplateResponse, + NotificationTemplateUpdate, + EventVariablesResponse, + TemplatePreviewRequest, + TemplatePreviewResponse, + EVENT_VARIABLES, + SAMPLE_DATA, +) +from backend.app.services.notification_service import notification_service + +router = APIRouter(prefix="/notification-templates", tags=["notification-templates"]) + + +# Event type display names +EVENT_NAMES = { + "print_start": "Print Started", + "print_complete": "Print Completed", + "print_failed": "Print Failed", + "print_stopped": "Print Stopped", + "print_progress": "Print Progress", + "printer_offline": "Printer Offline", + "printer_error": "Printer Error", + "filament_low": "Filament Low", + "maintenance_due": "Maintenance Due", + "test": "Test Notification", +} + + +@router.get("", response_model=list[NotificationTemplateResponse]) +async def get_templates(db: AsyncSession = Depends(get_db)): + """Get all notification templates.""" + result = await db.execute( + select(NotificationTemplate).order_by(NotificationTemplate.id) + ) + return result.scalars().all() + + +@router.get("/variables", response_model=list[EventVariablesResponse]) +async def get_variables(): + """Get available variables for each event type.""" + return [ + EventVariablesResponse( + event_type=event_type, + event_name=EVENT_NAMES.get(event_type, event_type), + variables=variables, + ) + for event_type, variables in EVENT_VARIABLES.items() + ] + + +@router.get("/{template_id}", response_model=NotificationTemplateResponse) +async def get_template(template_id: int, db: AsyncSession = Depends(get_db)): + """Get a single notification template.""" + result = await db.execute( + select(NotificationTemplate).where(NotificationTemplate.id == template_id) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return template + + +@router.put("/{template_id}", response_model=NotificationTemplateResponse) +async def update_template( + template_id: int, + update: NotificationTemplateUpdate, + db: AsyncSession = Depends(get_db), +): + """Update a notification template.""" + result = await db.execute( + select(NotificationTemplate).where(NotificationTemplate.id == template_id) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + if update.title_template is not None: + template.title_template = update.title_template + if update.body_template is not None: + template.body_template = update.body_template + + await db.commit() + await db.refresh(template) + + # Clear template cache so changes take effect immediately + notification_service.clear_template_cache() + + return template + + +@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse) +async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)): + """Reset a notification template to its default values.""" + result = await db.execute( + select(NotificationTemplate).where(NotificationTemplate.id == template_id) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + # Find the default template + default = next( + (t for t in DEFAULT_TEMPLATES if t["event_type"] == template.event_type), + None, + ) + if not default: + raise HTTPException(status_code=500, detail="Default template not found") + + template.title_template = default["title_template"] + template.body_template = default["body_template"] + + await db.commit() + await db.refresh(template) + + # Clear template cache so changes take effect immediately + notification_service.clear_template_cache() + + return template + + +@router.post("/preview", response_model=TemplatePreviewResponse) +async def preview_template(request: TemplatePreviewRequest): + """Preview a template with sample data.""" + sample = SAMPLE_DATA.get(request.event_type, {}) + + # Safe template rendering - replace missing vars with empty string + def safe_format(template: str, data: dict) -> str: + result = template + for key, value in data.items(): + result = result.replace("{" + key + "}", str(value)) + # Remove any remaining unreplaced placeholders + import re + result = re.sub(r"\{[a-z_]+\}", "", result) + return result + + return TemplatePreviewResponse( + title=safe_format(request.title_template, sample), + body=safe_format(request.body_template, sample), + ) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index eee12f62a..c586bd0c4 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -34,7 +34,7 @@ async def get_db() -> AsyncSession: async def init_db(): # Import models to register them with SQLAlchemy - from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note # noqa: F401 + from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -42,6 +42,9 @@ async def init_db(): # Run migrations for new columns (SQLite doesn't auto-add columns) await run_migrations(conn) + # Seed default notification templates + await seed_notification_templates() + async def run_migrations(conn): """Add new columns to existing tables if they don't exist.""" @@ -127,3 +130,29 @@ async def run_migrations(conn): except Exception: # Column already exists pass + + +async def seed_notification_templates(): + """Seed default notification templates if they don't exist.""" + from sqlalchemy import select + from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES + + async with async_session() as session: + # Check if templates already exist + result = await session.execute(select(NotificationTemplate).limit(1)) + if result.scalar_one_or_none() is not None: + # Templates already seeded + return + + # Insert default templates + for template_data in DEFAULT_TEMPLATES: + template = NotificationTemplate( + event_type=template_data["event_type"], + name=template_data["name"], + title_template=template_data["title_template"], + body_template=template_data["body_template"], + is_default=True, + ) + session.add(template) + + await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 68e6d586c..f60b40f79 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,7 +54,7 @@ from fastapi.responses import FileResponse from backend.app.core.database import init_db, async_session from sqlalchemy import select, or_ from backend.app.core.websocket import ws_manager -from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance, camera +from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera from backend.app.api.routes import settings as settings_routes from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import ( @@ -1018,6 +1018,7 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix) app.include_router(print_queue.router, prefix=app_settings.api_prefix) app.include_router(kprofiles.router, prefix=app_settings.api_prefix) app.include_router(notifications.router, prefix=app_settings.api_prefix) +app.include_router(notification_templates.router, prefix=app_settings.api_prefix) app.include_router(spoolman.router, prefix=app_settings.api_prefix) app.include_router(updates.router, prefix=app_settings.api_prefix) app.include_router(maintenance.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6e5b0b572..2c65a9f04 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from backend.app.models.settings import Settings from backend.app.models.smart_plug import SmartPlug from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory from backend.app.models.kprofile_note import KProfileNote +from backend.app.models.notification_template import NotificationTemplate __all__ = [ "Printer", @@ -16,4 +17,5 @@ __all__ = [ "PrinterMaintenance", "MaintenanceHistory", "KProfileNote", + "NotificationTemplate", ] diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py new file mode 100644 index 000000000..f485ee4d0 --- /dev/null +++ b/backend/app/models/notification_template.py @@ -0,0 +1,90 @@ +"""Notification template model for customizable notification messages.""" + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class NotificationTemplate(Base): + """Model for notification message templates.""" + + __tablename__ = "notification_templates" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + event_type: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + title_template: Mapped[str] = mapped_column(Text, nullable=False) + body_template: Mapped[str] = mapped_column(Text, nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + +# Default templates for seeding +DEFAULT_TEMPLATES = [ + { + "event_type": "print_start", + "name": "Print Started", + "title_template": "Print Started", + "body_template": "{printer}: {filename}\nEstimated: {estimated_time}", + }, + { + "event_type": "print_complete", + "name": "Print Completed", + "title_template": "Print Completed", + "body_template": "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g", + }, + { + "event_type": "print_failed", + "name": "Print Failed", + "title_template": "Print Failed", + "body_template": "{printer}: {filename}\nTime: {duration}\nReason: {reason}", + }, + { + "event_type": "print_stopped", + "name": "Print Stopped", + "title_template": "Print Stopped", + "body_template": "{printer}: {filename}\nTime: {duration}", + }, + { + "event_type": "print_progress", + "name": "Print Progress", + "title_template": "Print {progress}% Complete", + "body_template": "{printer}: {filename}\nRemaining: {remaining_time}", + }, + { + "event_type": "printer_offline", + "name": "Printer Offline", + "title_template": "Printer Offline", + "body_template": "{printer} has disconnected", + }, + { + "event_type": "printer_error", + "name": "Printer Error", + "title_template": "Printer Error: {error_type}", + "body_template": "{printer}\n{error_detail}", + }, + { + "event_type": "filament_low", + "name": "Filament Low", + "title_template": "Filament Low", + "body_template": "{printer}: Slot {slot} at {remaining_percent}%", + }, + { + "event_type": "maintenance_due", + "name": "Maintenance Due", + "title_template": "Maintenance Due", + "body_template": "{printer}:\n{items}", + }, + { + "event_type": "test", + "name": "Test Notification", + "title_template": "BambuTrack Test", + "body_template": "This is a test notification. If you see this, notifications are working!", + }, +] diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py new file mode 100644 index 000000000..52af8bf67 --- /dev/null +++ b/backend/app/schemas/notification_template.py @@ -0,0 +1,166 @@ +"""Pydantic schemas for notification templates.""" + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +class EventType(str, Enum): + """Supported notification event types.""" + + PRINT_START = "print_start" + PRINT_COMPLETE = "print_complete" + PRINT_FAILED = "print_failed" + PRINT_STOPPED = "print_stopped" + PRINT_PROGRESS = "print_progress" + PRINTER_OFFLINE = "printer_offline" + PRINTER_ERROR = "printer_error" + FILAMENT_LOW = "filament_low" + MAINTENANCE_DUE = "maintenance_due" + TEST = "test" + + +# Available variables for each event type +EVENT_VARIABLES: dict[str, list[str]] = { + "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"], + "print_complete": ["printer", "filename", "duration", "filament_grams", "timestamp", "app_name"], + "print_failed": ["printer", "filename", "duration", "reason", "timestamp", "app_name"], + "print_stopped": ["printer", "filename", "duration", "timestamp", "app_name"], + "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"], + "printer_offline": ["printer", "timestamp", "app_name"], + "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"], + "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"], + "maintenance_due": ["printer", "items", "timestamp", "app_name"], + "test": ["app_name", "timestamp"], +} + +# Sample data for previewing templates +SAMPLE_DATA: dict[str, dict[str, str]] = { + "print_start": { + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "estimated_time": "1h 23m", + "timestamp": "2024-01-15 14:30", + "app_name": "BambuTrack", + }, + "print_complete": { + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "duration": "1h 18m", + "filament_grams": "15.2", + "timestamp": "2024-01-15 15:48", + "app_name": "BambuTrack", + }, + "print_failed": { + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "duration": "0h 45m", + "reason": "Filament runout", + "timestamp": "2024-01-15 15:15", + "app_name": "BambuTrack", + }, + "print_stopped": { + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "duration": "0h 30m", + "timestamp": "2024-01-15 15:00", + "app_name": "BambuTrack", + }, + "print_progress": { + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "progress": "50", + "remaining_time": "0h 41m", + "timestamp": "2024-01-15 15:00", + "app_name": "BambuTrack", + }, + "printer_offline": { + "printer": "Bambu X1C", + "timestamp": "2024-01-15 14:30", + "app_name": "BambuTrack", + }, + "printer_error": { + "printer": "Bambu X1C", + "error_type": "AMS Error", + "error_detail": "Filament slot 1 jammed", + "timestamp": "2024-01-15 14:30", + "app_name": "BambuTrack", + }, + "filament_low": { + "printer": "Bambu X1C", + "slot": "1", + "remaining_percent": "15", + "color": "Black PLA", + "timestamp": "2024-01-15 14:30", + "app_name": "BambuTrack", + }, + "maintenance_due": { + "printer": "Bambu X1C", + "items": "• Nozzle cleaning (OVERDUE)\n• Carbon rod lubrication (Soon)", + "timestamp": "2024-01-15 14:30", + "app_name": "BambuTrack", + }, + "test": { + "app_name": "BambuTrack", + "timestamp": "2024-01-15 14:30", + }, +} + + +class NotificationTemplateBase(BaseModel): + """Base schema for notification templates.""" + + title_template: str = Field(..., min_length=1, max_length=200) + body_template: str = Field(..., min_length=1, max_length=2000) + + +class NotificationTemplateUpdate(BaseModel): + """Schema for updating a notification template.""" + + title_template: str | None = Field(default=None, min_length=1, max_length=200) + body_template: str | None = Field(default=None, min_length=1, max_length=2000) + + +class NotificationTemplateResponse(NotificationTemplateBase): + """Schema for notification template API responses.""" + + id: int + event_type: str + name: str + is_default: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TemplateVariableInfo(BaseModel): + """Information about a template variable.""" + + name: str + description: str + + +class EventVariablesResponse(BaseModel): + """Response for available variables per event type.""" + + event_type: str + event_name: str + variables: list[str] + + +class TemplatePreviewRequest(BaseModel): + """Request to preview a template with sample data.""" + + event_type: str + title_template: str + body_template: str + + +class TemplatePreviewResponse(BaseModel): + """Response with rendered template preview.""" + + title: str + body: str diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 25de3101c..16f4f322d 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -2,6 +2,7 @@ import json import logging +import re import smtplib from datetime import datetime from email.mime.multipart import MIMEMultipart @@ -14,8 +15,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.notification import NotificationProvider -from backend.app.models.settings import Settings -from backend.app.i18n import Translator +from backend.app.models.notification_template import NotificationTemplate logger = logging.getLogger(__name__) @@ -25,6 +25,7 @@ class NotificationService: def __init__(self): self._http_client: httpx.AsyncClient | None = None + self._template_cache: dict[str, NotificationTemplate] = {} async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" @@ -66,136 +67,77 @@ class NotificationService: logger.warning(f"Invalid quiet hours format for provider {provider.name}") return False - async def _get_notification_language(self, db: AsyncSession) -> str: - """Get the notification language from settings.""" - result = await db.execute( - select(Settings).where(Settings.key == "notification_language") - ) - setting = result.scalar_one_or_none() - return setting.value if setting else "en" + async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None: + """Get a notification template by event type.""" + # Check cache first + if event_type in self._template_cache: + return self._template_cache[event_type] - def _format_duration(self, seconds: int | None, translator: Translator) -> str: + result = await db.execute( + select(NotificationTemplate).where(NotificationTemplate.event_type == event_type) + ) + template = result.scalar_one_or_none() + + if template: + self._template_cache[event_type] = template + + return template + + def _render_template(self, template_str: str, variables: dict[str, Any]) -> str: + """Render a template string with variables. Missing variables become empty.""" + result = template_str + for key, value in variables.items(): + result = result.replace("{" + key + "}", str(value) if value is not None else "") + # Remove any remaining unreplaced placeholders + result = re.sub(r"\{[a-z_]+\}", "", result) + return result + + def _format_duration(self, seconds: int | None) -> str: """Format duration in seconds to human-readable string.""" if seconds is None: - return translator.t("notification.unknown") + return "Unknown" hours = seconds // 3600 minutes = (seconds % 3600) // 60 if hours > 0: return f"{hours}h {minutes}m" return f"{minutes}m" - def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]: - """Build notification message for print start event.""" - filename = data.get("filename", translator.t("notification.unknown")) - # Clean up filename + def _clean_filename(self, filename: str) -> str: + """Remove file extensions from filename.""" if filename.endswith(".gcode.3mf"): - filename = filename[:-10] + return filename[:-10] elif filename.endswith(".3mf"): - filename = filename[:-4] + return filename[:-4] + return filename - title = translator.t("notification.print_started") - - estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time") - time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator) - - message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}" - return title, message - - def _build_print_complete_message( - self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None + async def _build_message_from_template( + self, db: AsyncSession, event_type: str, variables: dict[str, Any] ) -> tuple[str, str]: - """Build notification message for print complete event.""" - filename = data.get("filename", translator.t("notification.unknown")) - if filename.endswith(".gcode.3mf"): - filename = filename[:-10] - elif filename.endswith(".3mf"): - filename = filename[:-4] + """Build notification title and body from template.""" + # Add common variables + variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") + variables["app_name"] = "BambuTrack" - if status == "completed": - title = translator.t("notification.print_completed") - elif status == "failed": - title = translator.t("notification.print_failed") - elif status in ("aborted", "stopped", "cancelled"): - title = translator.t("notification.print_stopped") - else: - title = translator.t("notification.print_ended") + template = await self._get_template(db, event_type) + if not template: + # Fallback to simple message + logger.warning(f"Template not found for event type: {event_type}") + return event_type.replace("_", " ").title(), str(variables) - lines = [f"{printer_name}: {filename}"] + title = self._render_template(template.title_template, variables) + body = self._render_template(template.body_template, variables) - if archive_data: - # Add print time if available - if archive_data.get("print_time_seconds"): - lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}") - # Add filament used if available - if archive_data.get("actual_filament_grams"): - lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g") - # Add failure reason if failed - if status == "failed" and archive_data.get("failure_reason"): - lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}") - - message = "\n".join(lines) - return title, message - - def _build_progress_message( - self, printer_name: str, filename: str, progress: int, translator: Translator - ) -> tuple[str, str]: - """Build notification message for print progress milestone.""" - if filename.endswith(".gcode.3mf"): - filename = filename[:-10] - elif filename.endswith(".3mf"): - filename = filename[:-4] - - title = translator.t("notification.print_progress", progress=progress) - message = f"{printer_name}: {filename}" - return title, message - - def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]: - """Build notification message for printer offline event.""" - title = translator.t("notification.printer_offline") - message = translator.t("notification.printer_disconnected", printer=printer_name) - return title, message - - def _build_printer_error_message( - self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None - ) -> tuple[str, str]: - """Build notification message for printer error event.""" - title = translator.t("notification.printer_error", error_type=error_type) - message = f"{printer_name}" - if error_detail: - message += f"\n{error_detail}" - return title, message - - def _build_filament_low_message( - self, printer_name: str, slot: int, remaining_percent: int, translator: Translator - ) -> tuple[str, str]: - """Build notification message for low filament event.""" - title = translator.t("notification.filament_low") - message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent) - return title, message - - def _build_maintenance_due_message( - self, printer_name: str, maintenance_items: list[dict], translator: Translator - ) -> tuple[str, str]: - """Build notification message for maintenance due event.""" - title = translator.t("notification.maintenance_due") - lines = [f"{printer_name}:"] - for item in maintenance_items: - status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon") - lines.append(f"• {item['name']} ({status})") - message = "\n".join(lines) - return title, message + return title, body async def send_test_notification( self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None ) -> tuple[bool, str]: """Send a test notification to verify configuration.""" - lang = "en" if db: - lang = await self._get_notification_language(db) - translator = Translator(lang) - - title = translator.t("notification.test_title") - message = translator.t("notification.test_message") + title, message = await self._build_message_from_template(db, "test", {}) + else: + title = "BambuTrack Test" + message = "This is a test notification. If you see this, notifications are working!" try: if provider_type == "callmebot": @@ -461,10 +403,18 @@ class NotificationService: logger.info(f"No notification providers configured for print_start event on printer {printer_id}") return - lang = await self._get_notification_language(db) - translator = Translator(lang) + filename = self._clean_filename(data.get("filename", "Unknown")) + estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time") + time_str = self._format_duration(estimated_time * 60 if estimated_time else None) + + variables = { + "printer": printer_name, + "filename": filename, + "estimated_time": time_str, + } + logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}") - title, message = self._build_print_start_message(printer_name, data, translator) + title, message = await self._build_message_from_template(db, "print_start", variables) await self._send_to_providers(providers, title, message, db) async def on_print_complete( @@ -478,27 +428,47 @@ class NotificationService: ): """Handle print complete event - send notifications to relevant providers.""" logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}") - # Determine which event type this is + + # Determine event type based on status if status == "completed": event_field = "on_print_complete" + event_type = "print_complete" elif status in ("failed",): event_field = "on_print_failed" + event_type = "print_failed" elif status in ("aborted", "stopped", "cancelled"): event_field = "on_print_stopped" + event_type = "print_stopped" else: - # Unknown status, default to on_print_complete logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete") event_field = "on_print_complete" + event_type = "print_complete" providers = await self._get_providers_for_event(db, event_field, printer_id) if not providers: logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}") return - lang = await self._get_notification_language(db) - translator = Translator(lang) + filename = self._clean_filename(data.get("filename", "Unknown")) + + variables = { + "printer": printer_name, + "filename": filename, + "duration": "", + "filament_grams": "", + "reason": "", + } + + if archive_data: + if archive_data.get("print_time_seconds"): + variables["duration"] = self._format_duration(archive_data["print_time_seconds"]) + if archive_data.get("actual_filament_grams"): + variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}" + if status == "failed" and archive_data.get("failure_reason"): + variables["reason"] = archive_data["failure_reason"] + logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}") - title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data) + title, message = await self._build_message_from_template(db, event_type, variables) await self._send_to_providers(providers, title, message, db) async def on_print_progress( @@ -508,15 +478,21 @@ class NotificationService: filename: str, progress: int, db: AsyncSession, + remaining_time: int | None = None, ): """Handle print progress milestone (25%, 50%, 75%).""" providers = await self._get_providers_for_event(db, "on_print_progress", printer_id) if not providers: return - lang = await self._get_notification_language(db) - translator = Translator(lang) - title, message = self._build_progress_message(printer_name, filename, progress, translator) + variables = { + "printer": printer_name, + "filename": self._clean_filename(filename), + "progress": str(progress), + "remaining_time": self._format_duration(remaining_time) if remaining_time else "", + } + + title, message = await self._build_message_from_template(db, "print_progress", variables) await self._send_to_providers(providers, title, message, db) async def on_printer_offline( @@ -527,9 +503,9 @@ class NotificationService: if not providers: return - lang = await self._get_notification_language(db) - translator = Translator(lang) - title, message = self._build_printer_offline_message(printer_name, translator) + variables = {"printer": printer_name} + + title, message = await self._build_message_from_template(db, "printer_offline", variables) await self._send_to_providers(providers, title, message, db) async def on_printer_error( @@ -545,9 +521,13 @@ class NotificationService: if not providers: return - lang = await self._get_notification_language(db) - translator = Translator(lang) - title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail) + variables = { + "printer": printer_name, + "error_type": error_type, + "error_detail": error_detail or "", + } + + title, message = await self._build_message_from_template(db, "printer_error", variables) await self._send_to_providers(providers, title, message, db) async def on_filament_low( @@ -557,15 +537,21 @@ class NotificationService: slot: int, remaining_percent: int, db: AsyncSession, + color: str | None = None, ): """Handle low filament event.""" providers = await self._get_providers_for_event(db, "on_filament_low", printer_id) if not providers: return - lang = await self._get_notification_language(db) - translator = Translator(lang) - title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator) + variables = { + "printer": printer_name, + "slot": str(slot), + "remaining_percent": str(remaining_percent), + "color": color or "", + } + + title, message = await self._build_message_from_template(db, "filament_low", variables) await self._send_to_providers(providers, title, message, db) async def on_maintenance_due( @@ -584,12 +570,26 @@ class NotificationService: logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}") return - lang = await self._get_notification_language(db) - translator = Translator(lang) + # Format maintenance items list + items_list = [] + for item in maintenance_items: + status = "OVERDUE" if item.get("is_due") else "Soon" + items_list.append(f"- {item['name']} ({status})") + items_str = "\n".join(items_list) + + variables = { + "printer": printer_name, + "items": items_str, + } + logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}") - title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator) + title, message = await self._build_message_from_template(db, "maintenance_due", variables) await self._send_to_providers(providers, title, message, db) + def clear_template_cache(self): + """Clear the template cache. Call this when templates are updated.""" + self._template_cache.clear() + # Global instance notification_service = NotificationService() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cd5e05cdc..353ed873c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -677,6 +677,40 @@ export interface EmailConfig { use_tls?: boolean; } +// Notification Template types +export interface NotificationTemplate { + id: number; + event_type: string; + name: string; + title_template: string; + body_template: string; + is_default: boolean; + created_at: string; + updated_at: string; +} + +export interface NotificationTemplateUpdate { + title_template?: string; + body_template?: string; +} + +export interface EventVariablesResponse { + event_type: string; + event_name: string; + variables: string[]; +} + +export interface TemplatePreviewRequest { + event_type: string; + title_template: string; + body_template: string; +} + +export interface TemplatePreviewResponse { + title: string; + body: string; +} + // Spoolman types export interface SpoolmanStatus { enabled: boolean; @@ -1230,6 +1264,25 @@ export const api = { body: JSON.stringify(data), }), + // Notification Templates + getNotificationTemplates: () => request('/notification-templates'), + getNotificationTemplate: (id: number) => request(`/notification-templates/${id}`), + updateNotificationTemplate: (id: number, data: NotificationTemplateUpdate) => + request(`/notification-templates/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + resetNotificationTemplate: (id: number) => + request(`/notification-templates/${id}/reset`, { + method: 'POST', + }), + getTemplateVariables: () => request('/notification-templates/variables'), + previewTemplate: (data: TemplatePreviewRequest) => + request('/notification-templates/preview', { + method: 'POST', + body: JSON.stringify(data), + }), + // Spoolman Integration getSpoolmanStatus: () => request('/spoolman/status'), connectSpoolman: () => diff --git a/frontend/src/components/AddNotificationModal.tsx b/frontend/src/components/AddNotificationModal.tsx index cb360acfd..66bf61366 100644 --- a/frontend/src/components/AddNotificationModal.tsx +++ b/frontend/src/components/AddNotificationModal.tsx @@ -30,6 +30,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00'); const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00'); + // Event toggles + const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false); + const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true); + const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true); + const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true); + const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false); + const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false); + const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false); + const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false); + const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false); + // Provider-specific config const [config, setConfig] = useState>( provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {} @@ -115,6 +126,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal quiet_hours_enabled: quietHoursEnabled, quiet_hours_start: quietHoursEnabled ? quietHoursStart : null, quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null, + // Event toggles + on_print_start: onPrintStart, + on_print_complete: onPrintComplete, + on_print_failed: onPrintFailed, + on_print_stopped: onPrintStopped, + on_print_progress: onPrintProgress, + on_printer_offline: onPrinterOffline, + on_printer_error: onPrinterError, + on_filament_low: onFilamentLow, + on_maintenance_due: onMaintenanceDue, }; if (isEditing) { @@ -380,6 +401,64 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal )} + {/* Event Toggles */} +
+

Notification Events

+ + {/* Print Events */} +
+

Print Events

+
+
+ Start + +
+
+ Complete + +
+
+ Failed + +
+
+ Stopped + +
+
+
+ Progress + (25%, 50%, 75%) +
+ +
+
+
+ + {/* Printer Status Events */} +
+

Printer Status

+
+
+ Offline + +
+
+ Error + +
+
+ Low Filament + +
+
+ Maintenance + +
+
+
+
+ {/* Actions */}
@@ -130,16 +133,16 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi Offline )} {provider.on_printer_error && ( - Error + Error )} {provider.on_filament_low && ( - Low Filament + Low Filament )} {provider.on_maintenance_due && ( Maintenance )} {provider.quiet_hours_enabled && ( - + Quiet diff --git a/frontend/src/components/NotificationTemplateEditor.tsx b/frontend/src/components/NotificationTemplateEditor.tsx new file mode 100644 index 000000000..27431000d --- /dev/null +++ b/frontend/src/components/NotificationTemplateEditor.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect, useRef } from 'react'; +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { X, Save, Loader2, RotateCcw, Plus, Eye } from 'lucide-react'; +import { api } from '../api/client'; +import type { NotificationTemplate, NotificationTemplateUpdate } from '../api/client'; +import { Button } from './Button'; + +interface NotificationTemplateEditorProps { + template: NotificationTemplate; + onClose: () => void; +} + +export function NotificationTemplateEditor({ template, onClose }: NotificationTemplateEditorProps) { + const queryClient = useQueryClient(); + const bodyRef = useRef(null); + + const [titleTemplate, setTitleTemplate] = useState(template.title_template); + const [bodyTemplate, setBodyTemplate] = useState(template.body_template); + const [error, setError] = useState(null); + const [showPreview, setShowPreview] = useState(true); + + // Fetch variables for this event type + const { data: variablesData } = useQuery({ + queryKey: ['template-variables'], + queryFn: api.getTemplateVariables, + }); + + // Get variables for this template's event type + const eventVariables = variablesData?.find(v => v.event_type === template.event_type); + + // Live preview + const { data: preview, isLoading: previewLoading } = useQuery({ + queryKey: ['template-preview', template.event_type, titleTemplate, bodyTemplate], + queryFn: () => api.previewTemplate({ + event_type: template.event_type, + title_template: titleTemplate, + body_template: bodyTemplate, + }), + enabled: showPreview && titleTemplate.length > 0 && bodyTemplate.length > 0, + }); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: (data: NotificationTemplateUpdate) => api.updateNotificationTemplate(template.id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); + onClose(); + }, + onError: (err: Error) => { + setError(err.message); + }, + }); + + // Reset mutation + const resetMutation = useMutation({ + mutationFn: () => api.resetNotificationTemplate(template.id), + onSuccess: (resetTemplate) => { + setTitleTemplate(resetTemplate.title_template); + setBodyTemplate(resetTemplate.body_template); + queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); + }, + onError: (err: Error) => { + setError(err.message); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!titleTemplate.trim()) { + setError('Title is required'); + return; + } + if (!bodyTemplate.trim()) { + setError('Body is required'); + return; + } + + updateMutation.mutate({ + title_template: titleTemplate, + body_template: bodyTemplate, + }); + }; + + const insertVariable = (variable: string) => { + const textarea = bodyRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = bodyTemplate; + const before = text.substring(0, start); + const after = text.substring(end); + const newValue = before + `{${variable}}` + after; + + setBodyTemplate(newValue); + + // Restore focus and cursor position + setTimeout(() => { + textarea.focus(); + const newCursor = start + variable.length + 2; + textarea.setSelectionRange(newCursor, newCursor); + }, 0); + }; + + const hasChanges = titleTemplate !== template.title_template || bodyTemplate !== template.body_template; + + return ( +
+
+ {/* Header */} +
+

+ Edit Template: {template.name} +

+ +
+ + {/* Content */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Title */} +
+ + setTitleTemplate(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green" + placeholder="Notification title..." + /> +
+ + {/* Body */} +
+ +