diff --git a/app/__init__.py b/app/__init__.py index 13106ec..cd14e19 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -165,6 +165,7 @@ def create_app(config_name='default'): from app.routes import scheduled_reports # Phase 6 — Scheduled reports from app.routes import support # Support chat + admin tickets from app.routes import broadcast # Admin broadcast notifications + from app.routes import devices # Admin device registry app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -180,6 +181,7 @@ def create_app(config_name='default'): app.register_blueprint(scheduled_reports.bp) app.register_blueprint(support.bp) app.register_blueprint(broadcast.bp) + app.register_blueprint(devices.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. @@ -197,6 +199,7 @@ def create_app(config_name='default'): 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 + from app.api.devices import bp as _api_devices_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -206,6 +209,7 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) csrf.exempt(_api_comments_bp) + csrf.exempt(_api_devices_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index 178697c..e59e773 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -50,4 +50,7 @@ def register_api(app): from app.api.comments import bp as comments_bp api_bp.register_blueprint(comments_bp) + from app.api.devices import bp as devices_bp + api_bp.register_blueprint(devices_bp) + app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/auth.py b/app/api/auth.py index 3c01af4..67254ed 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -279,18 +279,20 @@ def me(): @jwt_required def register_device(): """ - Register or update the APNs device token for the authenticated user. + Register or update device info for the authenticated user. - Called on every app launch after authentication so the server always - has the current token (APNs rotates tokens periodically). + Called on every app launch so the server always has the current + app version and iOS version for the admin Devices page. + apns_token is optional (empty string when APNs push is not configured). Request JSON ------------ { - "device_id": "", - "apns_token": "", - "device_name": "John's iPhone", // optional - "app_version": "1.0.3" // optional + "device_id": "", + "device_name": "Nguyen\'s iPad", + "app_version": "1.0.3", + "ios_version": "18.3.1", + "apns_token": "" } Response 200 @@ -302,32 +304,37 @@ def register_device(): apns_token = (data.get('apns_token') or '').strip()[:200] device_name = (data.get('device_name') or '').strip()[:100] or None app_version = (data.get('app_version') or '').strip()[:20] or None + ios_version = (data.get('ios_version') or '').strip()[:20] or None - if not device_id or not apns_token: - return api_error('device_id and apns_token are required', 400) + if not device_id: + return api_error('device_id is required', 400) + + now = now_eastern() - # Upsert: update existing row or insert new one existing = DeviceToken.query.filter_by( user_id=g.api_user.id, device_id=device_id, ).first() if existing: - existing.apns_token = apns_token - existing.device_name = device_name - existing.app_version = app_version - existing.registered_at = now_eastern() + existing.apns_token = apns_token or existing.apns_token + existing.device_name = device_name or existing.device_name + existing.app_version = app_version or existing.app_version + existing.ios_version = ios_version or existing.ios_version + existing.last_seen_at = now else: db.session.add(DeviceToken( - user_id = g.api_user.id, - device_id = device_id, - apns_token = apns_token, - device_name = device_name, - app_version = app_version, + user_id = g.api_user.id, + device_id = device_id, + apns_token = apns_token, + device_name = device_name, + app_version = app_version, + ios_version = ios_version, + last_seen_at = now, )) db.session.commit() - logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s', - g.api_user.username, device_id, apns_token[-6:]) + logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | app=%s | ios=%s', + g.api_user.username, device_id[:8], app_version, ios_version) - return api_ok({'registered': True}) \ No newline at end of file + return api_ok({'registered': True}) diff --git a/app/api/devices.py b/app/api/devices.py new file mode 100644 index 0000000..04434b8 --- /dev/null +++ b/app/api/devices.py @@ -0,0 +1,95 @@ +""" +app/api/devices.py +------------------ +Mobile API endpoint for device registration. + +POST /api/v1/devices/register + Upserts a device record for the authenticated user. + Called on every app foreground (active scenePhase) so last_seen_at + stays current and the admin can identify stale / outdated installs. + + Request JSON + ------------ + { + "device_id": "stable-uuid-from-keychain", // required + "device_name": "Nguyen's iPad", // UIDevice.current.name + "app_version": "1.2.0", // CFBundleShortVersionString + "ios_version": "18.3.1" // UIDevice.current.systemVersion + } + + Response 200 + ------------ + { "ok": true, "data": { "registered": true } } +""" + +import logging + +from flask import Blueprint, request, g +from app import db +from app.models.device_registration import DeviceRegistration +from app.api.errors import api_ok, api_error +from app.api.decorators import jwt_required +from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE +from app.utils.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_devices', __name__) + +_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} + + +@bp.route('/devices/register', methods=['POST']) +@jwt_required +def register_device(): + user = g.api_user + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) + + data = request.get_json(silent=True) or {} + + device_id = (data.get('device_id') or '').strip() + device_name = (data.get('device_name') or '').strip()[:255] + app_version = (data.get('app_version') or '').strip()[:32] + ios_version = (data.get('ios_version') or '').strip()[:32] + + if not device_id: + return api_error('device_id is required', 400) + if len(device_id) > 64: + return api_error('device_id too long', 400) + + now = now_eastern() + + existing = DeviceRegistration.query.filter_by(device_id=device_id).first() + if existing: + # Update — always refresh last_seen_at and app/ios version + existing.user_id = user.id # re-bind if different user logs in same device + existing.device_name = device_name or existing.device_name + existing.app_version = app_version or existing.app_version + existing.ios_version = ios_version or existing.ios_version + existing.last_seen_at = now + db.session.commit() + log_action(ACTION_UPDATE, 'DeviceRegistration', existing.id, + f'{device_name} v{app_version}', + f'user={user.username}; ios={ios_version}') + logger.info('API DEVICES | updated | device_id=%s | user=%s | app=%s', + device_id[:8], user.username, app_version) + else: + reg = DeviceRegistration( + device_id = device_id, + user_id = user.id, + device_name = device_name, + app_version = app_version, + ios_version = ios_version, + registered_at = now, + last_seen_at = now, + ) + db.session.add(reg) + db.session.commit() + log_action(ACTION_CREATE, 'DeviceRegistration', reg.id, + f'{device_name} v{app_version}', + f'user={user.username}; ios={ios_version}') + logger.info('API DEVICES | registered | device_id=%s | user=%s | app=%s', + device_id[:8], user.username, app_version) + + return api_ok({'registered': True}) diff --git a/app/api/issues.py b/app/api/issues.py index 90f6a61..6604370 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -433,3 +433,64 @@ def update_issue_photos(issue_id): issue.id, len(new_photos), user.username) return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)}) + +# ── Attach Resolution Photos (mobile) ───────────────────────────────────────── + +@bp.route('/issues//result_photos', methods=['PATCH']) +@jwt_required +def update_issue_result_photos(issue_id): + """ + Attach resolution photos to an issue from the mobile app. + + Called when an inspector marks an issue resolved and uploads photos + showing the fix. Stored in Issue.result_photos so they appear under + "Resolution Details" on the web — identical to photos uploaded via the + web update form. + + Request JSON + ------------ + { "result_photos": ["uploads/issue_result_photos/a.jpg", ...] } + + Response 200 + ------------ + { "ok": true, "data": { "issue_id": 99, "result_photos_count": 2 } } + """ + 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 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 api_error('Access denied', 403) + + data = request.get_json(silent=True) or {} + raw = data.get('result_photos') + + if not isinstance(raw, list): + return api_error('result_photos must be a list of path strings', 400) + + new_photos = [p for p in raw if isinstance(p, str) and p.strip()] + if not new_photos: + return api_error('result_photos must contain at least one valid path', 400) + + # Merge idempotently with any existing result_photos + existing = issue.result_photos or [] + merged = existing + [p for p in new_photos if p not in existing] + issue.result_photos = merged + + db.session.commit() + + log_action(ACTION_UPDATE, 'Issue', issue.id, + f'result_photos updated (+{len(new_photos)} photos)', + f'source=mobile; updated_by={user.username}') + + logger.info('API ISSUES | result_photos_updated | issue_id=%d | added=%d | user=%s', + issue.id, len(new_photos), user.username) + + return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)}) diff --git a/app/api/photos.py b/app/api/photos.py index fcfbbff..852874d 100644 --- a/app/api/photos.py +++ b/app/api/photos.py @@ -46,7 +46,7 @@ def upload_photo(): Multipart form fields --------------------- file — binary image data (jpg / png / gif) - entity_type — "inspection" | "issue" (controls subfolder) + entity_type — "inspection" | "issue" | "issue_result" (controls subfolder) Response 200 ------------ @@ -80,6 +80,8 @@ def upload_photo(): # Determine destination subfolder if entity_type == 'issue': subfolder = 'issue_photos' + elif entity_type == 'issue_result': + subfolder = 'issue_result_photos' else: subfolder = 'inspection_photos' diff --git a/app/models/__init__.py b/app/models/__init__.py index dd90aff..1e43a69 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,4 +5,5 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken -from app.models.notification_matrix import NotificationMatrix \ No newline at end of file +from app.models.notification_matrix import NotificationMatrix +from app.models.device_registration import DeviceRegistration \ No newline at end of file diff --git a/app/models/api_token.py b/app/models/api_token.py index 9dff3e9..168b4a9 100644 --- a/app/models/api_token.py +++ b/app/models/api_token.py @@ -10,9 +10,9 @@ RefreshToken Revocation is instant: delete the row. DeviceToken - One row per (user, device) pair. Stores the APNs token so the server - can push notifications to the device. Updated on every app launch - because APNs tokens can rotate. + One row per (user, device) pair. Stores device info so the admin can + see all installed devices and their versions. Updated on every app + launch because APNs tokens can rotate. """ import secrets @@ -100,25 +100,28 @@ class RefreshToken(db.Model): class DeviceToken(db.Model): """ - APNs device token for push notification delivery. + Device record for admin tracking and optional APNs push delivery. One row per (user, device_id) pair — upserted on every app launch. - The apns_token is the hex string returned by the iOS SDK. + apns_token is optional (empty string when APNs push is not configured). + ios_version and last_seen_at added in phase31 for the admin Devices page. """ __tablename__ = 'api_device_tokens' - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column( + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True, ) - device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor - apns_token = db.Column(db.String(200), nullable=False) - device_name = db.Column(db.String(100), nullable=True) - app_version = db.Column(db.String(20), nullable=True) - registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + device_id = db.Column(db.String(64), nullable=False) + apns_token = db.Column(db.String(200), nullable=False, default='') + device_name = db.Column(db.String(100), nullable=True) + app_version = db.Column(db.String(20), nullable=True) + ios_version = db.Column(db.String(20), nullable=True) + registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + last_seen_at = db.Column(db.DateTime, nullable=True) __table_args__ = ( db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'), diff --git a/app/models/device_registration.py b/app/models/device_registration.py new file mode 100644 index 0000000..31238ea --- /dev/null +++ b/app/models/device_registration.py @@ -0,0 +1,24 @@ +# app/models/device_registration.py +# ----------------------------------- +# Tracks iOS devices that have registered with the server. +# One row per physical device — upserted on every app foreground. + +from app import db +from app.utils.time_utils import now_eastern + + +class DeviceRegistration(db.Model): + __tablename__ = 'device_registrations' + + id = db.Column(db.Integer, primary_key=True) + # Stable UUID generated on first launch and stored in iOS Keychain. + # Unique across all devices; survives app restarts but not device wipes. + device_id = db.Column(db.String(64), nullable=False, unique=True, index=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + device_name = db.Column(db.String(255), nullable=False, default='') + app_version = db.Column(db.String(32), nullable=False, default='') + ios_version = db.Column(db.String(32), nullable=False, default='') + registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + last_seen_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + + user = db.relationship('User', backref=db.backref('devices', lazy='dynamic')) diff --git a/app/routes/devices.py b/app/routes/devices.py new file mode 100644 index 0000000..32d6d35 --- /dev/null +++ b/app/routes/devices.py @@ -0,0 +1,99 @@ +# app/routes/devices.py +# ---------------------- +# Admin-only page for viewing registered iOS devices and pushing +# "please update" notifications to users on outdated app versions. + +import logging +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_required, current_user + +from app import db +from app.models.api_token import DeviceToken +from app.models.notification import Notification +from app.utils.decorators import admin_required +from app.utils.audit import log_action, ACTION_CREATE + +logger = logging.getLogger(__name__) + +bp = Blueprint('devices', __name__, url_prefix='/admin/devices') + + +@bp.route('/', methods=['GET']) +@login_required +@admin_required +def index(): + """Show all registered devices, most-recently-seen first.""" + devices = ( + DeviceToken.query + .order_by(db.func.coalesce(DeviceToken.last_seen_at, DeviceToken.registered_at).desc()) + .all() + ) + return render_template('admin/devices.html', devices=devices) + + +@bp.route('/notify', methods=['POST']) +@login_required +@admin_required +def notify_update(): + """ + Send an in-app update notice to all users whose app_version is below + the version string entered by the admin. + """ + current_version = (request.form.get('current_version') or '').strip() + custom_message = (request.form.get('message') or '').strip() + + if not current_version: + flash('Current version is required.', 'danger') + return redirect(url_for('devices.index')) + + def version_tuple(v: str): + try: + return tuple(int(x) for x in v.strip().split('.')) + except ValueError: + return (0,) + + target_v = version_tuple(current_version) + + all_devices = DeviceToken.query.all() + outdated = [d for d in all_devices if version_tuple(d.app_version or '0') < target_v] + + if not outdated: + flash(f'No devices found running a version older than {current_version}.', 'info') + return redirect(url_for('devices.index')) + + seen_users = set() + notified = 0 + for device in outdated: + if device.user_id in seen_users: + continue + seen_users.add(device.user_id) + + body = custom_message or ( + f'A new version of JanitorialQC ({current_version}) is available. ' + f'Please update from the App Store to get the latest features and fixes.' + ) + notif = Notification( + user_id = device.user_id, + title = f'App Update Available — v{current_version}', + body = body, + link = None, + event_type = 'admin_broadcast', + is_read = False, + ) + db.session.add(notif) + notified += 1 + + db.session.commit() + + log_action(ACTION_CREATE, 'DeviceUpdateNotice', 0, + f'v{current_version} notice → {notified} user(s)', + f'outdated_devices={len(outdated)}; sent_by={current_user.username}') + + logger.info('DEVICES | update_notice | version=%s | users_notified=%d | by=%s', + current_version, notified, current_user.username) + + flash( + f'Update notice sent to {notified} user(s) on {len(outdated)} outdated device(s).', + 'success' + ) + return redirect(url_for('devices.index')) \ No newline at end of file diff --git a/app/templates/admin/devices.html b/app/templates/admin/devices.html new file mode 100644 index 0000000..b7c5625 --- /dev/null +++ b/app/templates/admin/devices.html @@ -0,0 +1,227 @@ +{% extends "base.html" %} + +{% block title %}Device Registry{% endblock %} + +{% block content %} +
+
+

