""" app/api/comments.py ------------------- Mobile API endpoints for issue comments. GET /api/v1/issues//comments Returns all comments for an issue, ordered oldest-first. Inspectors may only access issues within their contracted facilities. POST /api/v1/issues//comments Adds a comment to an issue. Inspectors may only comment on issues within their contracted facilities. Fires notify_by_matrix('issue_comment') so the relevant staff are notified. """ import logging from flask import Blueprint, request, g from app import db from app.models.issue import Issue, IssueComment from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required from app.utils.audit import log_action, ACTION_CREATE from app.utils.notifications import notify_by_matrix from app.utils.scope import get_inspector_scope from app.utils.time_utils import now_eastern logger = logging.getLogger(__name__) bp = Blueprint('api_comments', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} def _comment_payload(comment: IssueComment) -> dict: """Serialise an IssueComment to the dict returned in API responses.""" return { 'id': comment.id, 'issue_id': comment.issue_id, 'author_name': comment.author.display_name if comment.author else 'Unknown', 'author_role': comment.author.role if comment.author else '', 'status_at_time': comment.status_at_time or '', 'body': comment.body, 'created_at': comment.created_at.isoformat() if comment.created_at else None, } def _check_issue_access(issue: Issue, user) -> bool: """Return True if user may read/write this issue. False = 403.""" if user.role == 'inspector': fids = get_inspector_scope(user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: return False return True # ── GET comments ───────────────────────────────────────────────────────────── @bp.route('/issues//comments', methods=['GET']) @jwt_required def list_comments(issue_id): """ Return all comments for the given issue, oldest-first. Response 200 ------------ { "ok": true, "data": { "issue_id": 42, "comments": [ { "id": 1, "issue_id": 42, "author_name": "Jane Smith", "author_role": "director", "status_at_time": "in_progress", "body": "Cleaning crew has been notified.", "created_at": "2026-05-10T09:15:00" } ], "count": 1 } } """ user = g.api_user if user.role not in _ALLOWED_ROLES: return api_error('Access denied', 403) issue = db.session.get(Issue, issue_id) if issue is None: return api_error('Issue not found', 404) if not _check_issue_access(issue, user): return api_error('Access denied', 403) comments = ( issue.comments .order_by(IssueComment.created_at.asc()) .all() ) payload = [_comment_payload(c) for c in comments] logger.info('API COMMENTS | list | issue_id=%d | count=%d | user=%s', issue_id, len(payload), user.username) return api_ok({'issue_id': issue_id, 'comments': payload, 'count': len(payload)}) # ── POST comment ────────────────────────────────────────────────────────────── @bp.route('/issues//comments', methods=['POST']) @jwt_required def add_comment(issue_id): """ Add a comment to an issue. Request JSON ------------ { "body": "The spill has been cleaned up." } Response 200 ------------ { "ok": true, "data": { "comment_id": 7 } } """ user = g.api_user if user.role not in _ALLOWED_ROLES: return api_error('Access denied', 403) issue = db.session.get(Issue, issue_id) if issue is None: return api_error('Issue not found', 404) if not _check_issue_access(issue, user): return api_error('Access denied', 403) data = request.get_json(silent=True) or {} body = (data.get('body') or '').strip() if not body: return api_error('body is required', 400) comment = IssueComment( issue_id = issue_id, user_id = user.id, status_at_time = issue.status, body = body, ) db.session.add(comment) db.session.flush() # get comment.id # Notify via matrix — same event type as web-originated comments facility = issue.resolved_facility area_name = issue.area.name if issue.area else (facility.name if facility else '—') facility_id = facility.id if facility else None try: from flask import url_for issue_link = url_for('issues.view', issue_id=issue.id, _external=False) except RuntimeError: issue_link = f'/issues/{issue.id}' if facility_id: notify_by_matrix( event_type = 'issue_comment', title = f'New Comment on Issue #{issue.id}', body = ( f'{user.display_name} commented on Issue #{issue.id} ' f'at {area_name}: ' f'"{body[:120]}{"…" if len(body) > 120 else ""}"' ), link = issue_link, issue_id = issue.id, facility_id = facility_id, exclude_user_ids = {user.id}, ) db.session.commit() log_action(ACTION_CREATE, 'IssueComment', comment.id, f'comment on Issue #{issue_id}', f'source=mobile; author={user.username}; issue_status={issue.status}') logger.info('API COMMENTS | created | comment_id=%d | issue_id=%d | user=%s', comment.id, issue_id, user.username) return api_ok({'comment_id': comment.id})