diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index d4d3f50..1dc6e41 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -160,7 +160,9 @@ Each phase additive; existing tenant-zero traffic keeps working throughout. **MT-2 — Per-tenant migration runner. ✅ DONE (with blocker found).** Standalone `migrations_tenant/env.py` (reads URL from config, reuses `migrations/versions` via `version_locations`, no Flask) + `control/tenant_migrate.py` (`upgrade_tenant()`, `current_revision()`, `chain_head()`, CLI: `python -m control.tenant_migrate upgrade|current|heads --tenant `). Records `tenants.alembic_head` + a `ProvisioningJob('migrate')` per run. Existing `migrations/env.py` untouched (normal `flask db` still works). -> **⚠ Blocker found by MT-2:** the live `migrations/versions` chain has **no base** — `phase1_projects_roles.down_revision = '0003_add_user_active'`, which is absent, and no revision has `down_revision = None`. Alembic cannot build the revision map, so `upgrade head` fails against any DB (even a no-op on an up-to-date one). The pre-`phase1` baseline migrations must be restored (or a guarded squashed baseline created) before MT-2 can run against real MySQL DBs and before MT-3 can provision fresh tenants. +> **⚠ Blocker found by MT-2 — RESOLVED.** The live `migrations/versions` chain had **no base**: `phase1_projects_roles.down_revision = '0003_add_user_active'` pointed at a missing revision, and 14/30 phase migrations are not idempotent. **Fix:** a guarded squashed baseline `migrations/versions/0003_add_user_active.py` (`down_revision = None`) that recreates the full current schema (generated from the models, 25 tables, INFORMATION_SCHEMA-guarded) — restoring the chain root. Fresh tenants are built via `bootstrap_tenant()` = upgrade to the baseline **then `stamp head`**, so the non-idempotent phase migrations are never replayed. Existing DBs (LT) are at a later head, so the baseline is treated as applied ancestry and never runs. Ongoing migrations (phase33+, which MUST be guarded) apply incrementally to all tenants via `upgrade_tenant()`. +> +> **Operational note:** any *fresh* database (including a new dev DB) must use the bootstrap flow, not a naive `flask db upgrade` from empty, because the historical phase replay still hits the unguarded migrations. Cross-check the baseline against `mysqldump --no-data` of LT and test `bootstrap` on a scratch MySQL before going live. **MT-3 — Provisioning service.** Create DB → create **per-tenant MySQL user + password** + grant scoped to that DB only → upgrade to head → seed first tenant-admin → invite email. Creds encrypted into the `tenants` row. Idempotent, `provisioning_jobs`-logged. diff --git a/control/tenant_migrate.py b/control/tenant_migrate.py index a9e172b..771903a 100644 --- a/control/tenant_migrate.py +++ b/control/tenant_migrate.py @@ -46,6 +46,9 @@ _DEFAULT_VERSION_LOCATIONS = os.path.join(_REPO_ROOT, 'migrations', 'versions') # Everything except a deleted tenant should be kept schema-current. MIGRATABLE_STATUSES = ('provisioning', 'active', 'suspended') +# Squashed baseline that builds the full schema (migrations/versions root). +BASELINE_REVISION = '0003_add_user_active' + TenantRef = namedtuple('TenantRef', ['id', 'slug', 'db_uri']) @@ -125,7 +128,56 @@ def upgrade_tenant(tenant, script_location=None, version_locations=None, record_ raise -# ── CLI ──────────────────────────────────────────────────────────────────── +def bootstrap_tenant(tenant, script_location=None, version_locations=None, + baseline_rev=BASELINE_REVISION, record_job=True): + """Build a FRESH tenant database, then mark it current. + + Runs the squashed baseline (full schema) only, then `stamp head` — the + historical phase migrations are NOT replayed (several are not idempotent and + would conflict with the full-schema baseline). Use this for brand-new tenant + databases; use upgrade_tenant() for ongoing incremental migrations. + + Returns the stamped head revision. + """ + db_uri = tenant.db_uri + cfg = _make_config(db_uri, script_location, version_locations) + + job_id = None + if record_job: + with control_session() as s: + job = ProvisioningJob(tenant_id=tenant.id, action='migrate', + status='running', created_at=now_eastern()) + s.add(job) + s.flush() + job_id = job.id + + try: + with contextlib.redirect_stdout(io.StringIO()): + command.upgrade(cfg, baseline_rev) # build full schema (baseline only) + command.stamp(cfg, 'head') # mark at head without replaying phases + applied = current_revision(db_uri) + + with control_session() as s: + t = s.get(Tenant, tenant.id) + if t is not None: + t.alembic_head = applied + if job_id is not None: + j = s.get(ProvisioningJob, job_id) + if j is not None: + j.status = 'ok' + j.finished_at = now_eastern() + j.log = f'bootstrapped (baseline {baseline_rev}) + stamped {applied}' + return applied + + except Exception as e: + if job_id is not None: + with control_session() as s: + j = s.get(ProvisioningJob, job_id) + if j is not None: + j.status = 'failed' + j.finished_at = now_eastern() + j.log = f'{type(e).__name__}: {e}' + raise def _select_tenants(selector): with control_session() as s: @@ -174,6 +226,22 @@ def _cmd_current(args): return 0 +def _cmd_bootstrap(args): + tenants = _select_tenants(args.tenant) + if not tenants: + print(f'No matching tenants for --tenant {args.tenant}.') + return 0 + failures = 0 + for t in tenants: + try: + applied = bootstrap_tenant(t) + print(f' [ok] {t.slug} (id={t.id}) bootstrapped -> {applied}') + except Exception as e: + failures += 1 + print(f' [FAIL] {t.slug} (id={t.id}): {type(e).__name__}: {e}') + return 1 if failures else 0 + + def _cmd_heads(_args): print(chain_head()) return 0 @@ -192,6 +260,11 @@ def main(argv=None): p_cur.add_argument('--tenant', default='all', help="'all', or a tenant id or slug") p_cur.set_defaults(func=_cmd_current) + p_boot = sub.add_parser('bootstrap', + help='Build a FRESH tenant DB (baseline + stamp head)') + p_boot.add_argument('--tenant', required=True, help="'all', or a tenant id or slug") + p_boot.set_defaults(func=_cmd_bootstrap) + sub.add_parser('heads', help='Show the chain head revision').set_defaults(func=_cmd_heads) args = parser.parse_args(argv) diff --git a/migrations/versions/0003_add_user_active.py b/migrations/versions/0003_add_user_active.py new file mode 100644 index 0000000..f565746 --- /dev/null +++ b/migrations/versions/0003_add_user_active.py @@ -0,0 +1,525 @@ +"""baseline (squashed) — pre-phase1 core schema + +Revision ID: 0003_add_user_active +Revises: +Create Date: 2026-06-26 + +Squashed baseline that restores the missing root of the tenant migration chain. +The original 0001/0002/0003_add_user_active migrations were lost; phase1's +down_revision pointed at a non-existent '0003_add_user_active', leaving the +chain with no base and unbuildable by Alembic. + +This revision recreates the FULL current application schema (generated from the +SQLAlchemy models) with INFORMATION_SCHEMA existence guards (Rule 14), so it is: + * a complete no-op on existing databases (e.g. tenant-zero / LT, already at + a later head) — Alembic treats it as applied ancestry and never runs it; + * the schema builder for FRESH tenant databases. + +IMPORTANT — fresh tenant provisioning uses "upgrade to THIS revision, then +stamp head" (see control/tenant_migrate.bootstrap_tenant). The historical +phase1..phaseN migrations are NOT replayed on fresh DBs (several are not +idempotent), so this baseline must represent the complete schema on its own. +""" +from alembic import op +import sqlalchemy as sa + +revision = '0003_add_user_active' +down_revision = None +branch_labels = None +depends_on = None + + +def _table_exists(bind, table): + return bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = :t" + ), {'t': table}).scalar() > 0 + + +def _index_exists(bind, table, index): + return bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i" + ), {'t': table, 'i': index}).scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _table_exists(bind, 'notification_matrix'): + op.execute(sa.text("""CREATE TABLE notification_matrix ( + id INTEGER NOT NULL AUTO_INCREMENT, + event_type VARCHAR(50) NOT NULL, + role_key VARCHAR(30) NOT NULL, + enabled BOOL NOT NULL, + custom_emails TEXT, + PRIMARY KEY (id), + CONSTRAINT uq_notif_matrix_event_role UNIQUE (event_type, role_key) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'users'): + op.execute(sa.text("""CREATE TABLE users ( + id INTEGER NOT NULL AUTO_INCREMENT, + username VARCHAR(100) NOT NULL, + full_name VARCHAR(150), + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + `role` ENUM('admin','director','inspector','project_manager','customer') NOT NULL, + created_at DATETIME, + active BOOL NOT NULL, + password_set BOOL NOT NULL, + set_password_token VARCHAR(64), + set_password_token_expires DATETIME, + PRIMARY KEY (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'users', 'ix_users_email'): + op.execute(sa.text("CREATE UNIQUE INDEX ix_users_email ON users (email)")) + if not _index_exists(bind, 'users', 'ix_users_set_password_token'): + op.execute(sa.text("CREATE INDEX ix_users_set_password_token ON users (set_password_token)")) + if not _index_exists(bind, 'users', 'ix_users_username'): + op.execute(sa.text("CREATE UNIQUE INDEX ix_users_username ON users (username)")) + + if not _table_exists(bind, 'api_device_tokens'): + op.execute(sa.text("""CREATE TABLE api_device_tokens ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + device_id VARCHAR(64) NOT NULL, + apns_token VARCHAR(200) NOT NULL, + device_name VARCHAR(100), + app_version VARCHAR(20), + ios_version VARCHAR(20), + registered_at DATETIME NOT NULL, + last_seen_at DATETIME, + PRIMARY KEY (id), + CONSTRAINT uq_device_token_user_device UNIQUE (user_id, device_id), + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'api_device_tokens', 'ix_api_device_tokens_user_id'): + op.execute(sa.text("CREATE INDEX ix_api_device_tokens_user_id ON api_device_tokens (user_id)")) + + if not _table_exists(bind, 'api_refresh_tokens'): + op.execute(sa.text("""CREATE TABLE api_refresh_tokens ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + token_hash VARCHAR(64) NOT NULL, + device_id VARCHAR(64), + device_name VARCHAR(100), + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + revoked BOOL NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'api_refresh_tokens', 'ix_api_refresh_tokens_token_hash'): + op.execute(sa.text("CREATE UNIQUE INDEX ix_api_refresh_tokens_token_hash ON api_refresh_tokens (token_hash)")) + if not _index_exists(bind, 'api_refresh_tokens', 'ix_api_refresh_tokens_user_id'): + op.execute(sa.text("CREATE INDEX ix_api_refresh_tokens_user_id ON api_refresh_tokens (user_id)")) + + if not _table_exists(bind, 'audit_logs'): + op.execute(sa.text("""CREATE TABLE audit_logs ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER, + username VARCHAR(100) NOT NULL, + user_role VARCHAR(20) NOT NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id INTEGER, + entity_label VARCHAR(255), + details TEXT, + created_at DATETIME NOT NULL, + ip_address VARCHAR(45), + PRIMARY KEY (id), + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'audit_logs', 'ix_audit_logs_action'): + op.execute(sa.text("CREATE INDEX ix_audit_logs_action ON audit_logs (action)")) + if not _index_exists(bind, 'audit_logs', 'ix_audit_logs_created_at'): + op.execute(sa.text("CREATE INDEX ix_audit_logs_created_at ON audit_logs (created_at)")) + if not _index_exists(bind, 'audit_logs', 'ix_audit_logs_entity_type'): + op.execute(sa.text("CREATE INDEX ix_audit_logs_entity_type ON audit_logs (entity_type)")) + + if not _table_exists(bind, 'broadcasts'): + op.execute(sa.text("""CREATE TABLE broadcasts ( + id INTEGER NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + target_roles JSON NOT NULL, + sent_by_id INTEGER, + sent_at DATETIME NOT NULL, + recipient_count INTEGER NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(sent_by_id) REFERENCES users (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'device_registrations'): + op.execute(sa.text("""CREATE TABLE device_registrations ( + id INTEGER NOT NULL AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + user_id INTEGER NOT NULL, + device_name VARCHAR(255) NOT NULL, + app_version VARCHAR(32) NOT NULL, + ios_version VARCHAR(32) NOT NULL, + registered_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(user_id) REFERENCES users (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'device_registrations', 'ix_device_registrations_device_id'): + op.execute(sa.text("CREATE UNIQUE INDEX ix_device_registrations_device_id ON device_registrations (device_id)")) + if not _index_exists(bind, 'device_registrations', 'ix_device_registrations_user_id'): + op.execute(sa.text("CREATE INDEX ix_device_registrations_user_id ON device_registrations (user_id)")) + + if not _table_exists(bind, 'inspection_templates'): + op.execute(sa.text("""CREATE TABLE inspection_templates ( + id INTEGER NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + description TEXT, + frequency ENUM('daily','weekly','monthly','quarterly'), + created_by INTEGER, + created_at DATETIME, + form_schema JSON, + active BOOL NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(created_by) REFERENCES users (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'notification_preferences'): + op.execute(sa.text("""CREATE TABLE notification_preferences ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + event_type VARCHAR(50) NOT NULL, + email_enabled BOOL NOT NULL, + digest_mode BOOL NOT NULL, + digest_frequency VARCHAR(10) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_notif_pref_user_event UNIQUE (user_id, event_type), + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'notification_preferences', 'ix_notification_preferences_user_id'): + op.execute(sa.text("CREATE INDEX ix_notification_preferences_user_id ON notification_preferences (user_id)")) + + if not _table_exists(bind, 'projects'): + op.execute(sa.text("""CREATE TABLE projects ( + id INTEGER NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + description TEXT, + project_manager_id INTEGER, + active BOOL NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(project_manager_id) REFERENCES users (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'checklist_items'): + op.execute(sa.text("""CREATE TABLE checklist_items ( + id INTEGER NOT NULL AUTO_INCREMENT, + template_id INTEGER NOT NULL, + category VARCHAR(100), + item_description TEXT NOT NULL, + scoring_type ENUM('pass_fail','rating_5','rating_10'), + weight NUMERIC(3, 2), + requires_photo BOOL, + display_order INTEGER, + PRIMARY KEY (id), + FOREIGN KEY(template_id) REFERENCES inspection_templates (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'facilities'): + op.execute(sa.text("""CREATE TABLE facilities ( + id INTEGER NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + address TEXT, + contact_person VARCHAR(100), + contact_phone VARCHAR(20), + active BOOL, + created_at DATETIME, + project_id INTEGER, + PRIMARY KEY (id), + FOREIGN KEY(project_id) REFERENCES projects (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'facilities', 'ix_facilities_project_id'): + op.execute(sa.text("CREATE INDEX ix_facilities_project_id ON facilities (project_id)")) + + if not _table_exists(bind, 'inspector_assignments'): + op.execute(sa.text("""CREATE TABLE inspector_assignments ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_inspector_project UNIQUE (user_id, project_id), + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'inspector_assignments', 'ix_inspector_assignments_user_id'): + op.execute(sa.text("CREATE INDEX ix_inspector_assignments_user_id ON inspector_assignments (user_id)")) + + if not _table_exists(bind, 'areas'): + op.execute(sa.text("""CREATE TABLE areas ( + id INTEGER NOT NULL AUTO_INCREMENT, + facility_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + area_type VARCHAR(50), + PRIMARY KEY (id), + FOREIGN KEY(facility_id) REFERENCES facilities (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'customer_assignments'): + op.execute(sa.text("""CREATE TABLE customer_assignments ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + facility_id INTEGER, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_customer_assignment UNIQUE (user_id, project_id, facility_id), + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'customer_assignments', 'ix_customer_assignments_facility_id'): + op.execute(sa.text("CREATE INDEX ix_customer_assignments_facility_id ON customer_assignments (facility_id)")) + if not _index_exists(bind, 'customer_assignments', 'ix_customer_assignments_project_id'): + op.execute(sa.text("CREATE INDEX ix_customer_assignments_project_id ON customer_assignments (project_id)")) + if not _index_exists(bind, 'customer_assignments', 'ix_customer_assignments_user_id'): + op.execute(sa.text("CREATE INDEX ix_customer_assignments_user_id ON customer_assignments (user_id)")) + + if not _table_exists(bind, 'facility_score_alerts'): + op.execute(sa.text("""CREATE TABLE facility_score_alerts ( + id INTEGER NOT NULL AUTO_INCREMENT, + facility_id INTEGER NOT NULL, + sent_at DATETIME NOT NULL, + current_avg NUMERIC(5, 2) NOT NULL, + prior_avg NUMERIC(5, 2) NOT NULL, + delta NUMERIC(5, 2) NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'scheduled_reports'): + op.execute(sa.text("""CREATE TABLE scheduled_reports ( + id INTEGER NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + report_type ENUM('summary','facility','issues') NOT NULL, + frequency ENUM('daily','weekly','monthly') NOT NULL, + facility_id INTEGER, + recipients JSON NOT NULL, + include_pdf BOOL NOT NULL, + include_csv BOOL NOT NULL, + active BOOL NOT NULL, + created_by INTEGER, + created_at DATETIME NOT NULL, + last_sent_at DATETIME, + next_send_at DATETIME, + PRIMARY KEY (id), + FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE SET NULL, + FOREIGN KEY(created_by) REFERENCES users (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'support_tickets'): + op.execute(sa.text("""CREATE TABLE support_tickets ( + id INTEGER NOT NULL AUTO_INCREMENT, + customer_id INTEGER, + facility_id INTEGER, + subject VARCHAR(200) NOT NULL, + body TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(customer_id) REFERENCES users (id) ON DELETE SET NULL, + FOREIGN KEY(facility_id) REFERENCES facilities (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'inspections'): + op.execute(sa.text("""CREATE TABLE inspections ( + id INTEGER NOT NULL AUTO_INCREMENT, + template_id INTEGER NOT NULL, + facility_id INTEGER NOT NULL, + area_id INTEGER, + inspector_id INTEGER NOT NULL, + inspection_date DATETIME NOT NULL, + overall_score NUMERIC(5, 2), + status ENUM('in_progress','completed','flagged'), + notes TEXT, + form_data JSON, + completed_at DATETIME, + mobile_local_id VARCHAR(64), + submit_latitude NUMERIC(10, 7), + submit_longitude NUMERIC(10, 7), + parent_inspection_id INTEGER, + follow_up_required BOOL NOT NULL, + follow_up_note TEXT, + PRIMARY KEY (id), + FOREIGN KEY(template_id) REFERENCES inspection_templates (id), + FOREIGN KEY(facility_id) REFERENCES facilities (id), + FOREIGN KEY(area_id) REFERENCES areas (id), + FOREIGN KEY(inspector_id) REFERENCES users (id), + FOREIGN KEY(parent_inspection_id) REFERENCES inspections (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'inspections', 'ix_inspections_mobile_local_id'): + op.execute(sa.text("CREATE INDEX ix_inspections_mobile_local_id ON inspections (mobile_local_id)")) + + if not _table_exists(bind, 'support_ticket_replies'): + op.execute(sa.text("""CREATE TABLE support_ticket_replies ( + id INTEGER NOT NULL AUTO_INCREMENT, + ticket_id INTEGER NOT NULL, + user_id INTEGER, + body TEXT NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(ticket_id) REFERENCES support_tickets (id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'inspection_results'): + op.execute(sa.text("""CREATE TABLE inspection_results ( + id INTEGER NOT NULL AUTO_INCREMENT, + inspection_id INTEGER NOT NULL, + checklist_item_id INTEGER NOT NULL, + score NUMERIC(5, 2), + passed BOOL, + comments TEXT, + photo_path VARCHAR(255), + PRIMARY KEY (id), + FOREIGN KEY(inspection_id) REFERENCES inspections (id), + FOREIGN KEY(checklist_item_id) REFERENCES checklist_items (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'issues'): + op.execute(sa.text("""CREATE TABLE issues ( + id INTEGER NOT NULL AUTO_INCREMENT, + inspection_id INTEGER, + area_id INTEGER, + facility_id INTEGER, + severity ENUM('low','medium','high','critical') NOT NULL, + description TEXT NOT NULL, + photo_path VARCHAR(255), + status ENUM('open','in_progress','resolved','pending_verification'), + assigned_to INTEGER, + reported_by INTEGER, + reported_at DATETIME, + resolved_at DATETIME, + result_notes TEXT, + result_photos JSON, + mobile_photo_paths JSON, + verified_by INTEGER, + verified_at DATETIME, + verification_note TEXT, + sla_notified VARCHAR(10), + mobile_local_id VARCHAR(64), + vendor_name VARCHAR(100), + vendor_contact VARCHAR(200), + vendor_notes TEXT, + PRIMARY KEY (id), + FOREIGN KEY(inspection_id) REFERENCES inspections (id), + FOREIGN KEY(area_id) REFERENCES areas (id), + FOREIGN KEY(facility_id) REFERENCES facilities (id), + FOREIGN KEY(assigned_to) REFERENCES users (id), + FOREIGN KEY(reported_by) REFERENCES users (id) ON DELETE SET NULL, + FOREIGN KEY(verified_by) REFERENCES users (id) ON DELETE SET NULL +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'issues', 'ix_issues_mobile_local_id'): + op.execute(sa.text("CREATE INDEX ix_issues_mobile_local_id ON issues (mobile_local_id)")) + + if not _table_exists(bind, 'issue_comments'): + op.execute(sa.text("""CREATE TABLE issue_comments ( + id INTEGER NOT NULL AUTO_INCREMENT, + issue_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status_at_time VARCHAR(20), + body TEXT NOT NULL, + created_at DATETIME NOT NULL, + is_customer_visible BOOL NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(issue_id) REFERENCES issues (id), + FOREIGN KEY(user_id) REFERENCES users (id) +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'issue_followers'): + op.execute(sa.text("""CREATE TABLE issue_followers ( + id INTEGER NOT NULL AUTO_INCREMENT, + issue_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_issue_follower UNIQUE (issue_id, user_id), + FOREIGN KEY(issue_id) REFERENCES issues (id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + + if not _table_exists(bind, 'notifications'): + op.execute(sa.text("""CREATE TABLE notifications ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + link VARCHAR(512), + is_read BOOL NOT NULL, + created_at DATETIME NOT NULL, + issue_id INTEGER, + inspection_id INTEGER, + event_type VARCHAR(50), + digest_pending BOOL NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(user_id) REFERENCES users (id), + FOREIGN KEY(issue_id) REFERENCES issues (id) ON DELETE CASCADE, + FOREIGN KEY(inspection_id) REFERENCES inspections (id) ON DELETE CASCADE +)DEFAULT CHARSET=utf8mb4 ENGINE=InnoDB""")) + if not _index_exists(bind, 'notifications', 'ix_notifications_digest_pending'): + op.execute(sa.text("CREATE INDEX ix_notifications_digest_pending ON notifications (digest_pending)")) + if not _index_exists(bind, 'notifications', 'ix_notifications_user_id'): + op.execute(sa.text("CREATE INDEX ix_notifications_user_id ON notifications (user_id)")) + + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'notifications'): + op.execute(sa.text('DROP TABLE notifications')) + if _table_exists(bind, 'issue_followers'): + op.execute(sa.text('DROP TABLE issue_followers')) + if _table_exists(bind, 'issue_comments'): + op.execute(sa.text('DROP TABLE issue_comments')) + if _table_exists(bind, 'issues'): + op.execute(sa.text('DROP TABLE issues')) + if _table_exists(bind, 'inspection_results'): + op.execute(sa.text('DROP TABLE inspection_results')) + if _table_exists(bind, 'support_ticket_replies'): + op.execute(sa.text('DROP TABLE support_ticket_replies')) + if _table_exists(bind, 'inspections'): + op.execute(sa.text('DROP TABLE inspections')) + if _table_exists(bind, 'support_tickets'): + op.execute(sa.text('DROP TABLE support_tickets')) + if _table_exists(bind, 'scheduled_reports'): + op.execute(sa.text('DROP TABLE scheduled_reports')) + if _table_exists(bind, 'facility_score_alerts'): + op.execute(sa.text('DROP TABLE facility_score_alerts')) + if _table_exists(bind, 'customer_assignments'): + op.execute(sa.text('DROP TABLE customer_assignments')) + if _table_exists(bind, 'areas'): + op.execute(sa.text('DROP TABLE areas')) + if _table_exists(bind, 'inspector_assignments'): + op.execute(sa.text('DROP TABLE inspector_assignments')) + if _table_exists(bind, 'facilities'): + op.execute(sa.text('DROP TABLE facilities')) + if _table_exists(bind, 'checklist_items'): + op.execute(sa.text('DROP TABLE checklist_items')) + if _table_exists(bind, 'projects'): + op.execute(sa.text('DROP TABLE projects')) + if _table_exists(bind, 'notification_preferences'): + op.execute(sa.text('DROP TABLE notification_preferences')) + if _table_exists(bind, 'inspection_templates'): + op.execute(sa.text('DROP TABLE inspection_templates')) + if _table_exists(bind, 'device_registrations'): + op.execute(sa.text('DROP TABLE device_registrations')) + if _table_exists(bind, 'broadcasts'): + op.execute(sa.text('DROP TABLE broadcasts')) + if _table_exists(bind, 'audit_logs'): + op.execute(sa.text('DROP TABLE audit_logs')) + if _table_exists(bind, 'api_refresh_tokens'): + op.execute(sa.text('DROP TABLE api_refresh_tokens')) + if _table_exists(bind, 'api_device_tokens'): + op.execute(sa.text('DROP TABLE api_device_tokens')) + if _table_exists(bind, 'users'): + op.execute(sa.text('DROP TABLE users')) + if _table_exists(bind, 'notification_matrix'): + op.execute(sa.text('DROP TABLE notification_matrix'))