bmc_hub/app/modules/sag/backend/reminders.py
Christian 0831715d3a feat: add SMS service and frontend integration
- Implement SmsService class for sending SMS via CPSMS API.
- Add SMS sending functionality in the frontend with validation and user feedback.
- Create database migrations for SMS message storage and telephony features.
- Introduce telephony settings and user-specific configurations for click-to-call functionality.
- Enhance user experience with toast notifications for incoming calls and actions.
2026-02-14 02:26:29 +01:00

626 lines
22 KiB
Python

"""
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)$")
event_type: str = Field(default="reminder", pattern="^(reminder|meeting|technician_visit|obs|deadline)$")
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
event_type: Optional[str] = Field(default=None, pattern="^(reminder|meeting|technician_visit|obs|deadline)$")
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
event_type: Optional[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
event_type: Optional[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, event_type, 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'],
event_type=r.get('event_type'),
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.event_type, 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'],
event_type=r.get('event_type'),
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, event_type, 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, %s,
true, %s
)
RETURNING id, sag_id, title, message, priority, event_type, 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.event_type,
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, event_type, 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'],
event_type=r.get('event_type'),
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.event_type is not None:
updates.append("event_type = %s")
params.append(update.event_type)
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))