Device Registry

+

+ iOS devices that have opened the JanitorialQC app. + Records update on every app launch — Last Seen reflects the most recent foreground. +

+
+
+ {{ devices|length }} device{{ 's' if devices|length != 1 }} +
+
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} +{% endwith %} + +
+ + {# ── Device Table ──────────────────────────────────────────────────── #} +
+
+
+ + Registered Devices + sorted by most recently active +
+ +
+
+
+ {% if devices %} +
+ + + + + + + + + + + + + {% for d in devices %} + {% set app_ver = d.app_version or '—' %} + + + + + + + + + {% endfor %} + +
DeviceUserApp Ver.iOS Ver.Last SeenRegistered
+ + {{ d.device_name or '—' }} + + {{ d.user.display_name if d.user else '—' }} + {% if d.user %} +
{{ d.user.username }} + {% endif %} +
+ + {{ app_ver }} + + {{ d.ios_version or '—' }} + {{ (d.last_seen_at or d.registered_at).strftime('%b %-d, %Y') }}
+ + {{ (d.last_seen_at or d.registered_at).strftime('%I:%M %p') }} + +
+ {{ d.registered_at.strftime('%b %-d, %Y') }} +
+
+ {% else %} +
+ + No devices registered yet. Devices appear here once the iOS app opens while online. +
+ {% endif %} +
+
+
+ + {# ── Send Update Notice ────────────────────────────────────────────── #} +
+
+
+ Send Update Notice +
+
+

+ Sends an in-app notification to every user whose device is running + an app version older than the version you enter. + Delivered within 60 seconds via the app's background poll. + One notification per user (even if they have multiple devices). +

+
+ + +
+ + +
+ Devices running a version below this will receive the notice. +
+
+ +
+ + +
+ +
+ +
+
+ + {% if devices %} +
+

Version breakdown

+
+ {% endif %} +
+
+
+ +
+ + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 733c6cd..9815153 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -208,6 +208,12 @@ Broadcast + {% endif %}