From e41998b561374ca6204fd0c3f337195ff1ef958a Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 10 Jul 2026 12:29:44 -0400 Subject: [PATCH] Jul 10 - Update codes to catch up with the single tenant project --- CLAUDE.md | 26 +++++++- app/__init__.py | 6 ++ app/models/issue.py | 14 +++++ app/routes/dashboard.py | 7 +++ app/routes/facility_qr.py | 52 +++++++++++++++- app/routes/issues.py | 45 +++++++++++--- app/templates/dashboard.html | 48 ++++++++++++++ app/templates/facility_qr/view.html | 43 +++++++++++++ app/templates/issues/list.html | 18 ++++++ app/templates/issues/view.html | 62 +++++++++++++++++++ app/utils/forms.py | 10 +++ .../versions/phase39_issue_handler_type.py | 57 +++++++++++++++++ 12 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 migrations/versions/phase39_issue_handler_type.py diff --git a/CLAUDE.md b/CLAUDE.md index 1bfdf20..a87eb43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on this codebase. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. -> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; MT-9 iOS pending**) +> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); MT-9 iOS pending**) --- @@ -333,9 +333,15 @@ 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') nullable, ← phase39 + facility_handler_name VARCHAR(100) nullable, ← phase39 + facility_handler_contact VARCHAR(200) nullable, ← phase39 + facility_handler_notes TEXT nullable ← phase39 ``` +**`handler_type` (phase39):** Who is responsible for resolving the issue. `NULL` and `'internal'` both mean janitorial staff (the default). `'facility'` activates the `facility_handler_*` sub-fields (contact at the building). `'vendor'` indicates an external contractor and cross-references the existing `vendor_*` fields and work orders. Editable by admin/director/project_manager on the issue update form. Dashboard shows a three-card handler breakdown for staff roles. Issues list accepts `?handler_type=` filter. `Issue.HANDLER_LABELS` maps enum values to display names. + **Photo columns — three distinct fields with different semantics:** | Column | Type | Populated by | Displayed as | @@ -818,7 +824,18 @@ limiter = Limiter( → phase35_user_mfa → phase36_issue_work_orders → phase37_contract_recipients - → phase38_facility_qr ← HEAD + → phase38_facility_qr + → phase39_issue_handler_type ← HEAD +``` + +### phase39_issue_handler_type + +Adds four nullable columns to `issues`: `handler_type ENUM('internal','facility','vendor')`, `facility_handler_name VARCHAR(100)`, `facility_handler_contact VARCHAR(200)`, `facility_handler_notes TEXT`. Also enables the `POST /f//report` occupant issue reporting endpoint on the `facility_qr` blueprint (no schema change needed — reuses the `issues` table). Guarded with `INFORMATION_SCHEMA` existence checks — safe to re-run. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn ``` ### phase38_facility_qr @@ -1350,6 +1367,9 @@ set -a; . /etc/jqc/control.env; set +a | 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. | | 90 | **Per-contract recipients dispatch INSIDE `notify_by_matrix()` — never call `_notify_project_recipients()` from routes** | phase37. Contract-scoped recipients (`ProjectNotificationRecipient`) are dispatched automatically at the end of `notify_by_matrix()`, after matrix roles + global custom emails, with dedup against both. The contract is resolved from `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`; events fired without any facility context reach matrix recipients only. New `notify_by_matrix()` call sites should pass `facility_id` (or `issue_id`/`inspection_id`) so contract recipients fire. The `score_alert` cron call in `sla.py` now passes `facility_id=fid` for this reason (side effect: if the matrix ever enables `customer` for `score_alert`, customers are facility-scoped instead of org-wide — a strict improvement). Staff recipients use `respect_preferences=False` (contract config is the authority, same as matrix broadcasts). | | 91 | **Facility QR scan page is public and token-authorized — counts + scores ONLY** | phase38, same authorization class as rule 89: `GET /f/` has NO `@login_required`; the unguessable `facilities.qr_token` is the sole credential, because QR posters hang in public hallways. The page must NEVER render free-text issue descriptions, inspector/staff names, photos, or comments — only aggregate counts, scores, dates, template names, and severity/SLA counts. Rate-limited `60/hr`. Inactive facilities 404. The hybrid full-view button appears only when `current_user` is authenticated AND their role scope covers the facility (`_can_view_full()` — staff always; inspector/customer via scope utils); the internal page re-enforces scope anyway. QR URLs are built from `request.host_url` (rule 64 pattern) so each tenant's posters carry its own domain — the route resolves by Host and is NOT tenant-exempt. `qr_svg()` lives in `app/utils/qr.py` (general-purpose; the TOTP-specific `mfa.qr_svg()` mirrors stay untouched). Rotate a leaked poster with `POST /facilities//qr/regenerate`. | +| 92 | **`POST /f//report` creates issues with `reported_by=None` — honeypot protects it** | phase39. The occupant report endpoint shares the same authorization model as rule 91 (token = credential, no login). The honeypot field (`name="website"`, CSS-hidden, `position:absolute;left:-9999px`) silently drops bot submissions by redirecting to the success URL without creating an issue. Rate-limited `5/hr` per IP. The notification fires `notify_by_matrix('issue_created', issue_id=..., facility_id=...)` so admins are notified via the standard matrix. Do not add login gates, photo upload, or internal fields (assignee, comments) to this form — it is intentionally minimal. | +| 93 | **ProxyFix must wrap `app.wsgi_app` — without it, rate limiting and fail2ban are broken** | `app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)` reads `X-Forwarded-For` set by Nginx. Without it, `get_remote_address()` returns `127.0.0.1` for every request — Flask-Limiter shares one counter across all users and fail2ban can never ban an attacker's real IP. Always set in `create_app()` immediately after `app = Flask(__name__)`. | +| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. | --- diff --git a/app/__init__.py b/app/__init__.py index 46850b3..81cf089 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -32,6 +32,12 @@ def create_app(config_name='default'): app = Flask(__name__) app.config.from_object(config[config_name]) + # Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees + # the real client IP (needed for rate limiting and fail2ban logging) and + # the real scheme (needed for HTTPS URL generation in emails). + from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + db.init_app(app) login_manager.init_app(app) migrate.init_app(app, db) diff --git a/app/models/issue.py b/app/models/issue.py index be56177..fcb6eb9 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -84,6 +84,20 @@ class Issue(db.Model): vendor_contact = db.Column(db.String(200), nullable=True) # phone or email vendor_notes = db.Column(db.Text, nullable=True) + # Handler type — who is responsible for resolving the issue (phase39). + # NULL and 'internal' both mean janitorial staff (the default); 'facility' + # unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*. + handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True) + facility_handler_name = db.Column(db.String(100), nullable=True) + facility_handler_contact = db.Column(db.String(200), nullable=True) + facility_handler_notes = db.Column(db.Text, nullable=True) + + HANDLER_LABELS = { + 'internal': 'Janitorial Staff', + 'facility': 'Facility Staff', + 'vendor': 'External Vendor', + } + # 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 diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 8c329d5..c72bb78 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -95,6 +95,12 @@ def index(): 'medium': sum(1 for i in open_issues_all if i.severity == 'medium'), 'low': sum(1 for i in open_issues_all if i.severity == 'low'), } + # Handler breakdown (phase39) — NULL and 'internal' both mean janitorial staff + handler_breakdown = { + 'internal': sum(1 for i in open_issues_all if not i.handler_type or i.handler_type == 'internal'), + 'facility': sum(1 for i in open_issues_all if i.handler_type == 'facility'), + 'vendor': sum(1 for i in open_issues_all if i.handler_type == 'vendor'), + } # ── Issues resolved today ───────────────────────────────────────────────── resolved_today_q = Issue.query.filter( @@ -343,6 +349,7 @@ def index(): completed_today = completed_today, open_issues = open_issues, severity_breakdown = severity_breakdown, + handler_breakdown = handler_breakdown, resolved_today = resolved_today, pending_followups = pending_followups, issues_opened_today = issues_opened_today, diff --git a/app/routes/facility_qr.py b/app/routes/facility_qr.py index 3c0b330..6faf449 100644 --- a/app/routes/facility_qr.py +++ b/app/routes/facility_qr.py @@ -25,7 +25,7 @@ In multi-tenant mode the printed URL is built from the tenant's own domain import logging from datetime import timedelta -from flask import Blueprint, render_template, abort +from flask import Blueprint, render_template, redirect, request, url_for, abort from flask_login import current_user from sqlalchemy import func, or_ @@ -135,6 +135,7 @@ def scan(token): return render_template( 'facility_qr/view.html', + token = token, facility = facility, contract = facility.project, total_90 = total_90, @@ -154,3 +155,52 @@ def scan(token): can_view_full = _can_view_full(facility), generated_at = now, ) + + +@bp.route('//report', methods=['POST']) +@limiter.limit('5 per hour') +def report(token): + """Public occupant issue report submitted from the QR scan page. + + No login required — the unguessable QR token is the sole authorization. + A honeypot field silently rejects bot submissions. Creates an Issue with + reported_by=None so staff know it came from a public form. + """ + facility = Facility.query.filter_by(qr_token=token).first() + if facility is None or not facility.active: + abort(404) + + # Honeypot — bots fill this field, humans leave it blank + if request.form.get('website', '').strip(): + logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id) + return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') + + description = request.form.get('description', '').strip() + severity = request.form.get('severity', 'medium') + + if not description: + return redirect(url_for('facility_qr.scan', token=token)) + if severity not in ('low', 'medium', 'high'): + severity = 'medium' + + issue = Issue( + facility_id = facility.id, + severity = severity, + description = description, + status = 'open', + reported_by = None, # anonymous public submission + ) + db.session.add(issue) + db.session.commit() + + logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s', + facility.id, issue.id, severity) + + # Notify staff via the notification matrix (same event as Issues → Create) + try: + from app.utils.notifications import notify_by_matrix + notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id) + except Exception as exc: + logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc) + + return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') diff --git a/app/routes/issues.py b/app/routes/issues.py index 3d6844a..fd03dcb 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -235,15 +235,17 @@ def index(): ) ) - issue_id_filter = request.args.get('issue_id', '').strip() - severity_filter = request.args.get('severity', '') - status_filter = request.args.get('status', '') - sla_filter = request.args.get('sla', '') - facility_filter = request.args.get('facility_id', '') - contract_filter = request.args.get('contract_id', '') - date_from_filter = request.args.get('date_from', '') - date_to_filter = request.args.get('date_to', '') - reporter_filter = request.args.get('reporter_id', '') + issue_id_filter = request.args.get('issue_id', '').strip() + severity_filter = request.args.get('severity', '') + status_filter = request.args.get('status', '') + sla_filter = request.args.get('sla', '') + facility_filter = request.args.get('facility_id', '') + contract_filter = request.args.get('contract_id', '') + date_from_filter = request.args.get('date_from', '') + date_to_filter = request.args.get('date_to', '') + reporter_filter = request.args.get('reporter_id', '') + handler_type_filter = request.args.get('handler_type', '') + unassigned_filter = request.args.get('unassigned', '') if issue_id_filter.isdigit(): q = q.filter(Issue.id == int(issue_id_filter)) @@ -280,6 +282,17 @@ def index(): Area.facility_id == fid, ) ) + if handler_type_filter: + if handler_type_filter == 'internal': + # NULL handler_type means 'internal' (the default) + q = q.filter(db.or_( + Issue.handler_type == 'internal', + Issue.handler_type.is_(None), + )) + else: + q = q.filter(Issue.handler_type == handler_type_filter) + if unassigned_filter: + q = q.filter(Issue.assigned_to.is_(None)) # SLA filter — SLA status is computed in Python (not a DB column). # When active: load all matching rows, filter in Python, wrap in a # single-page compatible object so the template interface is unchanged. @@ -353,6 +366,8 @@ def index(): date_from_filter=date_from_filter, date_to_filter=date_to_filter, reporter_filter=reporter_filter, + handler_type_filter=handler_type_filter, + unassigned_filter=unassigned_filter, facilities=facilities, projects=projects, staff=staff, @@ -439,6 +454,18 @@ def view(issue_id): issue.vendor_contact = form.vendor_contact.data.strip() or None issue.vendor_notes = form.vendor_notes.data.strip() or None + # Handler type (phase39) + ht = form.handler_type.data or None + issue.handler_type = ht + if ht == 'facility': + issue.facility_handler_name = form.facility_handler_name.data.strip() or None + issue.facility_handler_contact = form.facility_handler_contact.data.strip() or None + issue.facility_handler_notes = form.facility_handler_notes.data.strip() or None + else: + issue.facility_handler_name = None + issue.facility_handler_contact = None + issue.facility_handler_notes = None + from app.routes.inspections import _save_photo new_photos = [] for file_obj in request.files.getlist('result_photos'): diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index a151945..40df130 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -178,6 +178,54 @@ +{# ── Open Issues by Handler (phase39) — staff only ──────────────────────── #} +{% if current_user.role not in ['customer'] %} + +{% endif %} + {# ── SLA Summary ─────────────────────────────────────────────────────────── #} {% if sla_breached > 0 or sla_at_risk > 0 %}
diff --git a/app/templates/facility_qr/view.html b/app/templates/facility_qr/view.html index 3e38d8b..e4b3d9a 100644 --- a/app/templates/facility_qr/view.html +++ b/app/templates/facility_qr/view.html @@ -26,6 +26,13 @@
+ {% if request.args.get('reported') == '1' %} + + {% endif %} +
@@ -170,6 +177,42 @@ {% endif %} + {# ── Report a problem ── #} +
+
+ Report a Problem +
+
+

+ See something that needs attention? Let our team know and we'll take care of it. +

+
+ + {# Honeypot — invisible to humans, filled by bots #} + +
+ + +
+
+ + +
+ +
+
+
+

{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %} Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index de97f07..937f7dc 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -82,6 +82,24 @@ {% endfor %}

+ {% if current_user.role not in ['customer'] %} +
+ + +
+
+
+ + +
+
+ {% endif %}
Clear diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index 2e6ec73..50e39df 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -76,6 +76,28 @@
Assigned To
{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}
+ {% if issue.handler_type and issue.handler_type != 'internal' %} +
Handled By
+
+ {% if issue.handler_type == 'facility' %} + + Facility Staff + + {% if issue.facility_handler_name %} + {{ issue.facility_handler_name }} + {% if issue.facility_handler_contact %} + {{ issue.facility_handler_contact }} + {% endif %} + {% endif %} + {% if issue.facility_handler_notes %} +
{{ issue.facility_handler_notes }}
+ {% endif %} + {% elif issue.handler_type == 'vendor' %} + External Vendor + {% endif %} +
+ {% endif %} + {% if issue.vendor_name %}
Contractor
@@ -366,6 +388,46 @@
{% if current_user.role in ['admin','director','project_manager'] %}
+

+ Handler / Ownership +

+
+ {{ form.handler_type.label(class="form-label small fw-semibold mb-1") }} + {{ form.handler_type(class="form-select form-select-sm", + id="handlerTypeSelect") }} +
+
+
+ {{ form.facility_handler_name.label(class="form-label small fw-semibold mb-1") }} + {{ form.facility_handler_name(class="form-control form-control-sm", + placeholder="Contact name at the facility", + value=issue.facility_handler_name or '') }} +
+
+ {{ form.facility_handler_contact.label(class="form-label small fw-semibold mb-1") }} + {{ form.facility_handler_contact(class="form-control form-control-sm", + placeholder="Phone or email", + value=issue.facility_handler_contact or '') }} +
+
+ {{ form.facility_handler_notes.label(class="form-label small fw-semibold mb-1") }} + {{ form.facility_handler_notes(class="form-control form-control-sm", rows=2, + placeholder="Notes about what they are handling…") }} +
+
+ +

External Contractor

diff --git a/app/utils/forms.py b/app/utils/forms.py index 5e94ea0..421fe27 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -223,6 +223,16 @@ class IssueUpdateForm(FlaskForm): vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) + # Handler type (phase39) + handler_type = SelectField('Handled By', choices=[ + ('', '— Select —'), + ('internal', 'Janitorial Staff'), + ('facility', 'Facility Staff'), + ('vendor', 'External Vendor'), + ], validators=[Optional()]) + facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)]) + facility_handler_contact = StringField('Facility Contact Phone/Email', validators=[Optional(), Length(max=200)]) + facility_handler_notes = TextAreaField('Facility Handler Notes', validators=[Optional(), Length(max=1000)]) # ── Projects ───────────────────────────────────────────────────────────────── diff --git a/migrations/versions/phase39_issue_handler_type.py b/migrations/versions/phase39_issue_handler_type.py new file mode 100644 index 0000000..8c8822f --- /dev/null +++ b/migrations/versions/phase39_issue_handler_type.py @@ -0,0 +1,57 @@ +"""Add handler_type and facility-handler fields to issues (phase39). + +Tracks who is responsible for resolving an issue: + internal — janitorial staff (default / NULL) + facility — building / facility staff (adds name/contact/notes sub-fields) + vendor — external contractor (already tracked via vendor_* columns) + +All columns are nullable so existing rows are unaffected (NULL == 'internal'). +Uses INFORMATION_SCHEMA existence checks — safe to re-run. +""" +from alembic import op +import sqlalchemy as sa + +revision = 'phase39_issue_handler_type' +down_revision = 'phase38_facility_qr' +branch_labels = None +depends_on = None + + +def _col_exists(conn, table, column): + row = conn.execute(sa.text(""" + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :t + AND COLUMN_NAME = :c + """), {'t': table, 'c': column}).scalar() + return bool(row) + + +def upgrade(): + conn = op.get_bind() + + if not _col_exists(conn, 'issues', 'handler_type'): + op.add_column('issues', sa.Column( + 'handler_type', + sa.Enum('internal', 'facility', 'vendor'), + nullable=True, + )) + + if not _col_exists(conn, 'issues', 'facility_handler_name'): + op.add_column('issues', sa.Column( + 'facility_handler_name', sa.String(100), nullable=True)) + + if not _col_exists(conn, 'issues', 'facility_handler_contact'): + op.add_column('issues', sa.Column( + 'facility_handler_contact', sa.String(200), nullable=True)) + + if not _col_exists(conn, 'issues', 'facility_handler_notes'): + op.add_column('issues', sa.Column( + 'facility_handler_notes', sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column('issues', 'facility_handler_notes') + op.drop_column('issues', 'facility_handler_contact') + op.drop_column('issues', 'facility_handler_name') + op.drop_column('issues', 'handler_type')