diff --git a/app/api/auth.py b/app/api/auth.py index cbeff41..3c01af4 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -50,6 +50,7 @@ def _user_payload(user: User) -> dict: return { 'id': user.id, 'username': user.username, + 'full_name': user.full_name or '', 'email': user.email, 'role': user.role, 'created_at': user.created_at.isoformat() if user.created_at else None, diff --git a/app/api/issues.py b/app/api/issues.py index b3100c9..6e61097 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -105,8 +105,16 @@ def list_issues(): query = Issue.query if user.role == 'inspector': - # Inspectors see only issues assigned to them - query = query.filter(Issue.assigned_to == user.id) + # Inspectors see issues assigned to them OR issues they reported. + # The reported_by path covers issues created on the iPad that haven't + # been assigned yet (assigned_to is NULL until a director assigns them). + # Pre-phase18 rows with reported_by = NULL still surface via assigned_to. + query = query.filter( + db.or_( + Issue.assigned_to == user.id, + Issue.reported_by == user.id, + ) + ) else: # Broader roles: exclude resolved by default so the list stays manageable status_filter = request.args.get('status') @@ -204,6 +212,7 @@ def create_issue(): photo_path = data.get('photo_path') or None, status = 'open', reported_at = now_eastern(), + reported_by = user.id, mobile_local_id = mobile_local_id, ) db.session.add(issue) @@ -264,7 +273,7 @@ def get_issue(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector' and issue.assigned_to != user.id: + if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id: return api_error('Access denied', 403) return api_ok(_issue_payload(issue)) @@ -294,8 +303,8 @@ def update_issue_status(issue_id): if issue is None: return api_error('Issue not found', 404) - if user.role == 'inspector' and issue.assigned_to != user.id: - return api_error('Access denied — you can only update issues assigned to you', 403) + if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id: + return api_error('Access denied — you can only update issues assigned to or reported by you', 403) data = request.get_json(silent=True) or {} new_status = (data.get('status') or '').strip().lower() diff --git a/app/models/issue.py b/app/models/issue.py index 065ac5a..153080d 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -54,6 +54,11 @@ class Issue(db.Model): photo_path = db.Column(db.String(255)) status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open') assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) + # Set at creation time to the user who filed the issue (inspector or admin). + # Nullable for backward compatibility — pre-phase18 rows will be NULL. + # Used by the mobile API to return issues the inspector created but hasn't + # been assigned yet (assigned_to is NULL until a director assigns them). + reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) reported_at = db.Column(db.DateTime, default=now_eastern) resolved_at = db.Column(db.DateTime) result_notes = db.Column(db.Text) @@ -75,6 +80,7 @@ class Issue(db.Model): # with that backref at mapper configuration time (CLAUDE.md rule 31 revised). facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues') assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') + reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_issues') verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues') comments = db.relationship('IssueComment', backref='issue', lazy='dynamic', order_by='IssueComment.created_at', diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index c0030d8..0dd7565 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -110,9 +110,14 @@ def index(): recent_inspections = recent_q.limit(5).all() # ── Pending follow-up inspections ──────────────────────────────────── + # follow_ups is a lazy='dynamic' relationship — comparing it to None does + # NOT produce a "has no rows" predicate for dynamic relationships. The + # correct idiom is ~.any(), which generates EXISTS (SELECT 1 FROM inspections + # WHERE parent_inspection_id = inspections.id). This matches the identical + # filter used in routes/inspections.py:follow_up_filter. followup_q = Inspection.query.filter_by( follow_up_required=True, status='completed' - ).filter(Inspection.follow_ups == None) # noqa: E711 — SQLAlchemy usage + ).filter(~Inspection.follow_ups.any()) if is_inspector: followup_q = followup_q.filter(Inspection.inspector_id == current_user.id) elif is_customer: diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 2a019b1..e346c88 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -730,6 +730,7 @@ def flag_issue(inspection_id): status = 'open', assigned_to = form.assigned_to.data or None, reported_at = now_eastern(), + reported_by = current_user.id, ) db.session.add(issue) diff --git a/app/routes/issues.py b/app/routes/issues.py index c457096..641017b 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -447,6 +447,7 @@ def create(): status = 'open', assigned_to = form.assigned_to.data or None, reported_at = now_eastern(), + reported_by = current_user.id, ) db.session.add(issue) db.session.commit() diff --git a/migrations/versions/phase18_issue_reported_by.py b/migrations/versions/phase18_issue_reported_by.py new file mode 100644 index 0000000..3281e5e --- /dev/null +++ b/migrations/versions/phase18_issue_reported_by.py @@ -0,0 +1,75 @@ +"""phase18 — add reported_by column to issues table + +Background +---------- +Issues created on the iPad by an inspector have no `assigned_to` value until +a director assigns them via the web portal. The mobile API's list_issues +endpoint filtered inspectors to `assigned_to == user.id`, so their own +newly-submitted issues were invisible on the iPad until assigned. + +This migration adds `reported_by INT NULL FK → users.id` so the API can +return issues the inspector either created OR was assigned to, without +a join to the inspections table. + +The column is nullable for backward compatibility: existing issues created +before this migration will have reported_by = NULL and continue to surface +only via the assigned_to path. + +Uses INFORMATION_SCHEMA existence check — safe to re-run (CLAUDE.md rules 16, 17). + +Revision ID: phase18_issue_reported_by +Revises: phase17_notification_event_type +""" + +revision = 'phase18_issue_reported_by' +down_revision = 'phase17_notification_event_type' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND COLUMN_NAME = :col" + ), {"table": table, "col": column}) + return result.scalar() > 0 + + +def _fk_exists(conn, table, constraint_name): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND CONSTRAINT_NAME = :name" + ), {"table": table, "name": constraint_name}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _column_exists(bind, 'issues', 'reported_by'): + op.execute(sa.text( + "ALTER TABLE issues " + "ADD COLUMN reported_by INT NULL, " + "ADD CONSTRAINT fk_issues_reported_by " + " FOREIGN KEY (reported_by) REFERENCES users(id) " + " ON DELETE SET NULL" + )) + + +def downgrade(): + bind = op.get_bind() + + if _fk_exists(bind, 'issues', 'fk_issues_reported_by'): + op.execute(sa.text( + "ALTER TABLE issues DROP FOREIGN KEY fk_issues_reported_by" + )) + + if _column_exists(bind, 'issues', 'reported_by'): + op.execute(sa.text( + "ALTER TABLE issues DROP COLUMN reported_by" + ))