From 1095c8fcde1783877b9dfbe1861ffc674f34fc91 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Wed, 27 May 2026 16:40:46 -0400 Subject: [PATCH] 05/27 Sync with iPad app (Phase D) --- app/__init__.py | 2 + app/api/__init__.py | 4 + app/api/comments.py | 187 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 app/api/comments.py diff --git a/app/__init__.py b/app/__init__.py index c3aa335..19644c5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -162,6 +162,7 @@ def create_app(config_name='default'): from app.api.photos import bp as _api_photos_bp from app.api.notifications import bp as _api_notifications_bp from app.api.stats import bp as _api_stats_bp + from app.api.comments import bp as _api_comments_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -170,6 +171,7 @@ def create_app(config_name='default'): csrf.exempt(_api_photos_bp) csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) + csrf.exempt(_api_comments_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index 0c0102e..178697c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -46,4 +46,8 @@ def register_api(app): from app.api.stats import bp as stats_bp api_bp.register_blueprint(stats_bp) + # Phase D: Issue comments + from app.api.comments import bp as comments_bp + api_bp.register_blueprint(comments_bp) + app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 0000000..dd6b631 --- /dev/null +++ b/app/api/comments.py @@ -0,0 +1,187 @@ +""" +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})