diff --git a/CLAUDE.md b/CLAUDE.md
index c82d362..e0bb94e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -245,9 +245,23 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity
mobile_local_id VARCHAR(64) nullable indexed, ← Phase B
vendor_name VARCHAR(100) nullable, ← Phase 26
vendor_contact VARCHAR(200) nullable, ← Phase 26
- vendor_notes TEXT nullable ← Phase 26
+ vendor_notes TEXT nullable, ← Phase 26
+ handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
+ facility_handler_name VARCHAR(100) nullable, ← Phase 35
+ facility_handler_contact VARCHAR(200) nullable, ← Phase 35
+ facility_handler_notes TEXT nullable ← Phase 35
```
+**Handler (`handler_type`, Phase 35) — who is doing the work:**
+
+| Value | Meaning | Detail fields | `assigned_to` role |
+|---|---|---|---|
+| `internal` (default) | Our staff | — (the assignee IS the handler) | the handler |
+| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** |
+| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** |
+
+`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Set via the **Update Issue** panel on the issue detail page — the "Handled By" selector reveals the facility or vendor sub-fields via JS. Triage of `handler_type` + facility/vendor detail fields is **admin/director/project_manager only** (same gate as vendor fields); `assigned_to` remains admin/director. Issue list is filterable by `?handler_type=` and shows a Facility/Vendor badge. `Issue.handler_label` gives the display string. Not yet exposed in the mobile API.
+
**Photo columns — three distinct fields with different semantics:**
| Column | Type | Populated by | Displayed as |
@@ -714,7 +728,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase31_device_registry
→ phase32_device_token_columns
→ phase33_contract_recipients
- → phase34_facility_qr ← HEAD
+ → phase34_facility_qr
+ → phase35_issue_handler ← HEAD
```
### phase21_performance_indexes
@@ -810,6 +825,16 @@ flask db upgrade # adds + backfills public_token
sudo systemctl restart gunicorn
```
+### phase35_issue_handler
+
+Revision id `phase35_issue_handler` (file `phase35_issue_handler_type.py`). Adds to `issues`: `handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'` and `facility_handler_name/contact/notes`. **Backfills** existing rows with a non-empty `vendor_name` to `handler_type='vendor'`. Separates WHO handles an issue (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` checks — safe to re-run.
+
+**Deploy order:**
+```bash
+flask db upgrade
+sudo systemctl restart gunicorn
+```
+
**Deploy order for phases 24–32:**
```bash
flask db upgrade
diff --git a/app/models/issue.py b/app/models/issue.py
index be56177..de1f113 100644
--- a/app/models/issue.py
+++ b/app/models/issue.py
@@ -84,6 +84,18 @@ class Issue(db.Model):
vendor_contact = db.Column(db.String(200), nullable=True) # phone or email
vendor_notes = db.Column(db.Text, nullable=True)
+ # ── Who handles the issue (phase35) ──────────────────────────────────
+ # internal = our staff (assigned_to); facility = the facility's own staff
+ # (facility_handler_* below); vendor = external contractor (vendor_* above).
+ # assigned_to remains the internal follow-up owner in ALL cases.
+ handler_type = db.Column(
+ db.Enum('internal', 'facility', 'vendor'),
+ nullable=False, default='internal',
+ )
+ facility_handler_name = db.Column(db.String(100), nullable=True)
+ facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
+ facility_handler_notes = db.Column(db.Text, nullable=True)
+
# Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
# Do NOT add a second explicit db.relationship('Area') here — it conflicts
@@ -102,6 +114,17 @@ class Issue(db.Model):
"""Return True if the given user is currently following this issue."""
return self.followers.filter_by(user_id=user.id).first() is not None
+ # Human-readable label for the handler category (phase35).
+ HANDLER_LABELS = {
+ 'internal': 'Our Staff',
+ 'facility': 'Facility Staff',
+ 'vendor': 'External Vendor',
+ }
+
+ @property
+ def handler_label(self):
+ return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Our Staff')
+
@property
def resolved_facility(self):
"""Returns the Facility for this issue regardless of which path was used to create it.
diff --git a/app/routes/issues.py b/app/routes/issues.py
index 2a5144c..d993c1a 100644
--- a/app/routes/issues.py
+++ b/app/routes/issues.py
@@ -112,9 +112,12 @@ def export_list_pdf():
date_from_filter = request.args.get('date_from', '')
date_to_filter = request.args.get('date_to', '')
reporter_filter = request.args.get('reporter_id', '')
+ handler_filter = request.args.get('handler_type', '')
if issue_id_filter.isdigit():
q = q.filter(Issue.id == int(issue_id_filter))
+ if handler_filter in ('internal', 'facility', 'vendor'):
+ q = q.filter(Issue.handler_type == handler_filter)
if severity_filter:
q = q.filter(Issue.severity == severity_filter)
if status_filter:
@@ -243,9 +246,12 @@ def index():
date_from_filter = request.args.get('date_from', '')
date_to_filter = request.args.get('date_to', '')
reporter_filter = request.args.get('reporter_id', '')
+ handler_filter = request.args.get('handler_type', '')
if issue_id_filter.isdigit():
q = q.filter(Issue.id == int(issue_id_filter))
+ if handler_filter in ('internal', 'facility', 'vendor'):
+ q = q.filter(Issue.handler_type == handler_filter)
if severity_filter:
q = q.filter(Issue.severity == severity_filter)
if status_filter:
@@ -352,6 +358,7 @@ def index():
date_from_filter=date_from_filter,
date_to_filter=date_to_filter,
reporter_filter=reporter_filter,
+ handler_filter=handler_filter,
facilities=facilities,
projects=projects,
staff=staff,
@@ -432,12 +439,22 @@ def view(issue_id):
issue.result_notes = form.result_notes.data or None
- # Vendor / contractor assignment — admin, director, project_manager only
+ # Handler assignment (who handles it) + vendor/facility details —
+ # admin, director, project_manager only.
if current_user.role in ('admin', 'director', 'project_manager'):
+ handler = form.handler_type.data or 'internal'
+ if handler not in ('internal', 'facility', 'vendor'):
+ handler = 'internal'
+ issue.handler_type = handler
+
issue.vendor_name = (form.vendor_name.data or '').strip() or None
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
+ issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None
+ issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
+ issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
+
from app.routes.inspections import _save_photo
new_photos = []
for file_obj in request.files.getlist('result_photos'):
@@ -595,7 +612,7 @@ def view(issue_id):
db.session.commit() # Single atomic commit: issue fields + comment + all notification rows
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
- f'status={issue.status}; assigned_to={issue.assigned_to}')
+ f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html
index de97f07..0671103 100644
--- a/app/templates/issues/list.html
+++ b/app/templates/issues/list.html
@@ -82,6 +82,15 @@
{% endfor %}
+
+
+
+
Clear
@@ -172,6 +181,11 @@
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}
—{% endif %}
{% endif %}
+ {% if issue.handler_type == 'facility' %}
+
Facility
+ {% elif issue.handler_type == 'vendor' %}
+
Vendor
+ {% endif %}
{# Following badge + inline unfollow #}
@@ -184,7 +198,7 @@
class="d-inline"
title="Unfollow this issue">
-
+
|