""" app/api/notifications.py ------------------------ Mobile API endpoint for polling in-app notifications. GET /api/v1/notifications Returns unread notifications for the authenticated user. Accepts ?since= to fetch only notifications created after that datetime — used by the iPad poller to avoid re-delivering already- seen alerts. Returns a maximum of 50 notifications per call. PATCH /api/v1/notifications/mark-read Marks a list of notification IDs as read. Request JSON: { "ids": [1, 2, 3] } """ import logging from datetime import datetime import sqlalchemy.exc from flask import Blueprint, request, g from app import db from app.models.notification import Notification from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required logger = logging.getLogger(__name__) bp = Blueprint('api_notifications', __name__) def _parse_since(value): """Parse ?since= ISO 8601 string; return None on failure.""" if not value: return None for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'): try: return datetime.strptime(value, fmt) except (ValueError, TypeError): pass return None def _build_payload(notifications, has_event_type): """Serialise notification rows to dicts. Works before and after phase17 migration.""" rows = [] for n in notifications: rows.append({ 'id': n.id, 'title': n.title, 'body': n.body, 'event_type': (n.event_type if has_event_type else None), 'issue_id': n.issue_id, 'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'), }) return rows @bp.route('/notifications', methods=['GET']) @jwt_required def list_notifications(): """ Return unread notifications for the authenticated user. Query parameters ---------------- since ISO 8601 datetime Only return notifications created after this time. limit int (default 50, max 50) Response 200 ------------ { "ok": true, "data": { "notifications": [...], "count": N } } """ user = g.api_user since = _parse_since(request.args.get('since')) limit = min(int(request.args.get('limit', 50)), 50) def _run_orm(): q = Notification.query.filter_by(user_id=user.id, is_read=False) if since: q = q.filter(Notification.created_at > since) return q.order_by(Notification.created_at.asc()).limit(limit).all() # Attempt the ORM query (works after phase17 migration runs). # If the event_type column does not yet exist in the DB, MySQL raises # OperationalError: Unknown column 'notifications.event_type' in SELECT. # getattr() does NOT protect against this — the failure is at the SQL layer. # The fallback raw-SQL query selects only the pre-phase17 columns so the # endpoint stays functional before the migration runs. try: notifications = _run_orm() has_event_type = True except sqlalchemy.exc.OperationalError: db.session.rollback() sql_parts = ( "SELECT id, title, body, issue_id, is_read, created_at " "FROM notifications " "WHERE user_id = :uid AND is_read = 0 " ) params = {'uid': user.id, 'lim': limit} if since: sql_parts += "AND created_at > :since " params['since'] = since sql_parts += "ORDER BY created_at ASC LIMIT :lim" rows = db.session.execute(db.text(sql_parts), params).fetchall() class _Row: """Minimal shim so _build_payload works with raw SQL rows.""" __slots__ = ('id', 'title', 'body', 'issue_id', 'created_at') def __init__(self, r): self.id = r[0] self.title = r[1] self.body = r[2] self.issue_id = r[3] self.created_at = r[5] notifications = [_Row(r) for r in rows] has_event_type = False payload = _build_payload(notifications, has_event_type) logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s', user.username, since, len(payload), has_event_type) return api_ok({'notifications': payload, 'count': len(payload)}) @bp.route('/notifications/mark-read', methods=['PATCH']) @jwt_required def mark_read(): """ Mark a list of notification IDs as read. Request JSON: { "ids": [1, 2, 3] } Response 200 ------------ { "ok": true, "data": { "marked": 3 } } """ user = g.api_user data = request.get_json(silent=True) or {} ids = data.get('ids') or [] if not isinstance(ids, list): return api_error('ids must be a list', 400) if ids: updated = ( Notification.query .filter( Notification.id.in_(ids), Notification.user_id == user.id, ) .all() ) for n in updated: n.is_read = True db.session.commit() count = len(updated) else: count = 0 logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count) return api_ok({'marked': count})