From b14c6b508a861ea1ad7c63c0dd40aba0ebf9c599 Mon Sep 17 00:00:00 2001 From: saquib Date: Tue, 8 Sep 2026 11:00:05 +0530 Subject: [PATCH] latest code --- .dockerignore | 54 + .env.development | 68 + .env.localdev | 132 + .env.production | 97 + .env.test | 68 + .env.uat | 68 + .gitignore | 79 + .pre-commit-config.yaml | 58 + .python-version | 1 + Dockerfile | 80 + README.md | 426 + alembic.ini | 116 + alembic/README | 1 + alembic/env.py | 43 + alembic/script.py.mako | 26 + .../06001599321d_add_collaboration_chat.py | 26 + .../0bffea34ee5f_disable_rls_temporarily.py | 35 + ...a9_add_max_bytes_quota_to_user_storage_.py | 38 + .../14b4aa5f0f5d_merge_multiple_heads.py | 26 + ...5f935213b22_add_tenant_storage_settings.py | 66 + .../1cb413ffaaa0_add_saas_mapping_tables.py | 59 + ...1f4d2a8c9b77_add_missing_vector_columns.py | 67 + .../229359b0d1b2_add_terms_accepted.py | 64 + ...1_add_shared_by_user_id_to_projectshare.py | 32 + .../29c02cb50737_initial_migration.py | 258 + ...4c62c1cf35_add_subscription_fields_and_.py | 51 + ..._add_signature_id_to_signature_imprints.py | 128 + ...nforce_enum_constraint_on_driveacl_role.py | 51 + ...ed9_refactor_documents_and_add_missing_.py | 100 + ...imit_notification_message_to_2000_chars.py | 36 + alembic/versions/49c19df2c1d4_sync_models.py | 371 + .../4a9dc50ce4af_drop_price_display.py | 33 + ...22ee6d91431_add_drive_share_link_fields.py | 30 + ...40c970ff890_add_tenant_chat_token_quota.py | 42 + ...90047e85_increase_file_size_constraints.py | 74 + ...1_add_multiple_files_to_signing_request.py | 34 + .../6c9cc3567866_merge_multiple_heads.py | 26 + .../73dff81eed72_add_tenant_contacts.py | 43 + .../765b053e3dcd_add_vector_index_checksum.py | 30 + ...cc1bb2_refactor_global_signature_config.py | 32 + ...5_add_chat_token_usage_and_quota_tables.py | 70 + .../80be2c61e7fc_add_is_popular_to_plans.py | 30 + ...a486fd287_add_created_at_to_drive_stars.py | 36 + .../8736ab6bd5af_add_notifications_table.py | 41 + .../88717d544229_add_is_default_to_roles.py | 34 + ..._limit_drivecomment_body_to_10000_chars.py | 36 + .../8d4dced54966_add_is_root_column.py | 59 + ...8_add_plan_roles_and_is_system_to_roles.py | 34 + ...7575_tenant_storage_config_and_provider.py | 415 + .../9a6db38b86a6_make_drivecomment_generic.py | 57 + alembic/versions/9af99fc7ca0f_merge_heads.py | 26 + ...9fe120ffb_add_device_and_session_tables.py | 79 + .../9c4a95ac6726_add_saasrolemapping.py | 45 + ...f3_limit_chatbotdocument_file_path_and_.py | 30 + .../versions/a1b2c3d4e5f6_add_rbac_tables.py | 90 + ...e6f7_update_access_model_with_hierarchy.py | 75 + ...e_remove_redundant_id_column_from_user_.py | 34 + ...f57c3d9f1c2_add_signature_config_tables.py | 64 + .../versions/b029d2f5a0c7_add_signature_id.py | 36 + ...3348_add_persistent_chat_history_tables.py | 131 + alembic/versions/b1_0_explicit_superadmin.py | 62 + alembic/versions/b1_3_row_level_security.py | 87 + ...e9f042_add_collaboration_messages_table.py | 55 + alembic/versions/b6_4_index_review.py | 65 + ...b_remove_tenant_storage_config_and_use_.py | 48 + ..._add_encrypted_ai_api_key_to_user_model.py | 38 + .../c1_0_org_units_and_scoped_roles.py | 247 + alembic/versions/c1_1_user_roles_tenant_id.py | 80 + alembic/versions/c3_0_org_unit_path.py | 77 + ...3abf44266de_enforce_b2_storage_provider.py | 40 + alembic/versions/c4_0_grant_expiry.py | 46 + alembic/versions/c4_1_access_log_retention.py | 39 + alembic/versions/c5_0_access_groups.py | 161 + .../c7d4e6f1a9b0_add_tenant_storage_quota.py | 66 + .../ca14c4ab10d9_merge_multiple_heads.py | 26 + ...70ab35793_create_chat_token_usage_table.py | 83 + ...7562afa2e_add_chat_token_usage_tracking.py | 116 + .../cfbee4f91fc2_add_updated_at_defaults.py | 52 + .../versions/d1_0_plans_and_entitlements.py | 219 + .../versions/d2_0_credit_limit_precision.py | 61 + ...add_10mb_check_constraints_on_document_.py | 60 + .../d551c03861f4_rename_quota_to_credits.py | 38 + ...5366d608_add_envelope_limits_to_tenants.py | 34 + ...baa8ef522c0_add_color_to_drive_comments.py | 38 + ..._device_constraints_and_session_updates.py | 28 + alembic/versions/e1_0_add_saas_plan_id.py | 32 + ...1a9d22ca5f_migrate_roles_access_to_uuid.py | 116 + ...57cb_add_cascade_deletes_to_drive_file_.py | 36 + ...6ffb_add_has_completed_tutorial_to_user.py | 34 + ...7834_add_ip_address_to_drive_activities.py | 30 + .../ff9052a97835_add_multi_tenancy.py | 104 + .../g001_add_signing_requests_table.py | 38 + .../g002_replace_docuseal_with_placements.py | 41 + .../versions/h003_add_tenant_smtp_settings.py | 76 + .../h004_add_signing_forensic_columns.py | 48 + .../versions/h004_limits_and_soft_delete.py | 108 + alembic/versions/i001_dual_bucket_storage.py | 56 + alembic/versions/j001_comments_fix.py | 31 + .../j002_allow_unlimited_tenant_quota.py | 38 + .../k001_limit_signature_id_to_15_chars.py | 42 + .../versions/k002_add_docs_tutorial_flag.py | 28 + alembic/versions/l001_merge_heads.py | 25 + .../versions/m001_add_activity_logs_table.py | 87 + alembic/versions/n001_add_user_designation.py | 29 + .../versions/n002_add_signature_imprints.py | 62 + alembic/versions/v3_extraction_data.py | 57 + .../w001_versioning_minor_and_version_fk.py | 137 + .../x001_add_user_smtp_credentials.py | 48 + alembic/versions/y001_add_user_mail_from.py | 39 + alembic/versions/z002_pref_lang.py | 45 + ..._add_disable_pdf_compression_to_tenants.py | 41 + alembic/versions/z004_add_global_workflows.py | 20 + ...dd_virus_scanning_and_quarantine_tables.py | 119 + app/ai_runner.py | 85 + app/ai_service/__init__.py | 0 app/ai_service/model_server.py | 31 + app/api/ws_router.py | 68 + app/assets/stylesheet/Stylesheet.deps.json | 23 + app/assets/stylesheet/Stylesheet.dll | Bin 0 -> 9216 bytes .../stylesheet/Stylesheet.runtimeconfig.json | 18 + app/assets/stylesheet/jats-html.xsl | 4447 ++++ app/core/cancellation.py | 13 + app/core/clamav.py | 118 + app/core/context.py | 4 + app/core/crypto.py | 35 + app/core/mail.py | 250 + app/core/model_manager.py | 70 + app/core/path_validation.py | 88 + app/core/progress.py | 17 + app/core/pubsub_listener.py | 41 + app/core/redis.py | 136 + app/core/request_body.py | 51 + app/core/request_cache.py | 69 + app/core/rls.py | 80 + app/core/sanitization.py | 78 + app/core/schemas.py | 55 + app/core/scope.py | 332 + app/core/security.py | 39 + app/core/settings.py | 268 + app/core/ssrf_protection.py | 79 + app/core/tenant_context.py | 114 + app/core/tenant_filter.py | 180 + app/core/tenant_metrics.py | 73 + app/core/token_blacklist.py | 74 + app/core/transactions.py | 88 + app/core/ws_auth.py | 93 + app/db/all_models.py | 95 + app/db/database.py | 100 + app/db/redis.py | 94 + .../realtime/chat_subscriber.py | 119 + .../realtime/connection_manager.py | 144 + app/infrastructure/vector/__init__.py | 1 + app/infrastructure/vector/vector_client.py | 361 + app/main.py | 449 + app/middleware/__init__.py | 3 + app/middleware/auth.py | 329 + app/middleware/csrf.py | 58 + app/middleware/ip_middleware.py | 16 + app/middleware/rbac.py | 54 + app/middleware/security_headers.py | 15 + app/middleware/tenant.py | 111 + app/middleware/tenant_context_middleware.py | 133 + app/modules/activity_logs/__init__.py | 3 + app/modules/activity_logs/constants.py | 67 + .../activity_logs/models/activity_log.py | 50 + app/modules/activity_logs/routes.py | 112 + app/modules/activity_logs/schemas.py | 227 + app/modules/activity_logs/service.py | 339 + .../auth/controllers/auth_controller.py | 66 + .../auth/controllers/role_controller.py | 78 + app/modules/auth/dependencies/__init__.py | 3 + .../auth/dependencies/access_dependency.py | 91 + app/modules/auth/middleware/hmac.py | 33 + app/modules/auth/models/access_model.py | 42 + app/modules/auth/models/device_model.py | 43 + app/modules/auth/models/role_access_model.py | 21 + app/modules/auth/models/role_model.py | 45 + app/modules/auth/models/saas_models.py | 44 + app/modules/auth/models/session_model.py | 35 + app/modules/auth/models/user_model.py | 226 + app/modules/auth/models/user_role_model.py | 160 + .../auth/repositories/device_repository.py | 45 + .../auth/repositories/session_repository.py | 72 + .../auth/repositories/user_repository.py | 43 + app/modules/auth/routes/access_routes.py | 76 + .../auth/routes/admin_dashboard_routes.py | 43 + app/modules/auth/routes/admin_user_routes.py | 655 + app/modules/auth/routes/auth_routes.py | 499 + app/modules/auth/routes/me_routes.py | 213 + app/modules/auth/routes/role_routes.py | 344 + app/modules/auth/routes/user_routes.py | 33 + app/modules/auth/schemas/access_schema.py | 84 + .../auth/schemas/admin_dashboard_schema.py | 57 + app/modules/auth/schemas/auth_schema.py | 182 + app/modules/auth/schemas/role_schema.py | 141 + .../auth/services/admin_dashboard_service.py | 557 + app/modules/auth/services/auth_service.py | 528 + .../auth/services/permission_service.py | 386 + app/modules/auth/services/privilege.py | 54 + app/modules/auth/services/role_service.py | 186 + .../auth/services/user_role_service.py | 389 + app/modules/auth/sso_router.py | 356 + app/modules/auth/utils/device_utils.py | 105 + app/modules/billing/__init__.py | 0 app/modules/billing/models/__init__.py | 0 app/modules/billing/models/plan_model.py | 285 + app/modules/billing/routes/__init__.py | 0 app/modules/billing/routes/billing_routes.py | 535 + app/modules/billing/schemas/__init__.py | 0 app/modules/billing/schemas/billing_schema.py | 146 + app/modules/billing/services/__init__.py | 0 app/modules/billing/services/billing_tasks.py | 62 + .../billing/services/entitlement_service.py | 235 + app/modules/chat/README.md | 41 + .../chat/controllers/chat_controller.py | 173 + app/modules/chat/engine/__init__.py | 0 app/modules/chat/engine/chatbot_engine.py | 692 + app/modules/chat/engine/formatter.py | 80 + app/modules/chat/engine/intent.py | 222 + app/modules/chat/engine/prompts.py | 120 + app/modules/chat/engine/reranker.py | 85 + app/modules/chat/engine/retrieval.py | 483 + app/modules/chat/engine/rewrite.py | 157 + app/modules/chat/models/chat_message_model.py | 60 + app/modules/chat/models/chat_model.py | 55 + app/modules/chat/models/chat_session_model.py | 75 + app/modules/chat/models/chat_usage_model.py | 47 + .../chat/repositories/chat_repository.py | 387 + app/modules/chat/routes/chat_routes.py | 108 + app/modules/chat/schemas/__init__.py | 0 app/modules/chat/schemas/chat_schema.py | 65 + app/modules/chat/services/chat_service.py | 594 + .../chat/services/redis_memory_service.py | 103 + app/modules/collab/models/collab_model.py | 37 + .../collab/repositories/chat_repository.py | 81 + app/modules/collab/routes/chat_routes.py | 142 + app/modules/collab/schemas/collab_schema.py | 45 + app/modules/collab/services/chat_service.py | 100 + .../models/system_configuration_model.py | 34 + .../routes/system_configuration_routes.py | 93 + .../schemas/system_configuration_schema.py | 41 + .../services/system_configuration_service.py | 225 + .../controllers/document_controller.py | 96 + .../controllers/export_controller.py | 208 + .../controllers/processing_controller.py | 618 + .../documents/models/document_model.py | 118 + .../processors/pdf/image_extractor.py | 219 + .../documents/processors/pdf/pdf_converter.py | 2344 ++ .../processors/pdf/structure_extractor.py | 592 + .../documents/processors/xml/jats_rules.py | 553 + .../documents/processors/xml/xml_compiler.py | 279 + .../repositories/document_repository.py | 81 + .../repositories/version_repository.py | 55 + .../documents/routes/comment_routes.py | 43 + .../documents/routes/details_routes.py | 29 + .../documents/routes/document_routes.py | 69 + app/modules/documents/routes/export_routes.py | 104 + .../documents/routes/sharing_routes.py | 56 + .../documents/routes/version_routes.py | 71 + .../documents/schemas/comment_schema.py | 26 + .../documents/schemas/document_schema.py | 118 + .../documents/schemas/export_schema.py | 28 + .../documents/schemas/processing_schema.py | 32 + .../documents/schemas/sharing_schema.py | 43 + app/modules/documents/schemas/tts_schema.py | 13 + .../documents/schemas/version_schema.py | 28 + .../documents/services/base_service.py | 48 + .../documents/services/document_service.py | 261 + .../documents/services/sharing_service.py | 266 + .../documents/services/version_service.py | 221 + app/modules/documents/tasks/celery_tasks.py | 373 + app/modules/drive/constants.py | 57 + .../drive/controllers/activity_controller.py | 16 + .../drive/controllers/comment_controller.py | 46 + .../drive/controllers/drive_controller.py | 449 + app/modules/drive/models/drive_model.py | 195 + .../drive/permissions/permission_service.py | 164 + .../drive/repositories/drive_repository.py | 822 + app/modules/drive/routes/drive_routes.py | 515 + app/modules/drive/routes/scan_routes.py | 85 + app/modules/drive/schemas/drive_schema.py | 234 + .../drive/services/activity_service.py | 234 + app/modules/drive/services/comment_service.py | 139 + app/modules/drive/services/drive_service.py | 721 + app/modules/drive/services/file_service.py | 1047 + app/modules/drive/services/folder_service.py | 229 + app/modules/drive/services/share_service.py | 695 + app/modules/drive/services/trash_service.py | 437 + app/modules/drive/services/version_service.py | 118 + app/modules/drive/tasks/upload_tasks.py | 209 + app/modules/drive/utils/breadcrumbs.py | 25 + app/modules/drive/utils/compress.py | 134 + app/modules/drive/utils/filename.py | 38 + app/modules/drive/utils/lossy_compress.py | 201 + app/modules/drive/utils/mime.py | 77 + app/modules/drive/utils/quota.py | 44 + app/modules/drive/utils/signature.py | 154 + app/modules/drive/workers/activity_worker.py | 34 + app/modules/drive/workers/delete_worker.py | 28 + .../drive/workers/notification_worker.py | 23 + app/modules/editor/converters.py | 120 + app/modules/editor/pdf_controller.py | 1916 ++ app/modules/editor/pdf_geometry.py | 211 + app/modules/editor/pdf_router.py | 385 + app/modules/editor/utils.py | 8 + app/modules/extraction/__init__.py | 0 app/modules/extraction/models/__init__.py | 0 .../extraction/models/extraction_model.py | 38 + app/modules/extraction/routes/__init__.py | 0 .../extraction/routes/extraction_routes.py | 67 + app/modules/extraction/schemas/__init__.py | 0 .../extraction/schemas/extraction_schema.py | 34 + app/modules/extraction/services/__init__.py | 0 .../extraction/services/docllm_service.py | 176 + .../extraction/services/extraction_service.py | 155 + app/modules/extraction/tasks/__init__.py | 0 .../extraction/tasks/extraction_tasks.py | 45 + .../models/notification_model.py | 33 + .../repositories/notification_repository.py | 112 + .../routes/notification_routes.py | 66 + app/modules/notifications/schemas/__init__.py | 0 .../schemas/notification_schema.py | 33 + .../services/notification_service.py | 191 + app/modules/org/__init__.py | 0 app/modules/org/models/__init__.py | 0 app/modules/org/models/group_model.py | 127 + app/modules/org/models/org_model.py | 240 + app/modules/org/routes/__init__.py | 0 app/modules/org/routes/org_routes.py | 514 + app/modules/org/schemas/__init__.py | 0 app/modules/org/schemas/org_schema.py | 193 + app/modules/org/services/__init__.py | 0 app/modules/org/services/access_audit.py | 354 + .../org/services/access_maintenance.py | 139 + app/modules/org/services/conflicts.py | 57 + app/modules/org/services/grant_service.py | 211 + .../org/services/membership_service.py | 232 + app/modules/org/services/org_service.py | 314 + app/modules/org/tasks.py | 68 + .../controllers/virus_scan_controller.py | 77 + .../security/models/virus_scan_models.py | 175 + .../repositories/virus_scan_repository.py | 210 + .../security/routes/security_routes.py | 118 + .../security/schemas/virus_scan_schemas.py | 75 + .../security/services/virus_scan_service.py | 297 + .../signing/models/signature_config_model.py | 78 + .../signing/models/signature_imprint.py | 47 + app/modules/signing/models/signing_request.py | 53 + app/modules/signing/routes/config_routes.py | 171 + app/modules/signing/routes/signing_routes.py | 467 + app/modules/signing/schemas/config_schema.py | 28 + .../signing/schemas/signing_schemas.py | 106 + .../signing/services/base_signing_provider.py | 38 + .../signing/services/docuseal_service.py | 137 + .../signing/services/provider_registry.py | 29 + .../services/signature_config_service.py | 191 + .../signing/services/signing_service.py | 2023 ++ .../signing/services/verification_service.py | 476 + .../signing/services/zoho_sign_service.py | 359 + app/modules/speech/routes/speech_routes.py | 82 + .../controllers/tenant_contact_controller.py | 45 + .../tenant/controllers/tenant_controller.py | 89 + .../controllers/tenant_smtp_controller.py | 77 + .../controllers/tenant_storage_controller.py | 94 + .../tenant/models/tenant_contact_model.py | 20 + app/modules/tenant/models/tenant_model.py | 72 + .../tenant/models/tenant_smtp_config_model.py | 49 + .../models/tenant_storage_config_model.py | 50 + .../repositories/tenant_contact_repository.py | 44 + .../tenant/repositories/tenant_repository.py | 60 + .../repositories/tenant_smtp_repository.py | 32 + .../repositories/tenant_storage_repository.py | 32 + .../tenant/routes/tenant_contact_routes.py | 130 + app/modules/tenant/routes/tenant_routes.py | 376 + .../tenant/schemas/tenant_contact_schema.py | 29 + app/modules/tenant/schemas/tenant_schema.py | 103 + .../tenant/schemas/tenant_smtp_schema.py | 35 + .../tenant/schemas/tenant_storage_schema.py | 34 + .../tenant/services/tenant_contact_service.py | 59 + app/modules/tenant/services/tenant_service.py | 124 + .../tenant/services/tenant_smtp_service.py | 126 + .../tenant/services/tenant_storage_service.py | 297 + app/services/saas_service.py | 469 + app/tasks/celery_app.py | 83 + app/tasks/routes.py | 250 + app/templates/emails/document_shared.html | 54 + app/templates/emails/email_resource.html | 34 + app/templates/emails/storage_alert_100.html | 24 + app/templates/emails/storage_alert_50.html | 24 + app/templates/emails/storage_alert_75.html | 24 + app/templates/emails/storage_alert_90.html | 24 + app/templates/emails/welcome.html | 558 + docker-compose.development.yml | 148 + docker-compose.production.yml | 148 + docker-compose.test.yml | 148 + manage.py | 116 + openapi.json | 21311 ++++++++++++++++ pyproject.toml | 85 + pytest.ini | 14 + scripts/b1_preflight.py | 251 + scripts/backfill_signature_imprints.py | 261 + scripts/bench_subtree.py | 166 + scripts/check_memory_leaks.py | 72 + scripts/clean_redundant.py | 36 + scripts/create_app_role.py | 112 + scripts/delete_project_accesses.py | 45 + scripts/endpoint_inventory.py | 182 + scripts/export_openapi.py | 70 + scripts/flush_redis.py | 64 + scripts/loadtest.py | 160 + scripts/multi_role_preflight.py | 157 + scripts/plan_review.py | 135 + scripts/seed.py | 322 + scripts/seed_smoke.py | 122 + scripts/smoke_billing.py | 138 + scripts/smoke_org.py | 250 + scripts/strip_comments.py | 112 + uv.lock | 2968 +++ 418 files changed, 86016 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.development create mode 100644 .env.localdev create mode 100644 .env.production create mode 100644 .env.test create mode 100644 .env.uat create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/06001599321d_add_collaboration_chat.py create mode 100644 alembic/versions/0bffea34ee5f_disable_rls_temporarily.py create mode 100644 alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py create mode 100644 alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py create mode 100644 alembic/versions/15f935213b22_add_tenant_storage_settings.py create mode 100644 alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py create mode 100644 alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py create mode 100644 alembic/versions/229359b0d1b2_add_terms_accepted.py create mode 100644 alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py create mode 100644 alembic/versions/29c02cb50737_initial_migration.py create mode 100644 alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py create mode 100644 alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py create mode 100644 alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py create mode 100644 alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py create mode 100644 alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py create mode 100644 alembic/versions/49c19df2c1d4_sync_models.py create mode 100644 alembic/versions/4a9dc50ce4af_drop_price_display.py create mode 100644 alembic/versions/522ee6d91431_add_drive_share_link_fields.py create mode 100644 alembic/versions/540c970ff890_add_tenant_chat_token_quota.py create mode 100644 alembic/versions/56d090047e85_increase_file_size_constraints.py create mode 100644 alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py create mode 100644 alembic/versions/6c9cc3567866_merge_multiple_heads.py create mode 100644 alembic/versions/73dff81eed72_add_tenant_contacts.py create mode 100644 alembic/versions/765b053e3dcd_add_vector_index_checksum.py create mode 100644 alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py create mode 100644 alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py create mode 100644 alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py create mode 100644 alembic/versions/823a486fd287_add_created_at_to_drive_stars.py create mode 100644 alembic/versions/8736ab6bd5af_add_notifications_table.py create mode 100644 alembic/versions/88717d544229_add_is_default_to_roles.py create mode 100644 alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py create mode 100644 alembic/versions/8d4dced54966_add_is_root_column.py create mode 100644 alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py create mode 100644 alembic/versions/9557c7307575_tenant_storage_config_and_provider.py create mode 100644 alembic/versions/9a6db38b86a6_make_drivecomment_generic.py create mode 100644 alembic/versions/9af99fc7ca0f_merge_heads.py create mode 100644 alembic/versions/9c49fe120ffb_add_device_and_session_tables.py create mode 100644 alembic/versions/9c4a95ac6726_add_saasrolemapping.py create mode 100644 alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py create mode 100644 alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py create mode 100644 alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py create mode 100644 alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py create mode 100644 alembic/versions/af57c3d9f1c2_add_signature_config_tables.py create mode 100644 alembic/versions/b029d2f5a0c7_add_signature_id.py create mode 100644 alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py create mode 100644 alembic/versions/b1_0_explicit_superadmin.py create mode 100644 alembic/versions/b1_3_row_level_security.py create mode 100644 alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py create mode 100644 alembic/versions/b6_4_index_review.py create mode 100644 alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py create mode 100644 alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py create mode 100644 alembic/versions/c1_0_org_units_and_scoped_roles.py create mode 100644 alembic/versions/c1_1_user_roles_tenant_id.py create mode 100644 alembic/versions/c3_0_org_unit_path.py create mode 100644 alembic/versions/c3abf44266de_enforce_b2_storage_provider.py create mode 100644 alembic/versions/c4_0_grant_expiry.py create mode 100644 alembic/versions/c4_1_access_log_retention.py create mode 100644 alembic/versions/c5_0_access_groups.py create mode 100644 alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py create mode 100644 alembic/versions/ca14c4ab10d9_merge_multiple_heads.py create mode 100644 alembic/versions/cba70ab35793_create_chat_token_usage_table.py create mode 100644 alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py create mode 100644 alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py create mode 100644 alembic/versions/d1_0_plans_and_entitlements.py create mode 100644 alembic/versions/d2_0_credit_limit_precision.py create mode 100644 alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py create mode 100644 alembic/versions/d551c03861f4_rename_quota_to_credits.py create mode 100644 alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py create mode 100644 alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py create mode 100644 alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py create mode 100644 alembic/versions/e1_0_add_saas_plan_id.py create mode 100644 alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py create mode 100644 alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py create mode 100644 alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py create mode 100644 alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py create mode 100644 alembic/versions/ff9052a97835_add_multi_tenancy.py create mode 100644 alembic/versions/g001_add_signing_requests_table.py create mode 100644 alembic/versions/g002_replace_docuseal_with_placements.py create mode 100644 alembic/versions/h003_add_tenant_smtp_settings.py create mode 100644 alembic/versions/h004_add_signing_forensic_columns.py create mode 100644 alembic/versions/h004_limits_and_soft_delete.py create mode 100644 alembic/versions/i001_dual_bucket_storage.py create mode 100644 alembic/versions/j001_comments_fix.py create mode 100644 alembic/versions/j002_allow_unlimited_tenant_quota.py create mode 100644 alembic/versions/k001_limit_signature_id_to_15_chars.py create mode 100644 alembic/versions/k002_add_docs_tutorial_flag.py create mode 100644 alembic/versions/l001_merge_heads.py create mode 100644 alembic/versions/m001_add_activity_logs_table.py create mode 100644 alembic/versions/n001_add_user_designation.py create mode 100644 alembic/versions/n002_add_signature_imprints.py create mode 100644 alembic/versions/v3_extraction_data.py create mode 100644 alembic/versions/w001_versioning_minor_and_version_fk.py create mode 100644 alembic/versions/x001_add_user_smtp_credentials.py create mode 100644 alembic/versions/y001_add_user_mail_from.py create mode 100644 alembic/versions/z002_pref_lang.py create mode 100644 alembic/versions/z003_add_disable_pdf_compression_to_tenants.py create mode 100644 alembic/versions/z004_add_global_workflows.py create mode 100644 alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py create mode 100644 app/ai_runner.py create mode 100644 app/ai_service/__init__.py create mode 100644 app/ai_service/model_server.py create mode 100644 app/api/ws_router.py create mode 100644 app/assets/stylesheet/Stylesheet.deps.json create mode 100644 app/assets/stylesheet/Stylesheet.dll create mode 100644 app/assets/stylesheet/Stylesheet.runtimeconfig.json create mode 100644 app/assets/stylesheet/jats-html.xsl create mode 100644 app/core/cancellation.py create mode 100644 app/core/clamav.py create mode 100644 app/core/context.py create mode 100644 app/core/crypto.py create mode 100644 app/core/mail.py create mode 100644 app/core/model_manager.py create mode 100644 app/core/path_validation.py create mode 100644 app/core/progress.py create mode 100644 app/core/pubsub_listener.py create mode 100644 app/core/redis.py create mode 100644 app/core/request_body.py create mode 100644 app/core/request_cache.py create mode 100644 app/core/rls.py create mode 100644 app/core/sanitization.py create mode 100644 app/core/schemas.py create mode 100644 app/core/scope.py create mode 100644 app/core/security.py create mode 100644 app/core/settings.py create mode 100644 app/core/ssrf_protection.py create mode 100644 app/core/tenant_context.py create mode 100644 app/core/tenant_filter.py create mode 100644 app/core/tenant_metrics.py create mode 100644 app/core/token_blacklist.py create mode 100644 app/core/transactions.py create mode 100644 app/core/ws_auth.py create mode 100644 app/db/all_models.py create mode 100644 app/db/database.py create mode 100644 app/db/redis.py create mode 100644 app/infrastructure/realtime/chat_subscriber.py create mode 100644 app/infrastructure/realtime/connection_manager.py create mode 100644 app/infrastructure/vector/__init__.py create mode 100644 app/infrastructure/vector/vector_client.py create mode 100644 app/main.py create mode 100644 app/middleware/__init__.py create mode 100644 app/middleware/auth.py create mode 100644 app/middleware/csrf.py create mode 100644 app/middleware/ip_middleware.py create mode 100644 app/middleware/rbac.py create mode 100644 app/middleware/security_headers.py create mode 100644 app/middleware/tenant.py create mode 100644 app/middleware/tenant_context_middleware.py create mode 100644 app/modules/activity_logs/__init__.py create mode 100644 app/modules/activity_logs/constants.py create mode 100644 app/modules/activity_logs/models/activity_log.py create mode 100644 app/modules/activity_logs/routes.py create mode 100644 app/modules/activity_logs/schemas.py create mode 100644 app/modules/activity_logs/service.py create mode 100644 app/modules/auth/controllers/auth_controller.py create mode 100644 app/modules/auth/controllers/role_controller.py create mode 100644 app/modules/auth/dependencies/__init__.py create mode 100644 app/modules/auth/dependencies/access_dependency.py create mode 100644 app/modules/auth/middleware/hmac.py create mode 100644 app/modules/auth/models/access_model.py create mode 100644 app/modules/auth/models/device_model.py create mode 100644 app/modules/auth/models/role_access_model.py create mode 100644 app/modules/auth/models/role_model.py create mode 100644 app/modules/auth/models/saas_models.py create mode 100644 app/modules/auth/models/session_model.py create mode 100644 app/modules/auth/models/user_model.py create mode 100644 app/modules/auth/models/user_role_model.py create mode 100644 app/modules/auth/repositories/device_repository.py create mode 100644 app/modules/auth/repositories/session_repository.py create mode 100644 app/modules/auth/repositories/user_repository.py create mode 100644 app/modules/auth/routes/access_routes.py create mode 100644 app/modules/auth/routes/admin_dashboard_routes.py create mode 100644 app/modules/auth/routes/admin_user_routes.py create mode 100644 app/modules/auth/routes/auth_routes.py create mode 100644 app/modules/auth/routes/me_routes.py create mode 100644 app/modules/auth/routes/role_routes.py create mode 100644 app/modules/auth/routes/user_routes.py create mode 100644 app/modules/auth/schemas/access_schema.py create mode 100644 app/modules/auth/schemas/admin_dashboard_schema.py create mode 100644 app/modules/auth/schemas/auth_schema.py create mode 100644 app/modules/auth/schemas/role_schema.py create mode 100644 app/modules/auth/services/admin_dashboard_service.py create mode 100644 app/modules/auth/services/auth_service.py create mode 100644 app/modules/auth/services/permission_service.py create mode 100644 app/modules/auth/services/privilege.py create mode 100644 app/modules/auth/services/role_service.py create mode 100644 app/modules/auth/services/user_role_service.py create mode 100644 app/modules/auth/sso_router.py create mode 100644 app/modules/auth/utils/device_utils.py create mode 100644 app/modules/billing/__init__.py create mode 100644 app/modules/billing/models/__init__.py create mode 100644 app/modules/billing/models/plan_model.py create mode 100644 app/modules/billing/routes/__init__.py create mode 100644 app/modules/billing/routes/billing_routes.py create mode 100644 app/modules/billing/schemas/__init__.py create mode 100644 app/modules/billing/schemas/billing_schema.py create mode 100644 app/modules/billing/services/__init__.py create mode 100644 app/modules/billing/services/billing_tasks.py create mode 100644 app/modules/billing/services/entitlement_service.py create mode 100644 app/modules/chat/README.md create mode 100644 app/modules/chat/controllers/chat_controller.py create mode 100644 app/modules/chat/engine/__init__.py create mode 100644 app/modules/chat/engine/chatbot_engine.py create mode 100644 app/modules/chat/engine/formatter.py create mode 100644 app/modules/chat/engine/intent.py create mode 100644 app/modules/chat/engine/prompts.py create mode 100644 app/modules/chat/engine/reranker.py create mode 100644 app/modules/chat/engine/retrieval.py create mode 100644 app/modules/chat/engine/rewrite.py create mode 100644 app/modules/chat/models/chat_message_model.py create mode 100644 app/modules/chat/models/chat_model.py create mode 100644 app/modules/chat/models/chat_session_model.py create mode 100644 app/modules/chat/models/chat_usage_model.py create mode 100644 app/modules/chat/repositories/chat_repository.py create mode 100644 app/modules/chat/routes/chat_routes.py create mode 100644 app/modules/chat/schemas/__init__.py create mode 100644 app/modules/chat/schemas/chat_schema.py create mode 100644 app/modules/chat/services/chat_service.py create mode 100644 app/modules/chat/services/redis_memory_service.py create mode 100644 app/modules/collab/models/collab_model.py create mode 100644 app/modules/collab/repositories/chat_repository.py create mode 100644 app/modules/collab/routes/chat_routes.py create mode 100644 app/modules/collab/schemas/collab_schema.py create mode 100644 app/modules/collab/services/chat_service.py create mode 100644 app/modules/configuration/models/system_configuration_model.py create mode 100644 app/modules/configuration/routes/system_configuration_routes.py create mode 100644 app/modules/configuration/schemas/system_configuration_schema.py create mode 100644 app/modules/configuration/services/system_configuration_service.py create mode 100644 app/modules/documents/controllers/document_controller.py create mode 100644 app/modules/documents/controllers/export_controller.py create mode 100644 app/modules/documents/controllers/processing_controller.py create mode 100644 app/modules/documents/models/document_model.py create mode 100644 app/modules/documents/processors/pdf/image_extractor.py create mode 100644 app/modules/documents/processors/pdf/pdf_converter.py create mode 100644 app/modules/documents/processors/pdf/structure_extractor.py create mode 100644 app/modules/documents/processors/xml/jats_rules.py create mode 100644 app/modules/documents/processors/xml/xml_compiler.py create mode 100644 app/modules/documents/repositories/document_repository.py create mode 100644 app/modules/documents/repositories/version_repository.py create mode 100644 app/modules/documents/routes/comment_routes.py create mode 100644 app/modules/documents/routes/details_routes.py create mode 100644 app/modules/documents/routes/document_routes.py create mode 100644 app/modules/documents/routes/export_routes.py create mode 100644 app/modules/documents/routes/sharing_routes.py create mode 100644 app/modules/documents/routes/version_routes.py create mode 100644 app/modules/documents/schemas/comment_schema.py create mode 100644 app/modules/documents/schemas/document_schema.py create mode 100644 app/modules/documents/schemas/export_schema.py create mode 100644 app/modules/documents/schemas/processing_schema.py create mode 100644 app/modules/documents/schemas/sharing_schema.py create mode 100644 app/modules/documents/schemas/tts_schema.py create mode 100644 app/modules/documents/schemas/version_schema.py create mode 100644 app/modules/documents/services/base_service.py create mode 100644 app/modules/documents/services/document_service.py create mode 100644 app/modules/documents/services/sharing_service.py create mode 100644 app/modules/documents/services/version_service.py create mode 100644 app/modules/documents/tasks/celery_tasks.py create mode 100644 app/modules/drive/constants.py create mode 100644 app/modules/drive/controllers/activity_controller.py create mode 100644 app/modules/drive/controllers/comment_controller.py create mode 100644 app/modules/drive/controllers/drive_controller.py create mode 100644 app/modules/drive/models/drive_model.py create mode 100644 app/modules/drive/permissions/permission_service.py create mode 100644 app/modules/drive/repositories/drive_repository.py create mode 100644 app/modules/drive/routes/drive_routes.py create mode 100644 app/modules/drive/routes/scan_routes.py create mode 100644 app/modules/drive/schemas/drive_schema.py create mode 100644 app/modules/drive/services/activity_service.py create mode 100644 app/modules/drive/services/comment_service.py create mode 100644 app/modules/drive/services/drive_service.py create mode 100644 app/modules/drive/services/file_service.py create mode 100644 app/modules/drive/services/folder_service.py create mode 100644 app/modules/drive/services/share_service.py create mode 100644 app/modules/drive/services/trash_service.py create mode 100644 app/modules/drive/services/version_service.py create mode 100644 app/modules/drive/tasks/upload_tasks.py create mode 100644 app/modules/drive/utils/breadcrumbs.py create mode 100644 app/modules/drive/utils/compress.py create mode 100644 app/modules/drive/utils/filename.py create mode 100644 app/modules/drive/utils/lossy_compress.py create mode 100644 app/modules/drive/utils/mime.py create mode 100644 app/modules/drive/utils/quota.py create mode 100644 app/modules/drive/utils/signature.py create mode 100644 app/modules/drive/workers/activity_worker.py create mode 100644 app/modules/drive/workers/delete_worker.py create mode 100644 app/modules/drive/workers/notification_worker.py create mode 100644 app/modules/editor/converters.py create mode 100644 app/modules/editor/pdf_controller.py create mode 100644 app/modules/editor/pdf_geometry.py create mode 100644 app/modules/editor/pdf_router.py create mode 100644 app/modules/editor/utils.py create mode 100644 app/modules/extraction/__init__.py create mode 100644 app/modules/extraction/models/__init__.py create mode 100644 app/modules/extraction/models/extraction_model.py create mode 100644 app/modules/extraction/routes/__init__.py create mode 100644 app/modules/extraction/routes/extraction_routes.py create mode 100644 app/modules/extraction/schemas/__init__.py create mode 100644 app/modules/extraction/schemas/extraction_schema.py create mode 100644 app/modules/extraction/services/__init__.py create mode 100644 app/modules/extraction/services/docllm_service.py create mode 100644 app/modules/extraction/services/extraction_service.py create mode 100644 app/modules/extraction/tasks/__init__.py create mode 100644 app/modules/extraction/tasks/extraction_tasks.py create mode 100644 app/modules/notifications/models/notification_model.py create mode 100644 app/modules/notifications/repositories/notification_repository.py create mode 100644 app/modules/notifications/routes/notification_routes.py create mode 100644 app/modules/notifications/schemas/__init__.py create mode 100644 app/modules/notifications/schemas/notification_schema.py create mode 100644 app/modules/notifications/services/notification_service.py create mode 100644 app/modules/org/__init__.py create mode 100644 app/modules/org/models/__init__.py create mode 100644 app/modules/org/models/group_model.py create mode 100644 app/modules/org/models/org_model.py create mode 100644 app/modules/org/routes/__init__.py create mode 100644 app/modules/org/routes/org_routes.py create mode 100644 app/modules/org/schemas/__init__.py create mode 100644 app/modules/org/schemas/org_schema.py create mode 100644 app/modules/org/services/__init__.py create mode 100644 app/modules/org/services/access_audit.py create mode 100644 app/modules/org/services/access_maintenance.py create mode 100644 app/modules/org/services/conflicts.py create mode 100644 app/modules/org/services/grant_service.py create mode 100644 app/modules/org/services/membership_service.py create mode 100644 app/modules/org/services/org_service.py create mode 100644 app/modules/org/tasks.py create mode 100644 app/modules/security/controllers/virus_scan_controller.py create mode 100644 app/modules/security/models/virus_scan_models.py create mode 100644 app/modules/security/repositories/virus_scan_repository.py create mode 100644 app/modules/security/routes/security_routes.py create mode 100644 app/modules/security/schemas/virus_scan_schemas.py create mode 100644 app/modules/security/services/virus_scan_service.py create mode 100644 app/modules/signing/models/signature_config_model.py create mode 100644 app/modules/signing/models/signature_imprint.py create mode 100644 app/modules/signing/models/signing_request.py create mode 100644 app/modules/signing/routes/config_routes.py create mode 100644 app/modules/signing/routes/signing_routes.py create mode 100644 app/modules/signing/schemas/config_schema.py create mode 100644 app/modules/signing/schemas/signing_schemas.py create mode 100644 app/modules/signing/services/base_signing_provider.py create mode 100644 app/modules/signing/services/docuseal_service.py create mode 100644 app/modules/signing/services/provider_registry.py create mode 100644 app/modules/signing/services/signature_config_service.py create mode 100644 app/modules/signing/services/signing_service.py create mode 100644 app/modules/signing/services/verification_service.py create mode 100644 app/modules/signing/services/zoho_sign_service.py create mode 100644 app/modules/speech/routes/speech_routes.py create mode 100644 app/modules/tenant/controllers/tenant_contact_controller.py create mode 100644 app/modules/tenant/controllers/tenant_controller.py create mode 100644 app/modules/tenant/controllers/tenant_smtp_controller.py create mode 100644 app/modules/tenant/controllers/tenant_storage_controller.py create mode 100644 app/modules/tenant/models/tenant_contact_model.py create mode 100644 app/modules/tenant/models/tenant_model.py create mode 100644 app/modules/tenant/models/tenant_smtp_config_model.py create mode 100644 app/modules/tenant/models/tenant_storage_config_model.py create mode 100644 app/modules/tenant/repositories/tenant_contact_repository.py create mode 100644 app/modules/tenant/repositories/tenant_repository.py create mode 100644 app/modules/tenant/repositories/tenant_smtp_repository.py create mode 100644 app/modules/tenant/repositories/tenant_storage_repository.py create mode 100644 app/modules/tenant/routes/tenant_contact_routes.py create mode 100644 app/modules/tenant/routes/tenant_routes.py create mode 100644 app/modules/tenant/schemas/tenant_contact_schema.py create mode 100644 app/modules/tenant/schemas/tenant_schema.py create mode 100644 app/modules/tenant/schemas/tenant_smtp_schema.py create mode 100644 app/modules/tenant/schemas/tenant_storage_schema.py create mode 100644 app/modules/tenant/services/tenant_contact_service.py create mode 100644 app/modules/tenant/services/tenant_service.py create mode 100644 app/modules/tenant/services/tenant_smtp_service.py create mode 100644 app/modules/tenant/services/tenant_storage_service.py create mode 100644 app/services/saas_service.py create mode 100644 app/tasks/celery_app.py create mode 100644 app/tasks/routes.py create mode 100644 app/templates/emails/document_shared.html create mode 100644 app/templates/emails/email_resource.html create mode 100644 app/templates/emails/storage_alert_100.html create mode 100644 app/templates/emails/storage_alert_50.html create mode 100644 app/templates/emails/storage_alert_75.html create mode 100644 app/templates/emails/storage_alert_90.html create mode 100644 app/templates/emails/welcome.html create mode 100644 docker-compose.development.yml create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.test.yml create mode 100644 manage.py create mode 100644 openapi.json create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 scripts/b1_preflight.py create mode 100644 scripts/backfill_signature_imprints.py create mode 100644 scripts/bench_subtree.py create mode 100644 scripts/check_memory_leaks.py create mode 100644 scripts/clean_redundant.py create mode 100644 scripts/create_app_role.py create mode 100644 scripts/delete_project_accesses.py create mode 100644 scripts/endpoint_inventory.py create mode 100644 scripts/export_openapi.py create mode 100644 scripts/flush_redis.py create mode 100644 scripts/loadtest.py create mode 100644 scripts/multi_role_preflight.py create mode 100644 scripts/plan_review.py create mode 100644 scripts/seed.py create mode 100644 scripts/seed_smoke.py create mode 100644 scripts/smoke_billing.py create mode 100644 scripts/smoke_org.py create mode 100644 scripts/strip_comments.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a11324 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +.git +.gitignore +.gitattributes +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +*$py.class +*.so +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ +.pytype/ +.pyre/ +*.egg-info/ +dist/ +build/ +.eggs/ +.venv/ +venv/ +env/ +ENV/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +storage_drive/ +storage/ +uploads/ +downloads/ +temp/ +tmp/ +Dockerfile.* +docker-compose*.yml +.docker/ +*.log +logs/ +.pytest_cache/ +htmlcov/ +coverage.xml +redis_data/ +qdrant_data/ +clamav_data/ +*.md +requirements*.txt +openapi.json +.pre-commit-config.yaml diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..da7bce5 --- /dev/null +++ b/.env.development @@ -0,0 +1,68 @@ +APP_ENV=development +HOST=0.0.0.0 +PORT=20001 +DEBUG=False +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_development +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 +SAAS_TRUST_SECRET=docqube_trust_secret_2026 +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" +APP_SECRET=Pk7rcnYeSE-9vgTuevzfvKJ5YAs6Bj0QjgFxyJW00oKMnmPlRxUfU8lAm33GAgxL +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=JVCzB612F573hYwqZZ +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=https://docqube.maskantech.in +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 +DEEPSEEK_API_KEY=sk-95235bd6d3b440d6b6e25bd722813f3c +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +LLM_TEMPERATURE=0.35 +MAX_RESPONSE_TOKENS=1500 +HF_HUB_OFFLINE=0 +TRANSFORMERS_OFFLINE=0 +VECTOR_DB=qdrant +QDRANT_HOST=qdrant +QDRANT_PORT=6333 +QDRANT_COLLECTION=docqube_collection +RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2 +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +CHUNK_SIZE=850 +CHUNK_OVERLAP=180 +TOP_K_QA=3 +TOP_K_SUMMARY=4 +MAX_CONTEXT_CHARS=4000 +MIN_SIMILARITY_SCORE=0.18 +LOG_LEVEL=WARNING +CORS_ORIGINS=https://docqube.maskantech.in +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=info.maskantech@gmail.com +SMTP_PASSWORD=tuthpljtkrwchgxd +MAIL_FROM=info.maskantech@gmail.com +SMTP_SECURE=True +FRONTEND_URL=https://docqube.maskantech.in +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 +DOCUSEAL_API_URL=https://api.docuseal.com +DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com +ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" +MAX_ACTIVE_DEVICES=5 \ No newline at end of file diff --git a/.env.localdev b/.env.localdev new file mode 100644 index 0000000..38ff103 --- /dev/null +++ b/.env.localdev @@ -0,0 +1,132 @@ +# Environment Configuration +APP_ENV=localdev + +# Server Configuration +HOST=127.0.0.1 +PORT=20001 +DEBUG=True + +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=new-base +# DB_USER=postgres +# DB_PASSWORD=ribai123 +# DB_SSLMODE=disable +# PGBOUNCER_PORT=5432 +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_local +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 + +# Security Configuration +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Redis Configuration +REDIS_HOST=106.51.105.22 +REDIS_PORT=6388 +REDIS_PASSWORD=JVCzB612F573hYwqZZ + +CLAMAV_HOST=106.51.105.22 +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=http://localhost:20001 + + +#Saas +SAAS_TRUST_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" + +# Google Auth Configuration +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +# AI & Models Configuration +# ========================= +# AI (DeepSeek) +# ========================= +DEEPSEEK_API_KEY=sk-95235bd6d3b440d6b6e25bd722813f3c +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +LLM_TEMPERATURE=0.35 +MAX_RESPONSE_TOKENS=1500 + +# Extraction / NVIDIA AI Configuration +EXTRACTION_API_KEY="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" +EXTRACTION_BASE_URL=https://integrate.api.nvidia.com/v1 +EXTRACTION_MODEL=mistralai/mistral-medium-3-instruct +EXTRACTION_VISION_MODEL=meta/llama-3.2-90b-vision-instruct + + +# Force Offline Mode for local models (Prevents startup delays/HF checks) +HF_HUB_OFFLINE=0 +TRANSFORMERS_OFFLINE=0 + +# Vector DB Configuration +VECTOR_DB=qdrant +QDRANT_HOST=localhost +QDRANT_PORT=6333 +QDRANT_COLLECTION=docqube_collection +RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2 + +# ========================= +# EMBEDDING +# ========================= +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +# RAG SETTINGS +# ========================= +CHUNK_SIZE=850 +CHUNK_OVERLAP=180 +TOP_K_QA=3 +TOP_K_SUMMARY=4 +MAX_CONTEXT_CHARS=4000 +MIN_SIMILARITY_SCORE=0.18 + +# Logging Configuration +LOG_LEVEL=INFO + +# CORS Configuration +CORS_ORIGINS=* + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=info.maskantech@gmail.com +SMTP_PASSWORD=ftqjghdbthlwpnsh +MAIL_FROM=info.maskantech@gmail.com +SMTP_SECURE=True + + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + +# whisper Model Configuration +WHISPER_MODEL=small + + +# Chat Redis Settings +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 + +# Docuseal API Settings +# DOCUSEAL_API_URL=https://api.docuseal.com +# DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +# DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com + +#ZOHO API Settings +# ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +# ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +# ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" +MAX_ACTIVE_DEVICES=10 + + diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..c78fa98 --- /dev/null +++ b/.env.production @@ -0,0 +1,97 @@ +# Environment Configuration +APP_ENV=production + +# Server Configuration +HOST=127.0.0.1 +PORT=20001 +DEBUG=True + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=prod_docqube +DB_USER=prod_user +DB_PASSWORD='GO5cA8hK3@47Uq9im44' +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 + + +#saas +SAAS_TRUST_SECRET=docqube_trust_secret_2026 +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" + +# Security Configuration +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Redis Configuration +REDIS_HOST=31.57.228.186 +REDIS_PORT=6379 +REDIS_PASSWORD=TP0TmTqyWzBIuvrE + +CLAMAV_HOST=31.57.228.186 +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=https://api.docqube.com + +# Google Auth Configuration +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + + +# ========================= +# AI (DeepSeek) +# ========================= +DEEPSEEK_API_KEY=sk-f51cbc9eca6b466d949bfdd60a892a97 +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 + +# ========================= +# RAG +# ========================= +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +CHUNK_SIZE=300 +CHUNK_OVERLAP=80 +TOP_K=3 +# Logging Configuration +LOG_LEVEL=INFO + +# CORS Configuration +CORS_ORIGINS=https://docqube.com,https://api.docqube.com +COOKIE_DOMAIN=.docqube.com +DISABLE_CSRF=True + +# Email Configuration +SMTP_HOST=smtp.hostinger.com +SMTP_PORT=465 +SMTP_USER=support@docqube.com +SMTP_PASSWORD=DQsupport@765$ +MAIL_FROM=support@docqube.com +SMTP_SECURE=True + +# Frontend Configuration +FRONTEND_URL=https://docqube.com + +# whisper Model Configuration +WHISPER_MODEL=small + +# Chat Redis Settings +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 + + +# Docuseal API Settings +DOCUSEAL_API_URL=https://api.docuseal.com +DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com + +#ZOHO API Settings +ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..0bda686 --- /dev/null +++ b/.env.test @@ -0,0 +1,68 @@ +APP_ENV=test +HOST=0.0.0.0 +PORT=20002 +DEBUG=False +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_test +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 +SAAS_TRUST_SECRET=docqube_trust_secret_2026 +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=JVCzB612F573hYwqZZ +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=https://docqubeapi-test.maskantech.in +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 +DEEPSEEK_API_KEY=sk-43e2e5e6d450447687b3b664f3a91f33 +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +LLM_TEMPERATURE=0.35 +MAX_RESPONSE_TOKENS=1500 +HF_HUB_OFFLINE=0 +TRANSFORMERS_OFFLINE=0 +VECTOR_DB=qdrant +QDRANT_HOST=qdrant +QDRANT_PORT=6333 +QDRANT_COLLECTION=docqube_collection +RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2 +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +CHUNK_SIZE=850 +CHUNK_OVERLAP=180 +TOP_K_QA=3 +TOP_K_SUMMARY=4 +MAX_CONTEXT_CHARS=4000 +MIN_SIMILARITY_SCORE=0.18 +LOG_LEVEL=INFO +CORS_ORIGINS=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=info.maskantech@gmail.com +SMTP_PASSWORD=tuthpljtkrwchgxd +MAIL_FROM=info.maskantech@gmail.com +SMTP_SECURE=True +FRONTEND_URL=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 +DOCUSEAL_API_URL=https://api.docuseal.com +DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com +ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" +MAX_ACTIVE_DEVICES=5 \ No newline at end of file diff --git a/.env.uat b/.env.uat new file mode 100644 index 0000000..ca5430d --- /dev/null +++ b/.env.uat @@ -0,0 +1,68 @@ +# Environment Configuration +APP_ENV=uat + +# Server Configuration +HOST=127.0.0.1 +PORT=20001 +DEBUG=True + +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_uat +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 + +# Security Configuration +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Redis Configuration +REDIS_HOST=106.51.105.22 +REDIS_PORT=6391 +REDIS_PASSWORD=JVCzB612F573hYwqZZ + +CLAMAV_HOST=106.51.105.22 +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=http://localhost:20001 + +# Google Auth Configuration +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +# AI & Models Configuration +OPENAI_API_KEY="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" +OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1 +OPENAI_MODEL=mistralai/mistral-medium-3-instruct +OPENAI_VISION_MODEL=meta/llama-3.2-90b-vision-instruct +MODEL_SERVER_URL=http://localhost:9000 + +# Logging Configuration +LOG_LEVEL=INFO + +# CORS Configuration +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Email Configuration +SMTP_HOST=smtp.hostinger.com +SMTP_PORT=465 +SMTP_USER=info@maskantech.in +SMTP_PASSWORD=Infomaskan@123 +MAIL_FROM=info@maskantech.in +SMTP_SECURE=True + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + +# whisper Model Configuration +WHISPER_MODEL=small + +# Chat Redis Settings +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67179d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +env1/ + +# Environment files and local secrets +!.env.example + +# Test, coverage, and static-analysis output +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pytype/ +.pyre/ +.coverage +.coverage.* +coverage.xml +htmlcov/ +tests/ + +# Build and packaging output +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Application runtime data +app/temp_uploads/* +storage_drive/ +storage/ +uploads/ +downloads/ +temp/ +tmp/ +logs/ +*.log + +# Local ML and Hugging Face caches +.cache/ +cache/ +model_cache/ +hub/ +!app/modules/**/models/ +!app/**/models/ + +# Local Docker/service data +.docker/ +redis_data/ +qdrant_data/ +clamav_data/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db +Desktop.ini +# Local rework environment — never commit +.env.rework +.env.rework_test +app/temp_uploads/* +!app/temp_uploads/.gitkeep +.env.rework_rls +.gitea/workflows/ +.github/workflows/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..81ce884 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,58 @@ +# B4.3 — the fast gates, before the commit rather than after the push. +# +# Only checks that run in about a second belong here. The characterization +# suite, the cross-tenant probes and the query budgets need a database and take +# a minute; they stay in CI. A pre-commit hook that takes a minute gets +# `--no-verify`d, and then none of it runs. +# +# pip install pre-commit && pre-commit install +# +# Everything here is also enforced in CI, so an uninstalled hook is a slower +# feedback loop rather than a hole. + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=512] + - id: detect-private-key + + - repo: local + hooks: + # The three rules that are cheap to check and expensive to discover late. + - id: no-exception-text-in-responses + name: No exception text in HTTP responses + entry: python -m pytest tests/probes/test_error_disclosure.py::test_no_exception_text_is_returned_to_callers -q + language: system + pass_filenames: false + files: ^app/.*\.py$ + description: > + `detail=f"...{e}"` sends internal errors to the caller. It reached an + unauthenticated endpoint once already. + + - id: transaction-discipline + name: Route handlers do not commit + entry: python -m pytest tests/probes/test_transaction_discipline.py -q + language: system + pass_filenames: false + files: ^app/.*\.py$ + description: > + The request owns the transaction. A handler that commits splits one + request into several and leaves partial state behind on failure. + + - id: layering + name: Import layering contracts + entry: lint-imports + language: system + pass_filenames: false + files: ^app/.*\.py$ + description: > + routes → controllers → services → repositories → models, one + direction only. diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e028118 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.8 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b0ce6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +ARG PYTHON_VERSION=3.11.8 + +FROM python:${PYTHON_VERSION}-slim-bookworm AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +COPY --from=ghcr.io/astral-sh/uv:0.12.8 /uv /uvx /bin/ + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libcairo2-dev \ + libpango1.0-dev \ + libgdk-pixbuf-2.0-dev \ + libffi-dev \ + libglib2.0-dev \ + libjpeg-dev \ + libopenjp2-7-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml uv.lock .python-version ./ + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync \ + --locked \ + --no-install-project \ + --no-editable + +FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libmagic1 \ + poppler-utils \ + libcairo2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi8 \ + libglib2.0-0 \ + libgl1 \ + libjpeg62-turbo \ + libopenjp2-7 \ + fonts-dejavu-core \ + fonts-liberation \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + +RUN addgroup --system app && \ + adduser --system \ + --ingroup app \ + --home /home/app \ + app + +COPY --from=builder --chown=app:app /app/.venv /app/.venv + +COPY --chown=app:app app ./app +COPY --chown=app:app manage.py . +COPY --chown=app:app alembic ./alembic +COPY --chown=app:app alembic.ini . +COPY --chown=app:app scripts ./scripts + +USER app + +CMD ["python", "manage.py", "run"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b523c1f --- /dev/null +++ b/README.md @@ -0,0 +1,426 @@ +# Docqube Backend + +**FastAPI backend application for Docqube** + +A modern, container-ready backend built with FastAPI, managed by **uv**, and orchestrated with **Docker Compose**. + +--- + +## Table of Contents + +- [Requirements](#requirements) +- [Python Version](#python-version) +- [Installing uv](#installing-uv) +- [Getting Started](#getting-started) +- [Local Development](#local-development) +- [Dependency Management](#dependency-management) +- [Docker Compose](#docker-compose) +- [Running Celery Worker](#running-celery-worker) +- [Useful uv Commands](#useful-uv-commands) +- [Development Workflow](#development-workflow) +- [Source of Truth](#source-of-truth) + +--- + +## Requirements + +| Tool | Version / Notes | +|-------------------|--------------------------| +| **Python** | `3.11.8` (strict) | +| **uv** | Latest | +| **Docker** | Docker Desktop / Engine | +| **Docker Compose**| v2+ | + +--- + +## Python Version + +This project is **locked to Python 3.11**. + +```text +Python 3.11.8 +``` + +| File | Setting | +|-------------------|----------------------------------| +| `pyproject.toml` | `requires-python = ">=3.11,<3.12"` | +| `.python-version` | `3.11.8` | + +**Verify:** + +```bash +uv run python --version +``` + +> **Expected:** `Python 3.11.8` +> Do **not** use Python 3.12+ unless compatibility has been fully verified and the project configuration intentionally updated. + +--- + +## Installing uv + +### Windows + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Close and reopen PowerShell, then verify: + +```powershell +uv --version +``` + +### macOS / Linux + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +Restart the terminal and verify: + +```bash +uv --version +``` + +--- + +## Getting Started + +```bash +# 1. Clone the repository +git clone +cd docqube_backend + +# 2. Install all dependencies +uv sync +``` + +`uv sync` will: + +- Create `.venv` automatically +- Use Python 3.11 +- Resolve dependencies from `pyproject.toml` +- Install exact versions from `uv.lock` +- Install Windows-specific packages when on Windows + +> You do **not** need to manually create or activate a virtual environment. + +--- + +## Local Development + +### Install dependencies + +```bash +uv sync +``` + +### Run the application + +```bash +uv run python manage.py run --env local +``` + +The application starts with the local environment configuration +(example: `http://127.0.0.1:20001`). +Host and port are controlled by the application settings. + +--- + +## Database Migrations & Seeding + +All database migrations and seeds are executed through `uv`: + +### 1. Apply Migrations (By Environment) + +**Local Environment:** +```bash +uv run python manage.py migrate --env local +``` + +**Test Environment:** +```bash +uv run python manage.py migrate --env test +``` + +**Production Environment:** +```bash +uv run python manage.py migrate --env production +``` + +*Or directly via Alembic (uses current environment variables):* +```bash +uv run alembic upgrade head +``` + +### 2. Create a New Migration (Autogenerate from Models) + +```bash +uv run alembic revision --autogenerate -m "your_migration_name" +``` + +### 3. Rollback the Last Migration + +```bash +uv run alembic downgrade -1 +``` + +### 4. Seed Database (By Environment) + +**Local:** +```bash +uv run python manage.py seed --env local +``` + +**Production:** +```bash +uv run python manage.py seed --env production +``` + +--- + +## Dependency Management + +All Python dependencies are managed exclusively through: + +- `pyproject.toml` +- `uv.lock` + +> **Do not** maintain a separate `requirements.txt`. + +### Add a production dependency + +```bash +uv add +``` + +Examples: + +```bash +uv add httpx +uv add "httpx==0.28.1" +uv add "pikepdf>=8.0.0,<11.0.0" +``` + +### Add a development dependency + +```bash +uv add --dev pytest +uv add --dev ruff +uv sync +``` + +### Remove a dependency + +```bash +uv remove +uv sync +``` + +### Upgrade dependencies + +**Single package:** + +```bash +uv lock --upgrade-package +uv sync +``` + +Example: + +```bash +uv lock --upgrade-package fastapi +uv sync +``` + +**Specific version:** + +```bash +uv add "fastapi==0.116.1" +``` + +**All packages** (use only for deliberate maintenance): + +```bash +uv lock --upgrade +uv sync +``` + +### Lockfile + +`uv.lock` **must** be committed to Git. +It guarantees identical dependency resolution across developer machines, CI, Docker, staging, and production. + +> **Never** edit `uv.lock` manually. + +### Inspection commands + +```bash +# Full dependency tree +uv tree + +# Find a package (Windows PowerShell) +uv tree | Select-String pikepdf + +# Find a package (Linux / macOS) +uv tree | grep pikepdf + +# Inspect an installed package +uv pip show pikepdf +uv pip show torch +``` + +--- + +## Docker Compose + +### Development environment + +```bash +# Start (build + run) +docker compose -p docqube-development -f docker-compose.development.yml up -d --build + +# Follow logs +docker compose -p docqube-development -f docker-compose.development.yml logs -f + +# Stop +docker compose -p docqube-development -f docker-compose.development.yml down + +# Normal rebuild +docker compose -p docqube-development -f docker-compose.development.yml up -d --build + +# Completely fresh build (no cache) +docker compose -p docqube-development -f docker-compose.development.yml build --no-cache +docker compose -p docqube-development -f docker-compose.development.yml up -d +``` + +--- + +## Running Celery Worker + +### Locally + +```bash +uv run celery -A app.tasks.celery_app:celery_app worker --loglevel=info +``` + +> The worker requires the correct Redis and environment configuration. + +### Recreate the virtual environment + +If `.venv` becomes corrupted: + +**Windows (PowerShell)** + +```powershell +Remove-Item -Recurse -Force .venv +uv sync +``` + +**Linux / macOS** + +```bash +rm -rf .venv +uv sync +``` + +**Force reinstall** + +```bash +uv sync --reinstall +``` + +--- + +## Useful uv Commands + +| Command | Description | +|----------------------------------------------|-------------------------------------------| +| `uv --version` | Check uv version | +| `uv run python --version` | Check Python version | +| `uv python find` | Locate the Python interpreter | +| `uv sync` | Install / synchronize dependencies | +| `uv add ` | Add a production dependency | +| `uv add --dev ` | Add a development dependency | +| `uv remove ` | Remove a dependency | +| `uv lock` | Update the lockfile | +| `uv lock --upgrade-package ` | Upgrade one package | +| `uv lock --upgrade` | Upgrade all packages | +| `uv tree` | View dependency tree | +| `uv pip show ` | Inspect an installed package | +| `uv run python` | Run Python inside the project environment | +| `uv run python manage.py run --env local` | Run the application | + +--- + +## Development Workflow + +### New developer quick start + +1. Clone the repository +2. Install `uv` +3. Run `uv sync` +4. Create the required environment file (e.g. `.env.development`) +5. Verify Python → `uv run python --version` → `Python 3.11.8` +6. Run locally **or** start the full stack with Docker Compose +7. Check services and view logs + +### Day-to-day workflow + +```bash +# Normal local development +uv sync +uv run python manage.py run --env local + +# Add a package +uv add + +# Remove a package +uv remove + +# Upgrade one package +uv lock --upgrade-package +uv sync +``` + +### Before creating a pull request + +```bash +uv sync +uv run python --version +uv run pytest +docker compose -p docqube-development -f docker-compose.development.yml build +docker compose -p docqube-development -f docker-compose.development.yml up -d +``` + +### Important rules + +**Do** + +- Use `uv sync`, `uv add`, `uv remove`, `uv run` +- Commit `pyproject.toml`, `uv.lock`, and `.python-version` +- Use Docker Compose for the full containerized environment + +**Do not** + +- Use `pip install ...` +- Manually create `env/` or `venv/` +- Manually edit `uv.lock` +- Commit `.venv/` or real `.env` files +- Maintain a manually synchronized `requirements.txt` + +--- + +## Source of Truth + +| Concern | Source of Truth | +|--------------------------------|----------------------------| +| Python version | `.python-version` | +| Python dependencies | `pyproject.toml` | +| Locked dependencies | `uv.lock` | +| Python / Docker environment | `Dockerfile` | +| Environment orchestration | `docker-compose.*.yml` | +| Environment configuration | `.env.*` | + + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0e0fb5b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+psycopg2://dummy + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a918a1b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,43 @@ +import sys +import os +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context + +# Add project root to path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.core.settings import DATABASE_URL +from app.db.database import Base + +# Import all models here so Alembic can detect them. +# +# `app.db.all_models` is the one that matters. Importing `app.main` reaches +# most models by accident — routes import what they use — but it missed +# `user_roles`, `access_groups` and `user_access_groups`, whose every import is +# function-local. Autogenerate reported all three as removed tables and offered +# to drop the SBAC schema. See tests/probes/test_model_registry.py. +import app.db.all_models # noqa: F401 +import app.main +from app.modules.tenant.models.tenant_contact_model import TenantContact + +target_metadata = Base.metadata + +config = context.config +config.set_main_option("sqlalchemy.url", DATABASE_URL.replace('%', '%%')) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + +run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/06001599321d_add_collaboration_chat.py b/alembic/versions/06001599321d_add_collaboration_chat.py new file mode 100644 index 0000000..b0202ae --- /dev/null +++ b/alembic/versions/06001599321d_add_collaboration_chat.py @@ -0,0 +1,26 @@ +"""add collaboration chat + +Revision ID: 06001599321d +Revises: 22dee9a1d631 +Create Date: 2026-02-26 16:13:54.998247 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '06001599321d' +down_revision: Union[str, None] = '22dee9a1d631' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/0bffea34ee5f_disable_rls_temporarily.py b/alembic/versions/0bffea34ee5f_disable_rls_temporarily.py new file mode 100644 index 0000000..d656678 --- /dev/null +++ b/alembic/versions/0bffea34ee5f_disable_rls_temporarily.py @@ -0,0 +1,35 @@ +"""disable_rls_temporarily + +Revision ID: 0bffea34ee5f +Revises: 14b4aa5f0f5d +Create Date: 2026-09-05 12:01:30.634229 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0bffea34ee5f' +down_revision: Union[str, None] = '14b4aa5f0f5d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +RLS_TABLES = [ + "user_org_units", "org_unit_leads", "user_roles", "access_groups", "user_access_groups", + "org_units", "tenant_subscriptions", "tenant_limit_overrides", "chatbot_documents", + "drive_activities", "drive_files", "drive_folders", "notifications", "tenant_smtp_configs", + "user_files", "projects", "roles", "activity_logs", "tenant_storage_configs", + "chat_sessions", "signing_requests", "tenant_signature_configs", "users" +] + +def upgrade() -> None: + for table in RLS_TABLES: + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + +def downgrade() -> None: + for table in RLS_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") diff --git a/alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py b/alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py new file mode 100644 index 0000000..f4b7857 --- /dev/null +++ b/alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py @@ -0,0 +1,38 @@ +"""Add max_bytes_quota to user_storage_usage + +Revision ID: 0e1a5f2cdea9 +Revises: 9a6db38b86a6 +Create Date: 2026-03-02 10:48:38.335359 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0e1a5f2cdea9' +down_revision: Union[str, None] = '9a6db38b86a6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('project_versions', sa.Column('drive_file_version_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'project_versions', 'drive_file_versions', ['drive_file_version_id'], ['id']) + op.add_column('user_files', sa.Column('is_deleted', sa.Boolean(), nullable=True)) + op.add_column('user_files', sa.Column('deleted_at', sa.TIMESTAMP(), nullable=True)) + op.add_column('user_storage_usage', sa.Column('max_bytes_quota', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_storage_usage', 'max_bytes_quota') + op.drop_column('user_files', 'deleted_at') + op.drop_column('user_files', 'is_deleted') + op.drop_constraint(None, 'project_versions', type_='foreignkey') + op.drop_column('project_versions', 'drive_file_version_id') + # ### end Alembic commands ### diff --git a/alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py b/alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py new file mode 100644 index 0000000..fc6d6e1 --- /dev/null +++ b/alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""Merge multiple heads + +Revision ID: 14b4aa5f0f5d +Revises: e1_0_add_saas_plan_id, z005_add_virus_scan +Create Date: 2026-09-02 19:15:56.213495 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '14b4aa5f0f5d' +down_revision: Union[str, None] = ('e1_0_add_saas_plan_id', 'z005_add_virus_scan') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/15f935213b22_add_tenant_storage_settings.py b/alembic/versions/15f935213b22_add_tenant_storage_settings.py new file mode 100644 index 0000000..ea2fbb3 --- /dev/null +++ b/alembic/versions/15f935213b22_add_tenant_storage_settings.py @@ -0,0 +1,66 @@ +"""add tenant storage settings + +Revision ID: 15f935213b22 +Revises: k002_add_docs_tutorial +Create Date: 2026-04-17 15:49:28.721223 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '15f935213b22' +down_revision: Union[str, None] = 'k002_add_docs_tutorial' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('tenant_storage_configs'): + op.create_table('tenant_storage_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('b2_key_id', sa.String(length=255), nullable=False), + sa.Column('encrypted_b2_application_key', sa.String(length=512), nullable=False), + sa.Column('b2_quarantine_bucket', sa.String(length=255), nullable=False), + sa.Column('b2_clean_bucket', sa.String(length=255), nullable=False), + sa.Column('b2_endpoint', sa.String(length=512), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + + existing_indexes = _index_names(bind, 'tenant_storage_configs') + if 'ix_tenant_storage_configs_tenant_id' not in existing_indexes: + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tenant_storage_configs_tenant_id'), ['tenant_id'], unique=True) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table('tenant_storage_configs'): + existing_indexes = _index_names(bind, 'tenant_storage_configs') + if 'ix_tenant_storage_configs_tenant_id' in existing_indexes: + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tenant_storage_configs_tenant_id')) + + op.drop_table('tenant_storage_configs') diff --git a/alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py b/alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py new file mode 100644 index 0000000..a5db058 --- /dev/null +++ b/alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py @@ -0,0 +1,59 @@ +"""Add SaaS mapping tables + +Revision ID: 1cb413ffaaa0 +Revises: n002_add_signature_imprints +Create Date: 2026-04-23 23:16:32.782986 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1cb413ffaaa0' +down_revision: Union[str, None] = 'n002_add_signature_imprints' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- SaaSTenantMapping --- + op.create_table( + 'saas_tenant_mappings', + sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('saas_tenant_id', sa.String(), nullable=False), + sa.Column('docqube_tenant_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['docqube_tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_saas_tenant_mappings_id'), 'saas_tenant_mappings', ['id'], unique=False) + op.create_index(op.f('ix_saas_tenant_mappings_saas_tenant_id'), 'saas_tenant_mappings', ['saas_tenant_id'], unique=True) + + # --- SaaSUserMapping --- + op.create_table( + 'saas_user_mappings', + sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('saas_user_id', sa.String(), nullable=False), + sa.Column('docqube_user_id', sa.Integer(), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['docqube_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_saas_user_mappings_id'), 'saas_user_mappings', ['id'], unique=False) + op.create_index(op.f('ix_saas_user_mappings_saas_user_id'), 'saas_user_mappings', ['saas_user_id'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_saas_user_mappings_saas_user_id'), table_name='saas_user_mappings') + op.drop_index(op.f('ix_saas_user_mappings_id'), table_name='saas_user_mappings') + op.drop_table('saas_user_mappings') + op.drop_index(op.f('ix_saas_tenant_mappings_saas_tenant_id'), table_name='saas_tenant_mappings') + op.drop_index(op.f('ix_saas_tenant_mappings_id'), table_name='saas_tenant_mappings') + op.drop_table('saas_tenant_mappings') diff --git a/alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py b/alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py new file mode 100644 index 0000000..ce4314e --- /dev/null +++ b/alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py @@ -0,0 +1,67 @@ +"""add missing vector columns + +Revision ID: 1f4d2a8c9b77 +Revises: fb6810c36ffb +Create Date: 2026-04-08 22:05:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "1f4d2a8c9b77" +down_revision: Union[str, None] = "fb6810c36ffb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + return column_name in {col["name"] for col in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "vector_indices"): + if not _column_exists(bind, "vector_indices", "index_name"): + op.add_column( + "vector_indices", sa.Column("index_name", sa.String(length=255), nullable=True) + ) + if not _column_exists(bind, "vector_indices", "checksum"): + op.add_column( + "vector_indices", sa.Column("checksum", sa.String(length=255), nullable=True) + ) + if not _column_exists(bind, "vector_indices", "metadata_info"): + op.add_column( + "vector_indices", sa.Column("metadata_info", sa.JSON(), nullable=True) + ) + + if _table_exists(bind, "vector_chunks"): + if not _column_exists(bind, "vector_chunks", "embedding_json"): + op.add_column( + "vector_chunks", sa.Column("embedding_json", sa.JSON(), nullable=True) + ) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "vector_chunks"): + if _column_exists(bind, "vector_chunks", "embedding_json"): + op.drop_column("vector_chunks", "embedding_json") + + if _table_exists(bind, "vector_indices"): + if _column_exists(bind, "vector_indices", "metadata_info"): + op.drop_column("vector_indices", "metadata_info") + if _column_exists(bind, "vector_indices", "checksum"): + op.drop_column("vector_indices", "checksum") + if _column_exists(bind, "vector_indices", "index_name"): + op.drop_column("vector_indices", "index_name") diff --git a/alembic/versions/229359b0d1b2_add_terms_accepted.py b/alembic/versions/229359b0d1b2_add_terms_accepted.py new file mode 100644 index 0000000..9dfe144 --- /dev/null +++ b/alembic/versions/229359b0d1b2_add_terms_accepted.py @@ -0,0 +1,64 @@ +"""add_terms_accepted + +Revision ID: 229359b0d1b2 +Revises: 823a486fd287 +Create Date: 2026-04-01 11:28:44.953518 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '229359b0d1b2' +down_revision: Union[str, None] = '823a486fd287' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('drive_comments', schema=None) as batch_op: + batch_op.alter_column('rejected_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True) + + with op.batch_alter_table('drive_stars', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('terms_accepted', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + batch_op.drop_index(batch_op.f('ix_users_email')) + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_email')) + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True) + batch_op.drop_column('terms_accepted') + + with op.batch_alter_table('drive_stars', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('drive_comments', schema=None) as batch_op: + batch_op.alter_column('rejected_at', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py b/alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py new file mode 100644 index 0000000..6ccef18 --- /dev/null +++ b/alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py @@ -0,0 +1,32 @@ +"""Add shared_by_user_id to ProjectShare + +Revision ID: 22dee9a1d631 +Revises: dbaa8ef522c0 +Create Date: 2026-02-26 15:33:00.617693 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '22dee9a1d631' +down_revision: Union[str, None] = 'dbaa8ef522c0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('project_shares', sa.Column('shared_by_user_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'project_shares', 'users', ['shared_by_user_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'project_shares', type_='foreignkey') + op.drop_column('project_shares', 'shared_by_user_id') + # ### end Alembic commands ### diff --git a/alembic/versions/29c02cb50737_initial_migration.py b/alembic/versions/29c02cb50737_initial_migration.py new file mode 100644 index 0000000..782db29 --- /dev/null +++ b/alembic/versions/29c02cb50737_initial_migration.py @@ -0,0 +1,258 @@ +"""initial migration + +Revision ID: 29c02cb50737 +Revises: +Create Date: 2026-02-16 19:28:02.116622 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '29c02cb50737' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('drive_acl', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('subject_type', sa.String(length=10), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=True), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('inherited_from', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('subscription', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('drive_activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('actor_id', sa.Integer(), nullable=False), + sa.Column('activity_type', sa.String(length=50), nullable=False), + sa.Column('activity_metadata', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(length=64), nullable=True), + sa.Column('device_info', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['actor_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_folders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('is_starred', sa.Boolean(), nullable=False), + sa.Column('is_trashed', sa.Boolean(), nullable=False), + sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_id'], ['drive_folders.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_shares', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('invited_email', sa.String(length=255), nullable=True), + sa.Column('link_token', sa.String(length=255), nullable=True), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('link_token') + ) + op.create_table('drive_stars', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('resource_type', 'resource_id', 'user_id', name='uq_drive_star') + ) + op.create_table('projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('file_type', sa.String(length=50), nullable=False), + sa.Column('output_format', sa.String(length=50), nullable=False), + sa.Column('progress', sa.Integer(), nullable=False), + sa.Column('current_step', sa.String(length=50), nullable=False), + sa.Column('session_id', sa.String(length=255), nullable=True), + sa.Column('html_content', sa.Text(), nullable=True), + sa.Column('markdown_content', sa.Text(), nullable=True), + sa.Column('xml_content', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_files', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('file_name', sa.String(length=512), nullable=False), + sa.Column('s3_key', sa.String(length=1024), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('s3_key') + ) + op.create_table('user_storage_usage', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('total_bytes', sa.BigInteger(), nullable=False), + sa.Column('file_count', sa.Integer(), nullable=False), + sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_table('drive_files', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('folder_id', sa.Integer(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=512), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('size', sa.BigInteger(), nullable=False), + sa.Column('hash', sa.String(length=128), nullable=True), + sa.Column('latest_version_id', sa.Integer(), nullable=True), + sa.Column('is_starred', sa.Boolean(), nullable=False), + sa.Column('is_trashed', sa.Boolean(), nullable=False), + sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['folder_id'], ['drive_folders.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('activity_type', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('activity_metadata', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(length=64), nullable=True), + sa.Column('device_info', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_shares', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), nullable=False), + sa.Column('shared_by_user_id', sa.Integer(), nullable=False), + sa.Column('permission', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('project_id', 'shared_with_user_id', name='unique_project_user_share') + ) + op.create_table('project_versions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('changes_description', sa.String(length=500), nullable=True), + sa.Column('markdown_content', sa.Text(), nullable=True), + sa.Column('xml_content', sa.Text(), nullable=True), + sa.Column('html_content', sa.Text(), nullable=True), + sa.Column('session_id', sa.String(length=255), nullable=True), + sa.Column('is_current', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('file_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['file_id'], ['drive_files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_file_versions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('file_id', sa.Integer(), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('storage_key', sa.Text(), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('size', sa.BigInteger(), nullable=False), + sa.Column('checksum', sa.String(length=128), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['file_id'], ['drive_files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('drive_file_versions') + op.drop_table('drive_comments') + op.drop_table('project_versions') + op.drop_table('project_shares') + op.drop_table('project_comments') + op.drop_table('project_activities') + op.drop_table('drive_files') + op.drop_table('user_storage_usage') + op.drop_table('user_files') + op.drop_table('projects') + op.drop_table('drive_stars') + op.drop_table('drive_shares') + op.drop_table('drive_folders') + op.drop_table('drive_activities') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('drive_acl') + # ### end Alembic commands ### diff --git a/alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py b/alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py new file mode 100644 index 0000000..7cf296a --- /dev/null +++ b/alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py @@ -0,0 +1,51 @@ +"""Add subscription fields and PlanPriceHistory + +Revision ID: 2b4c62c1cf35 +Revises: d2_0_credit_limit_precision +Create Date: 2026-08-19 18:04:44.734002 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '2b4c62c1cf35' +down_revision: Union[str, None] = 'd2_0_credit_limit_precision' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('plan_price_history', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('plan_id', sa.UUID(), nullable=False), + sa.Column('old_price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('new_price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('currency', sa.String(length=10), nullable=True), + sa.Column('changed_by', sa.Integer(), nullable=True), + sa.Column('changed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_plan_price_history_plan_id'), 'plan_price_history', ['plan_id'], unique=False) + + op.add_column('plans', sa.Column('price_amount', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('plans', sa.Column('interval', sa.String(length=20), nullable=True)) + op.add_column('plans', sa.Column('grace_period_days', sa.Integer(), server_default='0', nullable=False)) + op.add_column('plans', sa.Column('notify_days_before_expiry', sa.Integer(), server_default='7', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('plans', 'notify_days_before_expiry') + op.drop_column('plans', 'grace_period_days') + op.drop_column('plans', 'interval') + op.drop_column('plans', 'price_amount') + op.drop_index(op.f('ix_plan_price_history_plan_id'), table_name='plan_price_history') + op.drop_table('plan_price_history') + # ### end Alembic commands ### diff --git a/alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py b/alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py new file mode 100644 index 0000000..d293a28 --- /dev/null +++ b/alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py @@ -0,0 +1,128 @@ +"""add signature_id to signature_imprints + +Revision ID: 39d5fdfb5386 +Revises: 1cb413ffaaa0 +Create Date: 2026-04-25 15:33:32.819817 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '39d5fdfb5386' +down_revision: Union[str, None] = '1cb413ffaaa0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Legacy tables/indexes — drop only if present (some envs already lack them). + op.execute("DROP TABLE IF EXISTS vector_chunks CASCADE") + op.execute("DROP TABLE IF EXISTS vector_indices CASCADE") + op.execute("DROP TABLE IF EXISTS chat_token_usage CASCADE") + + op.execute("DROP INDEX IF EXISTS ix_chatbot_documents_drive_file_id") + op.execute("DROP INDEX IF EXISTS ix_chatbot_documents_id") + + with op.batch_alter_table('extraction_data', schema=None) as batch_op: + batch_op.alter_column('confidence_score', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=False) + batch_op.alter_column('extraction_status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + op.execute("DROP INDEX IF EXISTS idx_extraction_data_document_id") + op.create_index(op.f('ix_extraction_data_document_id'), 'extraction_data', ['document_id'], unique=False) + + op.execute("DROP INDEX IF EXISTS ix_signature_imprints_file_page") + op.execute("DROP INDEX IF EXISTS ix_signature_imprints_signing_request") + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + batch_op.add_column(sa.Column('signature_id', sa.String(length=15), nullable=True)) + batch_op.create_index(batch_op.f('ix_signature_imprints_signing_request_id'), ['signing_request_id'], unique=False) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signature_imprints_signing_request_id')) + batch_op.create_index(batch_op.f('ix_signature_imprints_signing_request'), ['signing_request_id'], unique=False) + batch_op.create_index(batch_op.f('ix_signature_imprints_file_page'), ['drive_file_id', 'page_number'], unique=False) + batch_op.drop_column('signature_id') + + with op.batch_alter_table('extraction_data', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_extraction_data_document_id')) + batch_op.create_index(batch_op.f('idx_extraction_data_document_id'), ['document_id'], unique=False) + batch_op.alter_column('extraction_status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + batch_op.alter_column('confidence_score', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=True) + + with op.batch_alter_table('chatbot_documents', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chatbot_documents_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_chatbot_documents_drive_file_id'), ['drive_file_id'], unique=False) + + op.create_table('chat_token_usage', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('chat_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('document_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('prompt_tokens', sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column('completion_tokens', sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column('total_tokens', sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column('model_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('cost_usd', sa.NUMERIC(precision=12, scale=8), autoincrement=False, nullable=False), + sa.Column('timestamp', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('phase', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('chat_token_usage_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('chat_token_usage_pkey')) + ) + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_token_usage_user_time'), ['user_id', 'timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_phase'), ['phase'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_model_time'), ['model_name', 'timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_model_name'), ['model_name'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_document_id'), ['document_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_chat_id'), ['chat_id'], unique=False) + + op.create_table('vector_indices', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('index_data', postgresql.BYTEA(), autoincrement=False, nullable=True), + sa.Column('dimension', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('total_vectors', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('index_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('checksum', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('metadata_info', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('vector_indices_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('vector_indices_pkey')) + ) + with op.batch_alter_table('vector_indices', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_vector_indices_user_id'), ['user_id'], unique=True) + batch_op.create_index(batch_op.f('ix_vector_indices_id'), ['id'], unique=False) + + op.create_table('vector_chunks', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('document_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('chunk_text', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('chunk_index', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('embedding_json', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('vector_chunks_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('vector_chunks_pkey')) + ) + with op.batch_alter_table('vector_chunks', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_vector_chunks_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_vector_chunks_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_vector_chunks_document_id'), ['document_id'], unique=False) + + # ### end Alembic commands ### diff --git a/alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py b/alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py new file mode 100644 index 0000000..fd70e8a --- /dev/null +++ b/alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py @@ -0,0 +1,51 @@ +"""Enforce enum constraint on DriveACL role + +Revision ID: 3aa7d970b8c2 +Revises: 89508c176437 +Create Date: 2026-03-03 22:06:24.190823 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3aa7d970b8c2' +down_revision: Union[str, None] = '89508c176437' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# The four valid drive roles +VALID_ROLES = ('viewer', 'commenter', 'editor', 'owner') + + +def upgrade() -> None: + # 1. Create the PostgreSQL enum type (idempotent via IF NOT EXISTS) + op.execute("CREATE TYPE drive_role AS ENUM ('viewer', 'commenter', 'editor', 'owner')") + + # 2. Sanitise any NULL or invalid rows before adding NOT NULL constraint. + # Default unknown/null values to 'viewer' (least-privileged), then log them. + op.execute( + "UPDATE drive_acls SET role = 'viewer' " + "WHERE role IS NULL OR role NOT IN ('viewer', 'commenter', 'editor', 'owner')" + ) + + # 3. Alter the column: cast existing VARCHAR values to the new enum type + op.execute( + "ALTER TABLE drive_acls " + "ALTER COLUMN role TYPE drive_role USING role::drive_role, " + "ALTER COLUMN role SET NOT NULL" + ) + + +def downgrade() -> None: + # Revert column to plain VARCHAR(50), nullable + op.execute( + "ALTER TABLE drive_acls " + "ALTER COLUMN role TYPE VARCHAR(50) USING role::VARCHAR, " + "ALTER COLUMN role DROP NOT NULL" + ) + # Drop the enum type (only if no other table references it) + op.execute("DROP TYPE IF EXISTS drive_role") diff --git a/alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py b/alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py new file mode 100644 index 0000000..cf85d17 --- /dev/null +++ b/alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py @@ -0,0 +1,100 @@ +"""Refactor documents and add missing fields + +Revision ID: 41bed7eb6ed9 +Revises: 06001599321d +Create Date: 2026-02-27 14:21:01.746676 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '41bed7eb6ed9' +down_revision: Union[str, None] = '06001599321d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('project_activities') + op.execute("DROP INDEX IF EXISTS ix_collaboration_chat_resource_id") + op.execute("DROP INDEX IF EXISTS ix_collaboration_chat_resource_type") + op.execute("DROP TABLE IF EXISTS collaboration_chat CASCADE") + op.drop_table('project_comments') + op.drop_table('project_shares') + op.add_column('project_versions', sa.Column('changes_description', sa.Text(), nullable=True)) + op.add_column('project_versions', sa.Column('session_id', sa.String(length=255), nullable=True)) + op.drop_column('project_versions', 'description') + op.add_column('projects', sa.Column('drive_file_id', sa.Integer(), nullable=True)) + op.create_index('idx_project_session', 'projects', ['session_id'], unique=False) + op.create_index('idx_project_user_status', 'projects', ['user_id', 'status'], unique=False) + op.create_index(op.f('ix_projects_drive_file_id'), 'projects', ['drive_file_id'], unique=False) + op.create_foreign_key(None, 'projects', 'drive_files', ['drive_file_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'projects', type_='foreignkey') + op.drop_index(op.f('ix_projects_drive_file_id'), table_name='projects') + op.drop_index('idx_project_user_status', table_name='projects') + op.drop_index('idx_project_session', table_name='projects') + op.drop_column('projects', 'drive_file_id') + op.add_column('project_versions', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True)) + op.drop_column('project_versions', 'session_id') + op.drop_column('project_versions', 'changes_description') + op.create_table('project_shares', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('shared_with_user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('permission', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('shared_by_user_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_shares_project_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], name='project_shares_shared_by_user_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], name='project_shares_shared_with_user_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='project_shares_pkey') + ) + op.create_table('project_comments', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('anchor', sa.TEXT(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], name='project_comments_author_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_comments_project_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='project_comments_pkey') + ) + op.create_table('collaboration_chat', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('resource_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('resource_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('message', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='collaboration_chat_user_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='collaboration_chat_pkey') + ) + op.create_index('ix_collaboration_chat_resource_type', 'collaboration_chat', ['resource_type'], unique=False) + op.create_index('ix_collaboration_chat_resource_id', 'collaboration_chat', ['resource_id'], unique=False) + op.create_table('project_activities', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('activity_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('activity_metadata', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True), + sa.Column('device_info', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_activities_project_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='project_activities_user_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='project_activities_pkey') + ) + # ### end Alembic commands ### diff --git a/alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py b/alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py new file mode 100644 index 0000000..c78a1cb --- /dev/null +++ b/alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py @@ -0,0 +1,36 @@ +"""Limit Notification message to 2000 chars + +Revision ID: 45c94ee15294 +Revises: d2e2c55290ee +Create Date: 2026-03-03 22:24:55.795394 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '45c94ee15294' +down_revision: Union[str, None] = 'd2e2c55290ee' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'message', + existing_type=sa.TEXT(), + type_=sa.String(length=2000), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'message', + existing_type=sa.String(length=2000), + type_=sa.TEXT(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/49c19df2c1d4_sync_models.py b/alembic/versions/49c19df2c1d4_sync_models.py new file mode 100644 index 0000000..6889daa --- /dev/null +++ b/alembic/versions/49c19df2c1d4_sync_models.py @@ -0,0 +1,371 @@ +"""sync_models + +Revision ID: 49c19df2c1d4 +Revises: 29c02cb50737 +Create Date: 2026-02-19 11:21:56.538569 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '49c19df2c1d4' +down_revision: Union[str, None] = '29c02cb50737' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('drive_acls', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=20), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=50), nullable=True), + sa.Column('actor_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chatbot_documents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('drive_file_id', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(), nullable=False), + sa.Column('file_path', sa.String(), nullable=True), + sa.Column('cloud_url', sa.String(), nullable=True), + sa.Column('chunks_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chatbot_documents_document_id'), 'chatbot_documents', ['document_id'], unique=True) + op.create_index(op.f('ix_chatbot_documents_drive_file_id'), 'chatbot_documents', ['drive_file_id'], unique=False) + op.create_index(op.f('ix_chatbot_documents_id'), 'chatbot_documents', ['id'], unique=False) + op.create_index(op.f('ix_chatbot_documents_user_id'), 'chatbot_documents', ['user_id'], unique=False) + op.create_table('vector_chunks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.String(), nullable=True), + sa.Column('chunk_text', sa.String(), nullable=False), + sa.Column('chunk_index', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_vector_chunks_document_id'), 'vector_chunks', ['document_id'], unique=False) + op.create_index(op.f('ix_vector_chunks_id'), 'vector_chunks', ['id'], unique=False) + op.create_index(op.f('ix_vector_chunks_user_id'), 'vector_chunks', ['user_id'], unique=False) + op.create_table('vector_indices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('index_data', sa.LargeBinary(), nullable=True), + sa.Column('dimension', sa.Integer(), nullable=True), + sa.Column('total_vectors', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_vector_indices_id'), 'vector_indices', ['id'], unique=False) + op.create_index(op.f('ix_vector_indices_user_id'), 'vector_indices', ['user_id'], unique=True) + op.drop_table('drive_acl') + op.alter_column('drive_activities', 'resource_type', + existing_type=sa.VARCHAR(length=10), + type_=sa.String(length=20), + nullable=True) + op.alter_column('drive_activities', 'resource_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_activities', 'actor_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_activities', 'activity_type', + existing_type=sa.VARCHAR(length=50), + nullable=True) + op.alter_column('drive_activities', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('drive_activities_actor_id_fkey'), 'drive_activities', type_='foreignkey') + op.create_foreign_key(None, 'drive_activities', 'users', ['actor_id'], ['id']) + op.drop_column('drive_activities', 'device_info') + op.drop_column('drive_activities', 'ip_address') + op.add_column('drive_comments', sa.Column('user_id', sa.Integer(), nullable=False)) + op.add_column('drive_comments', sa.Column('content', sa.Text(), nullable=False)) + op.alter_column('drive_comments', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('drive_comments_file_id_fkey'), 'drive_comments', type_='foreignkey') + op.drop_constraint(op.f('drive_comments_author_id_fkey'), 'drive_comments', type_='foreignkey') + op.create_foreign_key(None, 'drive_comments', 'users', ['user_id'], ['id']) + op.create_foreign_key(None, 'drive_comments', 'drive_files', ['file_id'], ['id']) + op.drop_column('drive_comments', 'anchor') + op.drop_column('drive_comments', 'body') + op.drop_column('drive_comments', 'resolved_at') + op.drop_column('drive_comments', 'author_id') + op.add_column('drive_file_versions', sa.Column('s3_key', sa.String(), nullable=True)) + op.add_column('drive_file_versions', sa.Column('content_type', sa.String(), nullable=True)) + op.add_column('drive_file_versions', sa.Column('uploader_id', sa.Integer(), nullable=True)) + op.alter_column('drive_file_versions', 'file_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_file_versions', 'version_number', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_file_versions', 'size', + existing_type=sa.BIGINT(), + nullable=True) + op.alter_column('drive_file_versions', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('drive_file_versions_file_id_fkey'), 'drive_file_versions', type_='foreignkey') + op.drop_constraint(op.f('drive_file_versions_created_by_id_fkey'), 'drive_file_versions', type_='foreignkey') + op.create_foreign_key(None, 'drive_file_versions', 'drive_files', ['file_id'], ['id']) + op.create_foreign_key(None, 'drive_file_versions', 'users', ['uploader_id'], ['id']) + op.drop_column('drive_file_versions', 'checksum') + op.drop_column('drive_file_versions', 'created_by_id') + op.drop_column('drive_file_versions', 'storage_key') + op.drop_column('drive_file_versions', 'mime_type') + op.alter_column('drive_stars', 'user_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_stars', 'resource_type', + existing_type=sa.VARCHAR(length=10), + nullable=True) + op.alter_column('drive_stars', 'resource_id', + existing_type=sa.INTEGER(), + nullable=True) + op.drop_constraint(op.f('uq_drive_star'), 'drive_stars', type_='unique') + op.drop_constraint(op.f('drive_stars_user_id_fkey'), 'drive_stars', type_='foreignkey') + op.create_foreign_key(None, 'drive_stars', 'users', ['user_id'], ['id']) + op.drop_column('drive_stars', 'created_at') + op.alter_column('project_activities', 'ip_address', + existing_type=sa.VARCHAR(length=64), + type_=sa.String(length=45), + existing_nullable=True) + op.alter_column('project_comments', 'anchor', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.Text(), + existing_nullable=True) + op.alter_column('project_shares', 'permission', + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=20), + existing_nullable=False) + op.drop_constraint(op.f('unique_project_user_share'), 'project_shares', type_='unique') + op.drop_constraint(op.f('project_shares_shared_by_user_id_fkey'), 'project_shares', type_='foreignkey') + op.drop_column('project_shares', 'updated_at') + op.drop_column('project_shares', 'shared_by_user_id') + op.add_column('project_versions', sa.Column('version_label', sa.String(length=50), nullable=False)) + op.add_column('project_versions', sa.Column('description', sa.Text(), nullable=True)) + op.alter_column('project_versions', 'created_by_id', + existing_type=sa.INTEGER(), + nullable=True) + op.drop_constraint(op.f('project_versions_created_by_id_fkey'), 'project_versions', type_='foreignkey') + op.create_foreign_key(None, 'project_versions', 'users', ['created_by_id'], ['id'], ondelete='SET NULL') + op.drop_column('project_versions', 'changes_description') + op.drop_column('project_versions', 'session_id') + op.add_column('user_files', sa.Column('filename', sa.String(), nullable=False)) + op.add_column('user_files', sa.Column('size', sa.BigInteger(), nullable=True)) + op.add_column('user_files', sa.Column('content_type', sa.String(), nullable=True)) + op.add_column('user_files', sa.Column('status', sa.String(), nullable=True)) + op.alter_column('user_files', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('user_files_s3_key_key'), 'user_files', type_='unique') + op.create_index(op.f('ix_user_files_id'), 'user_files', ['id'], unique=False) + op.create_index(op.f('ix_user_files_user_id'), 'user_files', ['user_id'], unique=False) + op.drop_constraint(op.f('user_files_user_id_fkey'), 'user_files', type_='foreignkey') + op.create_foreign_key(None, 'user_files', 'users', ['user_id'], ['id']) + op.drop_column('user_files', 'file_name') + op.drop_column('user_files', 'deleted_at') + op.drop_column('user_files', 'file_size') + op.drop_column('user_files', 'is_deleted') + op.drop_column('user_files', 'mime_type') + op.add_column('user_storage_usage', sa.Column('id', sa.Integer(), nullable=False)) + op.add_column('user_storage_usage', sa.Column('total_bytes_used', sa.BigInteger(), nullable=True)) + op.add_column('user_storage_usage', sa.Column('total_files_count', sa.Integer(), nullable=True)) + op.alter_column('user_storage_usage', 'last_updated', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.create_index(op.f('ix_user_storage_usage_id'), 'user_storage_usage', ['id'], unique=False) + op.create_unique_constraint(None, 'user_storage_usage', ['user_id']) + op.drop_constraint(op.f('user_storage_usage_user_id_fkey'), 'user_storage_usage', type_='foreignkey') + op.create_foreign_key(None, 'user_storage_usage', 'users', ['user_id'], ['id']) + op.drop_column('user_storage_usage', 'total_bytes') + op.drop_column('user_storage_usage', 'file_count') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_storage_usage', sa.Column('file_count', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('user_storage_usage', sa.Column('total_bytes', sa.BIGINT(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'user_storage_usage', type_='foreignkey') + op.create_foreign_key(op.f('user_storage_usage_user_id_fkey'), 'user_storage_usage', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_constraint(None, 'user_storage_usage', type_='unique') + op.drop_index(op.f('ix_user_storage_usage_id'), table_name='user_storage_usage') + op.alter_column('user_storage_usage', 'last_updated', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_column('user_storage_usage', 'total_files_count') + op.drop_column('user_storage_usage', 'total_bytes_used') + op.drop_column('user_storage_usage', 'id') + op.add_column('user_files', sa.Column('mime_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('user_files', sa.Column('is_deleted', sa.BOOLEAN(), autoincrement=False, nullable=False)) + op.add_column('user_files', sa.Column('file_size', sa.BIGINT(), autoincrement=False, nullable=False)) + op.add_column('user_files', sa.Column('deleted_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True)) + op.add_column('user_files', sa.Column('file_name', sa.VARCHAR(length=512), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'user_files', type_='foreignkey') + op.create_foreign_key(op.f('user_files_user_id_fkey'), 'user_files', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_index(op.f('ix_user_files_user_id'), table_name='user_files') + op.drop_index(op.f('ix_user_files_id'), table_name='user_files') + op.create_unique_constraint(op.f('user_files_s3_key_key'), 'user_files', ['s3_key']) + op.alter_column('user_files', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_column('user_files', 'status') + op.drop_column('user_files', 'content_type') + op.drop_column('user_files', 'size') + op.drop_column('user_files', 'filename') + op.add_column('project_versions', sa.Column('session_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('project_versions', sa.Column('changes_description', sa.VARCHAR(length=500), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'project_versions', type_='foreignkey') + op.create_foreign_key(op.f('project_versions_created_by_id_fkey'), 'project_versions', 'users', ['created_by_id'], ['id']) + op.alter_column('project_versions', 'created_by_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('project_versions', 'description') + op.drop_column('project_versions', 'version_label') + op.add_column('project_shares', sa.Column('shared_by_user_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('project_shares', sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False)) + op.create_foreign_key(op.f('project_shares_shared_by_user_id_fkey'), 'project_shares', 'users', ['shared_by_user_id'], ['id'], ondelete='CASCADE') + op.create_unique_constraint(op.f('unique_project_user_share'), 'project_shares', ['project_id', 'shared_with_user_id']) + op.alter_column('project_shares', 'permission', + existing_type=sa.String(length=20), + type_=sa.VARCHAR(length=50), + existing_nullable=False) + op.alter_column('project_comments', 'anchor', + existing_type=sa.Text(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True) + op.alter_column('project_activities', 'ip_address', + existing_type=sa.String(length=45), + type_=sa.VARCHAR(length=64), + existing_nullable=True) + op.add_column('drive_stars', sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'drive_stars', type_='foreignkey') + op.create_foreign_key(op.f('drive_stars_user_id_fkey'), 'drive_stars', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.create_unique_constraint(op.f('uq_drive_star'), 'drive_stars', ['resource_type', 'resource_id', 'user_id']) + op.alter_column('drive_stars', 'resource_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_stars', 'resource_type', + existing_type=sa.VARCHAR(length=10), + nullable=False) + op.alter_column('drive_stars', 'user_id', + existing_type=sa.INTEGER(), + nullable=False) + op.add_column('drive_file_versions', sa.Column('mime_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('drive_file_versions', sa.Column('storage_key', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('drive_file_versions', sa.Column('created_by_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('drive_file_versions', sa.Column('checksum', sa.VARCHAR(length=128), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'drive_file_versions', type_='foreignkey') + op.drop_constraint(None, 'drive_file_versions', type_='foreignkey') + op.create_foreign_key(op.f('drive_file_versions_created_by_id_fkey'), 'drive_file_versions', 'users', ['created_by_id'], ['id']) + op.create_foreign_key(op.f('drive_file_versions_file_id_fkey'), 'drive_file_versions', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE') + op.alter_column('drive_file_versions', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('drive_file_versions', 'size', + existing_type=sa.BIGINT(), + nullable=False) + op.alter_column('drive_file_versions', 'version_number', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_file_versions', 'file_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('drive_file_versions', 'uploader_id') + op.drop_column('drive_file_versions', 'content_type') + op.drop_column('drive_file_versions', 's3_key') + op.add_column('drive_comments', sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('drive_comments', sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True)) + op.add_column('drive_comments', sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('drive_comments', sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'drive_comments', type_='foreignkey') + op.drop_constraint(None, 'drive_comments', type_='foreignkey') + op.create_foreign_key(op.f('drive_comments_author_id_fkey'), 'drive_comments', 'users', ['author_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(op.f('drive_comments_file_id_fkey'), 'drive_comments', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE') + op.alter_column('drive_comments', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_column('drive_comments', 'content') + op.drop_column('drive_comments', 'user_id') + op.add_column('drive_activities', sa.Column('ip_address', sa.VARCHAR(length=64), autoincrement=False, nullable=True)) + op.add_column('drive_activities', sa.Column('device_info', sa.TEXT(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'drive_activities', type_='foreignkey') + op.create_foreign_key(op.f('drive_activities_actor_id_fkey'), 'drive_activities', 'users', ['actor_id'], ['id'], ondelete='CASCADE') + op.alter_column('drive_activities', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('drive_activities', 'activity_type', + existing_type=sa.VARCHAR(length=50), + nullable=False) + op.alter_column('drive_activities', 'actor_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_activities', 'resource_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_activities', 'resource_type', + existing_type=sa.String(length=20), + type_=sa.VARCHAR(length=10), + nullable=False) + op.create_table('drive_acl', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('resource_type', sa.VARCHAR(length=10), autoincrement=False, nullable=False), + sa.Column('resource_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('subject_type', sa.VARCHAR(length=10), autoincrement=False, nullable=False), + sa.Column('subject_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('role', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('inherited_from', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('drive_acl_pkey')) + ) + op.drop_index(op.f('ix_vector_indices_user_id'), table_name='vector_indices') + op.drop_index(op.f('ix_vector_indices_id'), table_name='vector_indices') + op.drop_table('vector_indices') + op.drop_index(op.f('ix_vector_chunks_user_id'), table_name='vector_chunks') + op.drop_index(op.f('ix_vector_chunks_id'), table_name='vector_chunks') + op.drop_index(op.f('ix_vector_chunks_document_id'), table_name='vector_chunks') + op.drop_table('vector_chunks') + op.drop_index(op.f('ix_chatbot_documents_user_id'), table_name='chatbot_documents') + op.drop_index(op.f('ix_chatbot_documents_id'), table_name='chatbot_documents') + op.drop_index(op.f('ix_chatbot_documents_drive_file_id'), table_name='chatbot_documents') + op.drop_index(op.f('ix_chatbot_documents_document_id'), table_name='chatbot_documents') + op.drop_table('chatbot_documents') + op.drop_table('drive_acls') + # ### end Alembic commands ### diff --git a/alembic/versions/4a9dc50ce4af_drop_price_display.py b/alembic/versions/4a9dc50ce4af_drop_price_display.py new file mode 100644 index 0000000..e0d7b97 --- /dev/null +++ b/alembic/versions/4a9dc50ce4af_drop_price_display.py @@ -0,0 +1,33 @@ +"""drop_price_display + +Revision ID: 4a9dc50ce4af +Revises: 2b4c62c1cf35 +Create Date: 2026-08-20 11:40:33.072200 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4a9dc50ce4af' +down_revision: Union[str, None] = '2b4c62c1cf35' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute("UPDATE plans SET price_amount = CAST(SUBSTRING(price_display FROM '[\d.]+') AS NUMERIC) WHERE price_display IS NOT NULL AND price_display != 'Contact Us' AND price_amount IS NULL") + op.drop_column('plans', 'price_display') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('plans', sa.Column('price_display', sa.VARCHAR(length=50), autoincrement=False, nullable=True)) + op.execute("UPDATE plans SET price_display = '$' || price_amount WHERE price_amount IS NOT NULL") + op.execute("UPDATE plans SET price_display = 'Contact Us' WHERE price_amount IS NULL") + # ### end Alembic commands ### diff --git a/alembic/versions/522ee6d91431_add_drive_share_link_fields.py b/alembic/versions/522ee6d91431_add_drive_share_link_fields.py new file mode 100644 index 0000000..558950b --- /dev/null +++ b/alembic/versions/522ee6d91431_add_drive_share_link_fields.py @@ -0,0 +1,30 @@ +"""add_drive_share_link_fields + +Revision ID: 522ee6d91431 +Revises: 8d4dced54966 +Create Date: 2026-02-24 14:35:17.542820 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '522ee6d91431' +down_revision: Union[str, None] = '8d4dced54966' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_shares', sa.Column('is_active', sa.Boolean(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('drive_shares', 'is_active') + # ### end Alembic commands ### diff --git a/alembic/versions/540c970ff890_add_tenant_chat_token_quota.py b/alembic/versions/540c970ff890_add_tenant_chat_token_quota.py new file mode 100644 index 0000000..ad07c63 --- /dev/null +++ b/alembic/versions/540c970ff890_add_tenant_chat_token_quota.py @@ -0,0 +1,42 @@ +"""add_tenant_chat_token_quota + +Revision ID: 540c970ff890 +Revises: 7fc8dd61bb95 +Create Date: 2026-05-13 11:21:14.192033 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '540c970ff890' +down_revision: Union[str, None] = '7fc8dd61bb95' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Get current connection and inspector + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Check if columns already exist (defensive migration) + columns = [c['name'] for c in inspector.get_columns('tenants')] + + if 'chat_token_daily_limit' not in columns: + op.add_column('tenants', sa.Column('chat_token_daily_limit', sa.BigInteger(), nullable=False, server_default='1000000')) + + if 'chat_tokens_used_today' not in columns: + op.add_column('tenants', sa.Column('chat_tokens_used_today', sa.BigInteger(), nullable=False, server_default='0')) + + if 'chat_tokens_last_reset_at' not in columns: + op.add_column('tenants', sa.Column('chat_tokens_last_reset_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False)) + + +def downgrade() -> None: + op.drop_column('tenants', 'chat_tokens_last_reset_at') + op.drop_column('tenants', 'chat_tokens_used_today') + op.drop_column('tenants', 'chat_token_daily_limit') diff --git a/alembic/versions/56d090047e85_increase_file_size_constraints.py b/alembic/versions/56d090047e85_increase_file_size_constraints.py new file mode 100644 index 0000000..6247c9b --- /dev/null +++ b/alembic/versions/56d090047e85_increase_file_size_constraints.py @@ -0,0 +1,74 @@ +"""increase_file_size_constraints + +Revision ID: 56d090047e85 +Revises: j002_unlimited_tenant_quota +Create Date: 2026-03-26 15:14:47.276886 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '56d090047e85' +down_revision: Union[str, None] = 'j002_unlimited_tenant_quota' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('projects', schema=None) as batch_op: + # Drop old 10MB constraints + batch_op.drop_constraint('ck_project_markdown_max', type_='check') + batch_op.drop_constraint('ck_project_xml_max', type_='check') + batch_op.drop_constraint('ck_project_html_max', type_='check') + + # Add new 30MB constraints + batch_op.create_check_constraint('ck_project_markdown_max', 'octet_length(markdown_content) <= 31457280') + batch_op.create_check_constraint('ck_project_xml_max', 'octet_length(xml_content) <= 31457280') + batch_op.create_check_constraint('ck_project_html_max', 'octet_length(html_content) <= 31457280') + + + + with op.batch_alter_table('project_versions', schema=None) as batch_op: + # Drop old 10MB constraints + batch_op.drop_constraint('ck_version_markdown_max', type_='check') + batch_op.drop_constraint('ck_version_xml_max', type_='check') + batch_op.drop_constraint('ck_version_html_max', type_='check') + + # Add new 30MB constraints + batch_op.create_check_constraint('ck_version_markdown_max', 'octet_length(markdown_content) <= 31457280') + batch_op.create_check_constraint('ck_version_xml_max', 'octet_length(xml_content) <= 31457280') + batch_op.create_check_constraint('ck_version_html_max', 'octet_length(html_content) <= 31457280') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('project_versions', schema=None) as batch_op: + # Drop new 30MB constraints + batch_op.drop_constraint('ck_version_markdown_max', type_='check') + batch_op.drop_constraint('ck_version_xml_max', type_='check') + batch_op.drop_constraint('ck_version_html_max', type_='check') + + # Re-add old 10MB constraints + batch_op.create_check_constraint('ck_version_markdown_max', 'octet_length(markdown_content) <= 10485760') + batch_op.create_check_constraint('ck_version_xml_max', 'octet_length(xml_content) <= 10485760') + batch_op.create_check_constraint('ck_version_html_max', 'octet_length(html_content) <= 10485760') + + with op.batch_alter_table('projects', schema=None) as batch_op: + # Drop new 30MB constraints + batch_op.drop_constraint('ck_project_markdown_max', type_='check') + batch_op.drop_constraint('ck_project_xml_max', type_='check') + batch_op.drop_constraint('ck_project_html_max', type_='check') + + # Re-add old 10MB constraints + batch_op.create_check_constraint('ck_project_markdown_max', 'octet_length(markdown_content) <= 10485760') + batch_op.create_check_constraint('ck_project_xml_max', 'octet_length(xml_content) <= 10485760') + batch_op.create_check_constraint('ck_project_html_max', 'octet_length(html_content) <= 10485760') + + + # ### end Alembic commands ### diff --git a/alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py b/alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py new file mode 100644 index 0000000..3f81ded --- /dev/null +++ b/alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py @@ -0,0 +1,34 @@ +"""add_multiple_files_to_signing_request + +Revision ID: 611d6b99ad11 +Revises: d551c03861f4 +Create Date: 2026-07-14 18:00:05.273738 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '611d6b99ad11' +down_revision: Union[str, None] = 'd551c03861f4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('signing_requests', sa.Column('drive_file_ids', sa.JSON(), nullable=True)) + op.add_column('signing_requests', sa.Column('signed_file_ids', sa.JSON(), nullable=True)) + op.alter_column('signing_requests', 'drive_file_id', + existing_type=sa.INTEGER(), + nullable=True) + + +def downgrade() -> None: + op.alter_column('signing_requests', 'drive_file_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('signing_requests', 'signed_file_ids') + op.drop_column('signing_requests', 'drive_file_ids') diff --git a/alembic/versions/6c9cc3567866_merge_multiple_heads.py b/alembic/versions/6c9cc3567866_merge_multiple_heads.py new file mode 100644 index 0000000..309e12b --- /dev/null +++ b/alembic/versions/6c9cc3567866_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""merge_multiple_heads + +Revision ID: 6c9cc3567866 +Revises: 45c94ee15294, ff9052a97835 +Create Date: 2026-03-05 07:53:09.436096 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6c9cc3567866' +down_revision: Union[str, None] = ('45c94ee15294', 'ff9052a97835') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/73dff81eed72_add_tenant_contacts.py b/alembic/versions/73dff81eed72_add_tenant_contacts.py new file mode 100644 index 0000000..8da7cf2 --- /dev/null +++ b/alembic/versions/73dff81eed72_add_tenant_contacts.py @@ -0,0 +1,43 @@ +"""Add tenant contacts + +Revision ID: 73dff81eed72 +Revises: dfae38e4ede3 +Create Date: 2026-08-12 10:56:21.668158 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '73dff81eed72' +down_revision: Union[str, None] = 'dfae38e4ede3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tenant_contacts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'email', name='uq_tenant_contacts_tenant_id_email'), + ) + op.create_index(op.f('ix_tenant_contacts_email'), 'tenant_contacts', ['email'], unique=False) + op.create_index(op.f('ix_tenant_contacts_tenant_id'), 'tenant_contacts', ['tenant_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tenant_contacts_tenant_id'), table_name='tenant_contacts') + op.drop_index(op.f('ix_tenant_contacts_email'), table_name='tenant_contacts') + op.drop_table('tenant_contacts') + # ### end Alembic commands ### diff --git a/alembic/versions/765b053e3dcd_add_vector_index_checksum.py b/alembic/versions/765b053e3dcd_add_vector_index_checksum.py new file mode 100644 index 0000000..64fdc02 --- /dev/null +++ b/alembic/versions/765b053e3dcd_add_vector_index_checksum.py @@ -0,0 +1,30 @@ +"""add_vector_index_checksum + +Revision ID: 765b053e3dcd +Revises: be67927ab00d +Create Date: 2026-03-03 20:25:42.849543 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '765b053e3dcd' +down_revision: Union[str, None] = 'be67927ab00d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py b/alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py new file mode 100644 index 0000000..2e618d7 --- /dev/null +++ b/alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py @@ -0,0 +1,32 @@ +"""refactor_global_signature_config + +Revision ID: 7d7641cc1bb2 +Revises: b3a7c1e9f042 +Create Date: 2026-07-21 07:19:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7d7641cc1bb2' +down_revision: Union[str, None] = 'af57c3d9f1c2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('global_signature_configs', sa.Column('global_credentials_json', postgresql.JSON(astext_type=sa.Text()), nullable=True)) + op.add_column('global_signature_configs', sa.Column('enabled_providers', postgresql.JSON(astext_type=sa.Text()), server_default='["docuseal", "zoho_sign"]', nullable=False)) + op.drop_column('global_signature_configs', 'docuseal_credentials') + op.drop_column('global_signature_configs', 'zoho_credentials') + + +def downgrade() -> None: + op.add_column('global_signature_configs', sa.Column('zoho_credentials', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.add_column('global_signature_configs', sa.Column('docuseal_credentials', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_column('global_signature_configs', 'enabled_providers') + op.drop_column('global_signature_configs', 'global_credentials_json') diff --git a/alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py b/alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py new file mode 100644 index 0000000..974cd55 --- /dev/null +++ b/alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py @@ -0,0 +1,70 @@ +"""Add chat token usage and quota tables + +Revision ID: 7fc8dd61bb95 +Revises: z002_pref_lang +Create Date: 2026-05-12 18:38:23.935092 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7fc8dd61bb95' +down_revision: Union[str, None] = 'z002_pref_lang' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Get current connection and inspector + conn = op.get_bind() + inspector = sa.inspect(conn) + + # 1. Create chat_token_usage if missing + if not inspector.has_table('chat_token_usage'): + op.create_table('chat_token_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=True), + sa.Column('prompt_tokens', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('completion_tokens', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('total_tokens', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('model_name', sa.String(length=100), nullable=False), + sa.Column('cost_usd', sa.DECIMAL(precision=12, scale=8), nullable=False, server_default='0.0'), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('phase', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_token_usage_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_session_id'), ['session_id'], unique=False) + + # 2. Create chat_token_quota if missing + if not inspector.has_table('chat_token_quota'): + op.create_table('chat_token_quota', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('daily_limit', sa.BigInteger(), nullable=False), + sa.Column('tokens_used_today', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('last_reset_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('total_tokens_ever', sa.BigInteger(), nullable=False, server_default='0'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id') + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + if inspector.has_table('chat_token_quota'): + op.drop_table('chat_token_quota') + + if inspector.has_table('chat_token_usage'): + op.drop_table('chat_token_usage') diff --git a/alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py b/alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py new file mode 100644 index 0000000..31a39ef --- /dev/null +++ b/alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py @@ -0,0 +1,30 @@ +"""add_is_popular_to_plans + +Revision ID: 80be2c61e7fc +Revises: 4a9dc50ce4af +Create Date: 2026-08-20 12:47:53.790237 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '80be2c61e7fc' +down_revision: Union[str, None] = '4a9dc50ce4af' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('plans', sa.Column('is_popular', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('plans', 'is_popular') + # ### end Alembic commands ### diff --git a/alembic/versions/823a486fd287_add_created_at_to_drive_stars.py b/alembic/versions/823a486fd287_add_created_at_to_drive_stars.py new file mode 100644 index 0000000..94fd0a3 --- /dev/null +++ b/alembic/versions/823a486fd287_add_created_at_to_drive_stars.py @@ -0,0 +1,36 @@ +"""add created_at to drive_stars + +Revision ID: 823a486fd287 +Revises: 88717d544229 +Create Date: 2026-03-27 23:24:47.417678 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '823a486fd287' +down_revision: Union[str, None] = '88717d544229' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add column as nullable first + op.add_column('drive_stars', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) + + # Update existing stars to current time so they aren't null + op.execute("UPDATE drive_stars SET created_at = NOW() WHERE created_at IS NULL") + + # Make column non-nullable with server default + op.alter_column('drive_stars', 'created_at', + existing_type=sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text('now()')) + + +def downgrade() -> None: + op.drop_column('drive_stars', 'created_at') diff --git a/alembic/versions/8736ab6bd5af_add_notifications_table.py b/alembic/versions/8736ab6bd5af_add_notifications_table.py new file mode 100644 index 0000000..9b782fb --- /dev/null +++ b/alembic/versions/8736ab6bd5af_add_notifications_table.py @@ -0,0 +1,41 @@ +"""add_notifications_table + +Revision ID: 8736ab6bd5af +Revises: 522ee6d91431 +Create Date: 2026-02-24 15:25:34.045912 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8736ab6bd5af' +down_revision: Union[str, None] = '522ee6d91431' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'notifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('notif_type', sa.String(length=50), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('resource_type', sa.String(length=20), nullable=True), + sa.Column('resource_id', sa.Integer(), nullable=True), + sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_notifications_user_id', 'notifications', ['user_id'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_notifications_user_id', table_name='notifications') + op.drop_table('notifications') diff --git a/alembic/versions/88717d544229_add_is_default_to_roles.py b/alembic/versions/88717d544229_add_is_default_to_roles.py new file mode 100644 index 0000000..32306f8 --- /dev/null +++ b/alembic/versions/88717d544229_add_is_default_to_roles.py @@ -0,0 +1,34 @@ +"""add is_default to roles + +Revision ID: 88717d544229 +Revises: 56d090047e85 +Create Date: 2026-03-27 14:08:16.581384 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '88717d544229' +down_revision: Union[str, None] = '56d090047e85' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.drop_column('is_default') + + # ### end Alembic commands ### diff --git a/alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py b/alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py new file mode 100644 index 0000000..7c47a5f --- /dev/null +++ b/alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py @@ -0,0 +1,36 @@ +"""Limit DriveComment body to 10000 chars + +Revision ID: 89508c176437 +Revises: ff8052a97834 +Create Date: 2026-03-03 22:02:03.251490 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '89508c176437' +down_revision: Union[str, None] = 'ff8052a97834' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('drive_comments', 'body', + existing_type=sa.TEXT(), + type_=sa.String(length=10000), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('drive_comments', 'body', + existing_type=sa.String(length=10000), + type_=sa.TEXT(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/8d4dced54966_add_is_root_column.py b/alembic/versions/8d4dced54966_add_is_root_column.py new file mode 100644 index 0000000..d9bcdd7 --- /dev/null +++ b/alembic/versions/8d4dced54966_add_is_root_column.py @@ -0,0 +1,59 @@ +"""add is_root column + +Revision ID: 8d4dced54966 +Revises: cfbee4f91fc2 +Create Date: 2026-02-24 11:15:51.402384 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8d4dced54966' +down_revision: Union[str, None] = 'cfbee4f91fc2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_comments', sa.Column('author_id', sa.Integer(), nullable=True)) + op.add_column('drive_comments', sa.Column('body', sa.Text(), nullable=True)) + + # Seeding existing comments if any (though usually clean) + op.execute("UPDATE drive_comments SET author_id = user_id WHERE author_id IS NULL") + op.execute("UPDATE drive_comments SET body = content WHERE body IS NULL") + + op.alter_column('drive_comments', 'author_id', nullable=False) + op.alter_column('drive_comments', 'body', nullable=False) + + op.add_column('drive_comments', sa.Column('anchor', sa.Text(), nullable=True)) + op.add_column('drive_comments', sa.Column('resolved_at', sa.DateTime(), nullable=True)) + op.drop_constraint(op.f('drive_comments_user_id_fkey'), 'drive_comments', type_='foreignkey') + op.create_foreign_key(None, 'drive_comments', 'users', ['author_id'], ['id']) + op.drop_column('drive_comments', 'user_id') + op.drop_column('drive_comments', 'content') + + # 🆕 Add is_root safely + op.add_column('drive_folders', sa.Column('is_root', sa.Boolean(), nullable=True)) + op.execute("UPDATE drive_folders SET is_root = false WHERE is_root IS NULL") + op.execute("UPDATE drive_folders SET is_root = true WHERE parent_id IS NULL") + op.alter_column('drive_folders', 'is_root', nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('drive_folders', 'is_root') + op.add_column('drive_comments', sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('drive_comments', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'drive_comments', type_='foreignkey') + op.create_foreign_key(op.f('drive_comments_user_id_fkey'), 'drive_comments', 'users', ['user_id'], ['id']) + op.drop_column('drive_comments', 'resolved_at') + op.drop_column('drive_comments', 'anchor') + op.drop_column('drive_comments', 'body') + op.drop_column('drive_comments', 'author_id') + # ### end Alembic commands ### diff --git a/alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py b/alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py new file mode 100644 index 0000000..c51bd43 --- /dev/null +++ b/alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py @@ -0,0 +1,34 @@ +"""Add plan_roles and is_system to roles + +Revision ID: 905726af73c8 +Revises: 80be2c61e7fc +Create Date: 2026-08-20 19:17:20.939383 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '905726af73c8' +down_revision: Union[str, None] = '80be2c61e7fc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('plan_roles', + sa.Column('plan_id', sa.UUID(), nullable=False), + sa.Column('role_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('plan_id', 'role_id') + ) + op.add_column('roles', sa.Column('is_system', sa.Boolean(), nullable=False, server_default='false')) + + +def downgrade() -> None: + op.drop_column('roles', 'is_system') + op.drop_table('plan_roles') diff --git a/alembic/versions/9557c7307575_tenant_storage_config_and_provider.py b/alembic/versions/9557c7307575_tenant_storage_config_and_provider.py new file mode 100644 index 0000000..6469cd6 --- /dev/null +++ b/alembic/versions/9557c7307575_tenant_storage_config_and_provider.py @@ -0,0 +1,415 @@ +"""tenant_storage_config_and_provider + +Revision ID: 9557c7307575 +Revises: h004_signing_forensic_v2 +Create Date: 2026-03-16 13:00:58.602085 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9557c7307575' +down_revision: Union[str, None] = 'h004_signing_forensic_v2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute("UPDATE drive_files SET size = 0 WHERE size IS NULL") + op.execute("UPDATE drive_files SET is_starred = false WHERE is_starred IS NULL") + op.execute("UPDATE drive_files SET is_trashed = false WHERE is_trashed IS NULL") + op.execute("UPDATE drive_files SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE drive_files SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE drive_folders SET is_starred = false WHERE is_starred IS NULL") + op.execute("UPDATE drive_folders SET is_trashed = false WHERE is_trashed IS NULL") + op.execute("UPDATE drive_folders SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE drive_folders SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE drive_shares SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.execute("UPDATE project_versions SET is_current = false WHERE is_current IS NULL") + op.execute("UPDATE project_versions SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.execute("UPDATE projects SET status = 'processing' WHERE status IS NULL") + op.execute("UPDATE projects SET file_type = 'PDF' WHERE file_type IS NULL") + op.execute("UPDATE projects SET output_format = 'EPUB' WHERE output_format IS NULL") + op.execute("UPDATE projects SET progress = 0 WHERE progress IS NULL") + op.execute("UPDATE projects SET current_step = 'upload' WHERE current_step IS NULL") + op.execute("UPDATE projects SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE signing_requests SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.execute("UPDATE tenants SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE tenants SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE users SET subscription = 'free' WHERE subscription IS NULL") + op.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.create_table('tenant_storage_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('storage_provider', sa.String(length=50), nullable=False), + sa.Column('b2_key_id', sa.String(length=255), nullable=True), + sa.Column('b2_application_key', sa.String(length=512), nullable=True), + sa.Column('b2_bucket_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_storage_configs_tenant_id ON tenant_storage_configs (tenant_id)") + + with op.batch_alter_table('accesses', schema=None) as batch_op: + batch_op.alter_column('name', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=255), + existing_nullable=False) + op.execute("DROP INDEX IF EXISTS ix_accesses_name") + batch_op.drop_constraint(batch_op.f('uq_accesses_access_code'), type_='unique') + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_accesses_access_code ON accesses (access_code)") + op.execute("CREATE INDEX IF NOT EXISTS ix_accesses_category ON accesses (category)") + op.execute("ALTER TABLE accesses DROP COLUMN IF EXISTS description") + + op.execute("DROP INDEX IF EXISTS idx_drive_activities_actor") + op.execute("DROP INDEX IF EXISTS idx_drive_activities_res") + op.execute("ALTER TABLE drive_activities DROP COLUMN IF EXISTS metadata") + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.add_column(sa.Column('storage_provider', sa.String(length=50), nullable=False, server_default='minio')) + op.execute("DROP INDEX IF EXISTS idx_drive_versions_file") + + with op.batch_alter_table('drive_files', schema=None) as batch_op: + batch_op.alter_column('size', + existing_type=sa.BIGINT(), + nullable=False, + existing_server_default=sa.text('0')) + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.execute("DROP INDEX IF EXISTS idx_drive_files_folder") + op.execute("DROP INDEX IF EXISTS idx_drive_files_owner") + op.execute("DROP INDEX IF EXISTS idx_drive_files_trashed") + + with op.batch_alter_table('drive_folders', schema=None) as batch_op: + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.execute("DROP INDEX IF EXISTS idx_drive_folders_owner") + op.execute("DROP INDEX IF EXISTS idx_drive_folders_parent") + op.execute("DROP INDEX IF EXISTS idx_drive_folders_trashed") + + with op.batch_alter_table('drive_shares', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.execute("DROP INDEX IF EXISTS idx_drive_shares_resource") + op.execute("DROP INDEX IF EXISTS uq_drive_shares_link") + batch_op.create_unique_constraint(None, ['link_token']) + batch_op.drop_constraint(batch_op.f('drive_shares_created_by_id_fkey'), type_='foreignkey') + batch_op.create_foreign_key(None, 'users', ['created_by_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('project_versions', schema=None) as batch_op: + batch_op.alter_column('is_current', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.execute("DROP INDEX IF EXISTS idx_project_versions_created_by") + op.execute("DROP INDEX IF EXISTS idx_project_versions_project_id") + + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'processing'::character varying")) + batch_op.alter_column('file_type', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'PDF'::character varying")) + batch_op.alter_column('output_format', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'EPUB'::character varying")) + batch_op.alter_column('progress', + existing_type=sa.INTEGER(), + nullable=False, + existing_server_default=sa.text('0')) + batch_op.alter_column('current_step', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'upload'::character varying")) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.execute("DROP INDEX IF EXISTS idx_projects_session_id") + op.execute("DROP INDEX IF EXISTS idx_projects_user_id") + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('system_configurations', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_system_configurations_config_key'), type_='unique') + + with op.batch_alter_table('tenant_smtp_configs', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_tenant_smtp_configs_tenant_id'), type_='unique') + op.execute("DROP INDEX IF EXISTS ix_tenant_smtp_configs_tenant_id") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_smtp_configs_tenant_id ON tenant_smtp_configs (tenant_id)") + + with op.batch_alter_table('tenants', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + batch_op.drop_constraint(batch_op.f('tenants_slug_key'), type_='unique') + op.execute("CREATE INDEX IF NOT EXISTS ix_tenants_id ON tenants (id)") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenants_slug ON tenants (slug)") + + with op.batch_alter_table('user_files', schema=None) as batch_op: + batch_op.add_column(sa.Column('storage_provider', sa.String(length=50), nullable=False, server_default='minio')) + op.execute("DROP INDEX IF EXISTS idx_user_files_s3_key") + op.execute("DROP INDEX IF EXISTS idx_user_files_user") + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('subscription', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'free'::character varying")) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.execute("DROP INDEX IF EXISTS idx_users_email") + op.execute("CREATE INDEX IF NOT EXISTS ix_users_email ON users (email)") + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_users_email") + op.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)") + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('subscription', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'free'::character varying")) + + with op.batch_alter_table('user_files', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_user_files_user ON user_files (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_user_files_s3_key ON user_files (s3_key)") + op.execute("ALTER TABLE user_files DROP COLUMN IF EXISTS storage_provider") + + with op.batch_alter_table('tenants', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_tenants_slug") + op.execute("DROP INDEX IF EXISTS ix_tenants_id") + batch_op.create_unique_constraint(batch_op.f('tenants_slug_key'), ['slug'], postgresql_nulls_not_distinct=False) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('tenant_smtp_configs', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_tenant_smtp_configs_tenant_id") + op.execute("CREATE INDEX IF NOT EXISTS ix_tenant_smtp_configs_tenant_id ON tenant_smtp_configs (tenant_id)") + batch_op.create_unique_constraint(batch_op.f('uq_tenant_smtp_configs_tenant_id'), ['tenant_id'], postgresql_nulls_not_distinct=False) + + with op.batch_alter_table('system_configurations', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_system_configurations_config_key'), ['config_key'], postgresql_nulls_not_distinct=False) + + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + op.execute("CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_projects_session_id ON projects (session_id)") + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('current_step', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'upload'::character varying")) + batch_op.alter_column('progress', + existing_type=sa.INTEGER(), + nullable=True, + existing_server_default=sa.text('0')) + batch_op.alter_column('output_format', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'EPUB'::character varying")) + batch_op.alter_column('file_type', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'PDF'::character varying")) + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'processing'::character varying")) + + with op.batch_alter_table('project_versions', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_project_versions_project_id ON project_versions (project_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_project_versions_created_by ON project_versions (created_by_id)") + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('is_current', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + + with op.batch_alter_table('drive_shares', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('drive_shares_created_by_id_fkey'), 'users', ['created_by_id'], ['id']) + batch_op.drop_constraint(None, type_='unique') + op.execute("CREATE INDEX IF NOT EXISTS uq_drive_shares_link ON drive_shares (link_token)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_shares_resource ON drive_shares (resource_type,resource_id)") + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + + with op.batch_alter_table('drive_folders', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_trashed ON drive_folders (is_trashed)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_parent ON drive_folders (parent_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_owner ON drive_folders (owner_id)") + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + + with op.batch_alter_table('drive_files', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_trashed ON drive_files (is_trashed)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_owner ON drive_files (owner_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_folder ON drive_files (folder_id)") + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + batch_op.alter_column('size', + existing_type=sa.BIGINT(), + nullable=True, + existing_server_default=sa.text('0')) + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_versions_file ON drive_file_versions (file_id)") + op.execute("ALTER TABLE drive_file_versions DROP COLUMN IF EXISTS storage_provider") + + with op.batch_alter_table('drive_activities', schema=None) as batch_op: + batch_op.add_column(sa.Column('metadata', sa.TEXT(), autoincrement=False, nullable=True)) + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_activities_res ON drive_activities (resource_type,resource_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_activities_actor ON drive_activities (actor_id)") + + with op.batch_alter_table('accesses', schema=None) as batch_op: + batch_op.add_column(sa.Column('description', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.execute("DROP INDEX IF EXISTS ix_accesses_category") + op.execute("DROP INDEX IF EXISTS ix_accesses_access_code") + batch_op.create_unique_constraint(batch_op.f('uq_accesses_access_code'), ['access_code'], postgresql_nulls_not_distinct=False) + op.execute("CREATE INDEX IF NOT EXISTS ix_accesses_name ON accesses (name)") + batch_op.alter_column('name', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_tenant_storage_configs_tenant_id") + + op.execute("DROP TABLE IF EXISTS tenant_storage_configs") + # ### end Alembic commands ### diff --git a/alembic/versions/9a6db38b86a6_make_drivecomment_generic.py b/alembic/versions/9a6db38b86a6_make_drivecomment_generic.py new file mode 100644 index 0000000..6b03ba2 --- /dev/null +++ b/alembic/versions/9a6db38b86a6_make_drivecomment_generic.py @@ -0,0 +1,57 @@ +"""Make DriveComment generic + +Revision ID: 9a6db38b86a6 +Revises: 41bed7eb6ed9 +Create Date: 2026-02-27 14:32:05.071480 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9a6db38b86a6' +down_revision: Union[str, None] = '41bed7eb6ed9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Add columns as nullable first + op.add_column('drive_comments', sa.Column('resource_type', sa.String(length=20), nullable=True)) + op.add_column('drive_comments', sa.Column('resource_id', sa.Integer(), nullable=True)) + + # 2. Populate data from existing file_id safely + # Use WHERE file_id IS NOT NULL to avoid unnecessary or invalid updates + op.execute("UPDATE drive_comments SET resource_type = 'file', resource_id = file_id WHERE file_id IS NOT NULL") + + # 3. Set to NOT NULL now that data is populated + op.alter_column('drive_comments', 'resource_type', nullable=False) + op.alter_column('drive_comments', 'resource_id', nullable=False) + + # 4. Cleanup old column and constraint + with op.batch_alter_table('drive_comments') as batch_op: + batch_op.drop_column('file_id') + + +def downgrade() -> None: + # 1. Add column back as nullable first + op.add_column('drive_comments', sa.Column('file_id', sa.Integer(), nullable=True)) + + # 2. Restore data from generic columns back to file_id + # Only migrate records that were originally or are currently 'file' types + op.execute("UPDATE drive_comments SET file_id = resource_id WHERE resource_type = 'file'") + + # 3. Delete or handle non-file comments that cannot exist in the old schema + # Alternatively, keep file_id as nullable if preferred, but here we restore original state + op.execute("DELETE FROM drive_comments WHERE resource_type != 'file'") + + # 4. Restore constraints + op.alter_column('drive_comments', 'file_id', nullable=False) + op.create_foreign_key('drive_comments_file_id_fkey', 'drive_comments', 'drive_files', ['file_id'], ['id']) + + # 5. Cleanup generic columns + op.drop_column('drive_comments', 'resource_id') + op.drop_column('drive_comments', 'resource_type') diff --git a/alembic/versions/9af99fc7ca0f_merge_heads.py b/alembic/versions/9af99fc7ca0f_merge_heads.py new file mode 100644 index 0000000..d4b7c0f --- /dev/null +++ b/alembic/versions/9af99fc7ca0f_merge_heads.py @@ -0,0 +1,26 @@ +"""merge heads + +Revision ID: 9af99fc7ca0f +Revises: 15f935213b22, cba70ab35793, v3_extraction +Create Date: 2026-04-18 14:22:43.985119 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9af99fc7ca0f' +down_revision: Union[str, None] = ('15f935213b22', 'cba70ab35793', 'v3_extraction') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/9c49fe120ffb_add_device_and_session_tables.py b/alembic/versions/9c49fe120ffb_add_device_and_session_tables.py new file mode 100644 index 0000000..9a4dd60 --- /dev/null +++ b/alembic/versions/9c49fe120ffb_add_device_and_session_tables.py @@ -0,0 +1,79 @@ +"""Add device and session tables + +Revision ID: 9c49fe120ffb +Revises: z004_add_global_workflows +Create Date: 2026-08-06 15:27:12.015009 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9c49fe120ffb' +down_revision: Union[str, None] = 'z004_add_global_workflows' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('devices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('fingerprint', sa.String(length=255), nullable=False), + sa.Column('browser', sa.String(length=100), nullable=True), + sa.Column('os', sa.String(length=100), nullable=True), + sa.Column('device_type', sa.String(length=50), nullable=True), + sa.Column('user_agent', sa.String(length=512), nullable=True), + sa.Column('ip', sa.String(length=50), nullable=True), + sa.Column('country', sa.String(length=100), nullable=True), + sa.Column('state', sa.String(length=100), nullable=True), + sa.Column('city', sa.String(length=100), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('first_login', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_login', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_ip', sa.String(length=50), nullable=True), + sa.Column('is_blocked', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_devices_fingerprint'), 'devices', ['fingerprint'], unique=False) + op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) + op.create_table('sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('device_id', sa.Integer(), nullable=True), + sa.Column('refresh_token_hash', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('last_activity', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_by', sa.String(length=50), nullable=True), + sa.Column('revoked_by_user_id', sa.Integer(), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['revoked_by_user_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sessions_device_id'), 'sessions', ['device_id'], unique=False) + op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions') + op.drop_index(op.f('ix_sessions_device_id'), table_name='sessions') + op.drop_table('sessions') + op.drop_index(op.f('ix_devices_user_id'), table_name='devices') + op.drop_index(op.f('ix_devices_fingerprint'), table_name='devices') + op.drop_table('devices') + # ### end Alembic commands ### diff --git a/alembic/versions/9c4a95ac6726_add_saasrolemapping.py b/alembic/versions/9c4a95ac6726_add_saasrolemapping.py new file mode 100644 index 0000000..8365943 --- /dev/null +++ b/alembic/versions/9c4a95ac6726_add_saasrolemapping.py @@ -0,0 +1,45 @@ +"""Add SaaSRoleMapping + +Revision ID: 9c4a95ac6726 +Revises: 905726af73c8 +Create Date: 2026-08-28 15:36:54.084287 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9c4a95ac6726' +down_revision: Union[str, None] = '905726af73c8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('saas_role_mappings', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('saas_role_id', sa.String(), nullable=False), + sa.Column('docqube_role_id', sa.UUID(), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['docqube_role_id'], ['roles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_saas_role_mappings_id'), 'saas_role_mappings', ['id'], unique=False) + op.create_index(op.f('ix_saas_role_mappings_saas_role_id'), 'saas_role_mappings', ['saas_role_id'], unique=True) + op.execute("ALTER TABLE tenant_contacts DROP CONSTRAINT IF EXISTS uq_tenant_contacts_tenant_id_email") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('uq_tenant_contacts_tenant_id_email', 'tenant_contacts', ['tenant_id', 'email']) + op.drop_index(op.f('ix_saas_role_mappings_saas_role_id'), table_name='saas_role_mappings') + op.drop_index(op.f('ix_saas_role_mappings_id'), table_name='saas_role_mappings') + op.drop_table('saas_role_mappings') + # ### end Alembic commands ### diff --git a/alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py b/alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py new file mode 100644 index 0000000..cc03f5f --- /dev/null +++ b/alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py @@ -0,0 +1,30 @@ +"""Limit ChatbotDocument file_path and filename to 512 chars + +Revision ID: 9f6521a38bf3 +Revises: 3aa7d970b8c2 +Create Date: 2026-03-03 22:12:00.390551 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9f6521a38bf3' +down_revision: Union[str, None] = '3aa7d970b8c2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py b/alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py new file mode 100644 index 0000000..f81d5da --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py @@ -0,0 +1,90 @@ +"""Add RBAC tables (accesses, roles, role_accesses) and role_id to users + +Revision ID: a1b2c3d4e5f6 +Revises: ff9052a97835 +Create Date: 2026-03-05 11:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = ("6c9cc3567866", "ff9052a97835") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create accesses table + op.execute( + """ + CREATE TABLE accesses ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL + ); + """ + ) + op.execute("CREATE INDEX ix_accesses_name ON accesses (name);") + + # 2. Create roles table (tenant_id nullable = system-wide role) + op.execute( + """ + CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + CONSTRAINT uq_role_tenant_name UNIQUE (tenant_id, name) + ); + """ + ) + op.execute("CREATE INDEX ix_roles_tenant_id ON roles (tenant_id);") + + # 3. Create role_accesses join table (composite PK) + op.execute( + """ + CREATE TABLE role_accesses ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + access_id INTEGER NOT NULL REFERENCES accesses(id) ON DELETE CASCADE, + CONSTRAINT pk_role_accesses PRIMARY KEY (role_id, access_id) + ); + """ + ) + + # 4. Add role_id to users (nullable FK, SET NULL on delete) + op.execute( + """ + ALTER TABLE users + ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL; + """ + ) + op.execute("CREATE INDEX ix_users_role_id ON users (role_id);") + + + +def downgrade() -> None: + # Reverse in dependency order + + # Remove role_id from users + op.execute("DROP INDEX IF EXISTS ix_users_role_id;") + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS role_id;") + + # Drop join table + op.execute("DROP TABLE IF EXISTS role_accesses;") + + # Drop roles table (seed data goes with it) + op.execute("DROP INDEX IF EXISTS ix_roles_tenant_id;") + op.execute("DROP TABLE IF EXISTS roles;") + + # Drop accesses table + op.execute("DROP INDEX IF EXISTS ix_accesses_name;") + op.execute("DROP TABLE IF EXISTS accesses;") diff --git a/alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py b/alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py new file mode 100644 index 0000000..5dcf1a5 --- /dev/null +++ b/alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py @@ -0,0 +1,75 @@ +"""Update Access model with hierarchical permission support + +Revision ID: a2b3c4d5e6f7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-03-05 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a2b3c4d5e6f7' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # FIRST: Drop any existing unique constraint on `name` (constraints may be named differently depending on DB) + op.execute("ALTER TABLE accesses DROP CONSTRAINT IF EXISTS uq_accesses_name CASCADE") + op.execute("ALTER TABLE accesses DROP CONSTRAINT IF EXISTS accesses_name_key CASCADE") + # also drop postgresql-generated index if it survived + op.execute("DROP INDEX IF EXISTS accesses_name_key") + + # Add new columns to accesses table (all nullable first) + op.add_column('accesses', sa.Column('access_code', sa.String(255), nullable=True)) + op.add_column('accesses', sa.Column('category', sa.String(100), nullable=True)) + op.add_column('accesses', sa.Column('parent_id', sa.Integer(), nullable=True)) + + # Create indexes on new columns + op.create_index('ix_access_code', 'accesses', ['access_code']) + op.create_index('ix_category', 'accesses', ['category']) + op.create_index('ix_accesses_parent_id', 'accesses', ['parent_id']) + + # Set default values from existing 'name' field + op.execute(""" + UPDATE accesses + SET access_code = LOWER(REPLACE(name, ' ', '_')), + category = 'General' + WHERE access_code IS NULL + """) + + # Now make columns NOT NULL + op.alter_column('accesses', 'access_code', existing_type=sa.String(255), nullable=False) + op.alter_column('accesses', 'category', existing_type=sa.String(100), nullable=False) + + # Create unique constraint on access_code + op.create_unique_constraint('uq_accesses_access_code', 'accesses', ['access_code']) + + # Create foreign key for parent_id + op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE') + + +def downgrade() -> None: + # Drop foreign key + op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey') + + # Drop unique constraint + op.drop_constraint('uq_accesses_access_code', 'accesses', type_='unique') + + # Drop indexes + op.drop_index('ix_accesses_parent_id', 'accesses') + op.drop_index('ix_category', 'accesses') + op.drop_index('ix_access_code', 'accesses') + + # Drop columns + op.drop_column('accesses', 'parent_id') + op.drop_column('accesses', 'category') + op.drop_column('accesses', 'access_code') + + # Re-add unique constraint on 'name' + op.create_unique_constraint('uq_accesses_name', 'accesses', ['name']) + + diff --git a/alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py b/alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py new file mode 100644 index 0000000..ee2d947 --- /dev/null +++ b/alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py @@ -0,0 +1,34 @@ +"""Remove redundant id column from user_storage_usage + +Revision ID: a902935ea69e +Revises: 0e1a5f2cdea9 +Create Date: 2026-03-02 10:53:47.556155 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a902935ea69e' +down_revision: Union[str, None] = '0e1a5f2cdea9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_user_storage_usage_id', table_name='user_storage_usage') + op.drop_constraint('user_storage_usage_user_id_key', 'user_storage_usage', type_='unique') + op.drop_column('user_storage_usage', 'id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_storage_usage', sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.create_unique_constraint('user_storage_usage_user_id_key', 'user_storage_usage', ['user_id'], postgresql_nulls_not_distinct=False) + op.create_index('ix_user_storage_usage_id', 'user_storage_usage', ['id'], unique=False) + # ### end Alembic commands ### diff --git a/alembic/versions/af57c3d9f1c2_add_signature_config_tables.py b/alembic/versions/af57c3d9f1c2_add_signature_config_tables.py new file mode 100644 index 0000000..92e64c6 --- /dev/null +++ b/alembic/versions/af57c3d9f1c2_add_signature_config_tables.py @@ -0,0 +1,64 @@ +"""add signature config tables + +Revision ID: af57c3d9f1c2 +Revises: daf45366d608 +Create Date: 2026-07-20 18:53:11.916379 + +""" +from typing import Sequence, Union +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'af57c3d9f1c2' +down_revision: Union[str, None] = 'daf45366d608' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'global_signature_configs', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('active_provider', sa.String(length=50), nullable=False, server_default='zoho_sign'), + sa.Column('docuseal_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('zoho_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL') + ) + + op.execute( + "INSERT INTO global_signature_configs (id, active_provider, created_at, updated_at) " + "VALUES (gen_random_uuid(), 'zoho_sign', now(), now())" + ) + + op.create_table( + 'tenant_signature_configs', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('use_custom_credentials', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('custom_provider', sa.String(length=50), nullable=False, server_default='docuseal'), + sa.Column('custom_credentials_json', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL') + ) + op.create_index(op.f('ix_tenant_signature_configs_tenant_id'), 'tenant_signature_configs', ['tenant_id'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_tenant_signature_configs_tenant_id'), table_name='tenant_signature_configs') + op.drop_table('tenant_signature_configs') + op.drop_table('global_signature_configs') diff --git a/alembic/versions/b029d2f5a0c7_add_signature_id.py b/alembic/versions/b029d2f5a0c7_add_signature_id.py new file mode 100644 index 0000000..3ef076b --- /dev/null +++ b/alembic/versions/b029d2f5a0c7_add_signature_id.py @@ -0,0 +1,36 @@ +"""add_signature_id + +Revision ID: b029d2f5a0c7 +Revises: fb6810c36ffb +Create Date: 2026-04-14 14:59:09.399640 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b029d2f5a0c7' +down_revision: Union[str, None] = 'fb6810c36ffb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.add_column(sa.Column('signature_id', sa.String(length=128), nullable=True)) + batch_op.create_index(batch_op.f('ix_signing_requests_signature_id'), ['signature_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signing_requests_signature_id')) + batch_op.drop_column('signature_id') + + # ### end Alembic commands ### diff --git a/alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py b/alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py new file mode 100644 index 0000000..50509b2 --- /dev/null +++ b/alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py @@ -0,0 +1,131 @@ +"""Add persistent chat history tables + +Revision ID: b0a09cd13348 +Revises: w001_versioning_minor +Create Date: 2026-05-06 16:38:12.054824 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'b0a09cd13348' +down_revision: Union[str, None] = 'w001_versioning_minor' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + from sqlalchemy.engine.reflection import Inspector + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + + if not inspector.has_table('activity_logs'): + op.create_table('activity_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('user_name', sa.String(length=255), nullable=True), + sa.Column('user_email', sa.String(length=255), nullable=True), + sa.Column('module', sa.String(length=50), nullable=False), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('target_id', sa.String(length=255), nullable=False), + sa.Column('target_type', sa.String(length=50), nullable=False), + sa.Column('target_name', sa.String(length=512), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('ip_address', sa.String(length=64), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('request_id', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('activity_logs', schema=None) as batch_op: + batch_op.create_index('ix_activity_logs_module_action', ['module', 'action'], unique=False) + batch_op.create_index('ix_activity_logs_target_id', ['target_id'], unique=False) + batch_op.create_index('ix_activity_logs_tenant_created_at', ['tenant_id', 'created_at'], unique=False) + batch_op.create_index('ix_activity_logs_user_created_at', ['user_id', 'created_at'], unique=False) + + if not inspector.has_table('chat_sessions'): + op.create_table('chat_sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('chatbot_document_id', sa.Integer(), nullable=True), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=False), + sa.ForeignKeyConstraint(['chatbot_document_id'], ['chatbot_documents.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('chat_sessions', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_sessions_chatbot_document_id'), ['chatbot_document_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_sessions_session_id'), ['session_id'], unique=True) + batch_op.create_index(batch_op.f('ix_chat_sessions_tenant_id'), ['tenant_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_sessions_user_id'), ['user_id'], unique=False) + + if not inspector.has_table('chat_messages'): + op.create_table('chat_messages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('message_id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('token_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('message_id') + ) + with op.batch_alter_table('chat_messages', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_messages_session_id'), ['session_id'], unique=False) + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + # Check if index exists before dropping + indexes = [idx['name'] for idx in inspector.get_indexes('drive_file_versions')] + if 'ix_drive_file_versions_file_major_minor' in indexes: + batch_op.drop_index('ix_drive_file_versions_file_major_minor') + + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + # Check if index exists before dropping + indexes = [idx['name'] for idx in inspector.get_indexes('signature_imprints')] + if 'ix_signature_imprints_version_id' in indexes: + batch_op.drop_index('ix_signature_imprints_version_id') + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + batch_op.create_index('ix_signature_imprints_version_id', ['drive_file_version_id'], unique=False) + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.create_index('ix_drive_file_versions_file_major_minor', ['file_id', 'version_number', 'minor_version'], unique=False) + + with op.batch_alter_table('chat_messages', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_chat_messages_session_id')) + + op.drop_table('chat_messages') + with op.batch_alter_table('chat_sessions', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_chat_sessions_user_id')) + batch_op.drop_index(batch_op.f('ix_chat_sessions_tenant_id')) + batch_op.drop_index(batch_op.f('ix_chat_sessions_session_id')) + batch_op.drop_index(batch_op.f('ix_chat_sessions_chatbot_document_id')) + + op.drop_table('chat_sessions') + with op.batch_alter_table('activity_logs', schema=None) as batch_op: + batch_op.drop_index('ix_activity_logs_user_created_at') + batch_op.drop_index('ix_activity_logs_tenant_created_at') + batch_op.drop_index('ix_activity_logs_target_id') + batch_op.drop_index('ix_activity_logs_module_action') + + op.drop_table('activity_logs') + # ### end Alembic commands ### diff --git a/alembic/versions/b1_0_explicit_superadmin.py b/alembic/versions/b1_0_explicit_superadmin.py new file mode 100644 index 0000000..f17a2d6 --- /dev/null +++ b/alembic/versions/b1_0_explicit_superadmin.py @@ -0,0 +1,62 @@ +"""B1.0 — make superadmin an explicit flag instead of a null tenant + +Adds users.is_superadmin and backfills it from the signal it replaces, so the +operators who are superadmins today stay superadmins after deploy. + +Until now privilege was inferred from `tenant_id IS NULL`. That made the +*absence* of tenant context a grant of authority: a bug that dropped the tenant +escalated instead of denying. The flag separates "has no tenant" from "may do +anything". + +This migration only adds and backfills. It deliberately does not drop or alter +`tenant_id`, and the application honours both signals for one release, so this +can be reverted without locking anyone out. + +Revision ID: b1_0_explicit_superadmin +Revises: dfae38e4ede3 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b1_0_explicit_superadmin" +down_revision = "73dff81eed72" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "is_superadmin", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + # Backfill in the same migration, not a follow-up script. If this ran later + # there would be a window in which every superadmin was demoted. + op.execute( + """ + UPDATE users + SET is_superadmin = true + WHERE tenant_id IS NULL + AND is_deleted = false + """ + ) + + op.create_index( + "ix_users_is_superadmin", + "users", + ["is_superadmin"], + postgresql_where=sa.text("is_superadmin"), + ) + + +def downgrade() -> None: + # Safe to reverse: `tenant_id IS NULL` still identifies the same people, + # and the application still honours it. + op.drop_index("ix_users_is_superadmin", table_name="users") + op.drop_column("users", "is_superadmin") diff --git a/alembic/versions/b1_3_row_level_security.py b/alembic/versions/b1_3_row_level_security.py new file mode 100644 index 0000000..7c03b78 --- /dev/null +++ b/alembic/versions/b1_3_row_level_security.py @@ -0,0 +1,87 @@ +"""B1.3 — row-level security on every tenant-owned table + +Enables **and forces** RLS on the 15 tables carrying a `tenant_id`, with a policy +that compares the row's tenant against a session variable the application sets +per request. + +Why this exists when B1.1 already filters queries: the ORM listener attaches to +entity loading, so it cannot filter aggregates — `query(X).count()` compiles to +`SELECT count(*) FROM (SELECT ...)` and comes back unfiltered. RLS applies in the +database regardless of how the query was composed, so it closes a hole the +listener structurally cannot. + +FORCE matters. Without it the policy does not apply to the table's owner, and +migrations run as the owner — so the tables would look protected while the +application, if it ever connected as that role, saw everything. + +This migration is inert until the application sets `docqube.tenant_id`, and +takes effect fully only once the app connects as a NOSUPERUSER / NOBYPASSRLS +role (B1.4). A superuser connection bypasses RLS unconditionally. + +Revision ID: b1_3_row_level_security +Revises: b1_0_explicit_superadmin +""" + +from alembic import op + +revision = "b1_3_row_level_security" +down_revision = "b1_0_explicit_superadmin" +branch_labels = None +depends_on = None + +POLICY = "docqube_tenant_isolation" + +# Derived from the schema when this was written; asserted against +# information_schema by tests/probes/test_rls.py so drift is caught rather than +# assumed. +TENANT_TABLES = [ + "activity_logs", + "chat_sessions", + "chatbot_documents", + "drive_activities", + "drive_files", + "drive_folders", + "notifications", + "projects", + "roles", + "signing_requests", + "tenant_signature_configs", + "tenant_smtp_configs", + "tenant_storage_configs", + "user_files", + "users", +] + +# The predicate, in words: +# * `docqube.bypass = 'on'` — an explicit system operation (migrations, +# background sweeps). Only the application can set this; it is not reachable +# from user input. +# * the row has no tenant — shared system records such as global roles. +# * the row's tenant matches the session variable. +# +# With no variable set, `current_setting(..., true)` returns NULL, `NULLIF` +# keeps it NULL, and `tenant_id = NULL` is NULL — so only shared rows are +# visible. Losing the tenant denies rather than reveals. +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute( + f"CREATE POLICY {POLICY} ON {table} " + f"USING ({USING}) WITH CHECK ({USING})" + ) + + +def downgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") diff --git a/alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py b/alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py new file mode 100644 index 0000000..6aa2e01 --- /dev/null +++ b/alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py @@ -0,0 +1,55 @@ +"""add collaboration_messages table + +Revision ID: b3a7c1e9f042 +Revises: f055f9b157cb +Create Date: 2026-03-02 15:17:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b3a7c1e9f042' +down_revision: Union[str, None] = 'f055f9b157cb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "collaboration_messages", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "file_id", + sa.Integer(), + sa.ForeignKey("drive_files.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sender_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("message", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + ), + ) + + # Performance indexes + op.create_index("ix_collab_msg_file_id", "collaboration_messages", ["file_id"]) + op.create_index("ix_collab_msg_sender_id", "collaboration_messages", ["sender_id"]) + op.create_index("ix_collab_msg_created_at", "collaboration_messages", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_collab_msg_created_at", table_name="collaboration_messages") + op.drop_index("ix_collab_msg_sender_id", table_name="collaboration_messages") + op.drop_index("ix_collab_msg_file_id", table_name="collaboration_messages") + op.drop_table("collaboration_messages") diff --git a/alembic/versions/b6_4_index_review.py b/alembic/versions/b6_4_index_review.py new file mode 100644 index 0000000..205496d --- /dev/null +++ b/alembic/versions/b6_4_index_review.py @@ -0,0 +1,65 @@ +"""B6.4 — indexes on the foreign keys that queries actually filter by + +The database has 34 foreign keys with no supporting index. PostgreSQL does not +create one automatically, and the cost is invisible on a seeded database: a +sequential scan over five rows is instant, over five hundred thousand it is not. + +This adds indexes only where a measured query path filters or joins on the +column — the ones surfaced by the B6.3 query-budget work. Indexing all 34 would +add write cost for paths nothing traverses; the remainder are listed in +`tests/probes/test_index_coverage.py` so they stay visible rather than +forgotten. + +**Deployment note.** `CREATE INDEX` takes a lock that blocks writes for the +duration. On DocQube's current row counts that is brief, but on a large +`drive_files` it will not be — run these with `CONCURRENTLY` outside a +transaction if the table has grown. Alembic cannot do that inside its own +transaction, which is why this migration is written to be easy to translate. + +Revision ID: b6_4_index_review +Revises: b1_3_row_level_security +""" + +from alembic import op + +revision = "b6_4_index_review" +down_revision = "b1_3_row_level_security" +branch_labels = None +depends_on = None + +# (index name, table, columns) — each justified by a query path measured in +# tests/characterization/test_query_budget.py. +INDEXES = [ + # Every folder listing filters files by folder. + ("ix_drive_files_folder_id", "drive_files", "folder_id"), + ("ix_drive_files_owner_id", "drive_files", "owner_id"), + # Hierarchy walks in get_effective_role and breadcrumbs. + ("ix_drive_folders_parent_id", "drive_folders", "parent_id"), + ("ix_drive_folders_owner_id", "drive_folders", "owner_id"), + # The batched star lookup added in B6.3 filters on all three. + ( + "ix_drive_stars_user_resource", + "drive_stars", + "user_id, resource_type, resource_id", + ), + # Version history, loaded per file. + ("ix_drive_file_versions_file_id", "drive_file_versions", "file_id"), + ("ix_project_versions_project_id", "project_versions", "project_id"), + # Permission resolution joins accesses on every authenticated request. + ("ix_role_accesses_access_id", "role_accesses", "access_id"), + # Signing looks requests up by the file they belong to. + ("ix_signing_requests_drive_file_id", "signing_requests", "drive_file_id"), + # Comments and activity are read per resource. + ("ix_drive_comments_author_id", "drive_comments", "author_id"), + ("ix_drive_activities_actor_id", "drive_activities", "actor_id"), +] + + +def upgrade() -> None: + for name, table, columns in INDEXES: + op.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({columns})") + + +def downgrade() -> None: + for name, _table, _columns in INDEXES: + op.execute(f"DROP INDEX IF EXISTS {name}") diff --git a/alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py b/alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py new file mode 100644 index 0000000..d764cc5 --- /dev/null +++ b/alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py @@ -0,0 +1,48 @@ +"""remove_tenant_storage_config_and_use_system_config + +Revision ID: bb224e0b642b +Revises: i001_dual_bucket_storage +Create Date: 2026-03-17 09:09:02.599878 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bb224e0b642b' +down_revision: Union[str, None] = 'i001_dual_bucket_storage' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the tenant-specific storage config table + op.drop_table('tenant_storage_configs') + + +def downgrade() -> None: + # Recreate the tenant-specific storage config table + op.create_table( + 'tenant_storage_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('storage_provider', sa.String(length=50), nullable=False), + sa.Column('b2_key_id', sa.String(length=255), nullable=True), + sa.Column('b2_application_key', sa.String(length=512), nullable=True), + sa.Column('b2_quarantine_bucket', sa.String(length=255), nullable=True), + sa.Column('b2_clean_bucket', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id') + ) + op.create_index(op.f('ix_tenant_storage_configs_tenant_id'), 'tenant_storage_configs', ['tenant_id'], unique=True) diff --git a/alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py b/alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py new file mode 100644 index 0000000..a9e3536 --- /dev/null +++ b/alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py @@ -0,0 +1,38 @@ +"""Add encrypted_ai_api_key to user_model + +Revision ID: be67927ab00d +Revises: b3a7c1e9f042 +Create Date: 2026-03-03 12:43:12.848347 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'be67927ab00d' +down_revision: Union[str, None] = 'b3a7c1e9f042' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('collaboration_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.add_column('users', sa.Column('encrypted_ai_api_key', sa.String(length=512), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'encrypted_ai_api_key') + op.alter_column('collaboration_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### diff --git a/alembic/versions/c1_0_org_units_and_scoped_roles.py b/alembic/versions/c1_0_org_units_and_scoped_roles.py new file mode 100644 index 0000000..d813cb4 --- /dev/null +++ b/alembic/versions/c1_0_org_units_and_scoped_roles.py @@ -0,0 +1,247 @@ +"""C1/C3 — organisation units, membership, leadership, and scoped role grants + +Four tables, and a backfill that is deliberately a no-op in behaviour. + +`user_roles` exists because `users.role_id` is single-valued: one role, held +across the whole tenant. Scoped access has nowhere to live until a user can hold +the same role in two units. The backfill turns each existing `users.role_id` +into one `user_roles` row with `org_unit_id = NULL`, which means tenant-wide — +exactly the authority that user already had. Nobody gains or loses anything on +this migration. + +`users.role_id` is **not** dropped. Both mechanisms are honoured for one +release, the same shape as the `is_superadmin` transition and for the same +reason: reverting this migration must not be able to remove everyone's +permissions. Removal is a later, deliberate step with its own preflight. + +All four tables carry `tenant_id`, so `app/core/tenant_filter.py` scopes them +automatically, and they are added to the RLS policy here because +`tests/probes/test_isolation_ratchet.py` fails if a tenant-owned table is left +unprotected. + +Revision ID: c1_0_org_units_and_scoped_roles +Revises: b6_4_index_review +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "c1_0_org_units_and_scoped_roles" +down_revision = "b6_4_index_review" +branch_labels = None +depends_on = None + +# Kept in step with b1_3_row_level_security.py. +# Named TENANT_TABLES so `test_isolation_ratchet.py` finds it: the ratchet +# scans every migration for this constant, which is what lets a later +# migration add tenant-owned tables without the check going stale. +TENANT_TABLES = ["org_units", "user_org_units", "org_unit_leads"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + op.create_table( + "org_units", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + # SET NULL, not CASCADE: deleting a department must not silently delete + # every team beneath it. What happens to orphans is an application + # decision, and it should be visible as one. + sa.Column( + "parent_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("code", sa.String(50), nullable=True), + sa.Column("unit_type", sa.String(50), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("depth", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + op.create_index("ix_org_units_tenant_id", "org_units", ["tenant_id"]) + op.create_index("ix_org_units_parent_id", "org_units", ["parent_id"]) + op.create_index("ix_org_units_tenant_parent", "org_units", ["tenant_id", "parent_id"]) + # Codes are unique per tenant among *live* rows only — a soft-deleted unit + # must not reserve its code forever. + op.create_index( + "uq_org_units_tenant_code_live", + "org_units", + ["tenant_id", "code"], + unique=True, + postgresql_where=sa.text("NOT is_deleted AND code IS NOT NULL"), + ) + + op.create_table( + "user_org_units", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "org_unit_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_org_units_user_unit"), + ) + op.create_index("ix_user_org_units_tenant_id", "user_org_units", ["tenant_id"]) + op.create_index("ix_user_org_units_user_id", "user_org_units", ["user_id"]) + op.create_index("ix_user_org_units_org_unit_id", "user_org_units", ["org_unit_id"]) + # "Exactly one primary" as an index, not as application logic: written in + # Python it is a race between two concurrent writes, which is the same + # defect class as the user_storage_usage get-or-create found under load. + op.create_index( + "uq_user_org_units_one_primary", + "user_org_units", + ["user_id"], + unique=True, + postgresql_where=sa.text("is_primary"), + ) + + op.create_table( + "org_unit_leads", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "org_unit_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + op.create_index("ix_org_unit_leads_tenant_id", "org_unit_leads", ["tenant_id"]) + op.create_index("ix_org_unit_leads_org_unit_id", "org_unit_leads", ["org_unit_id"]) + op.create_index("ix_org_unit_leads_user_id", "org_unit_leads", ["user_id"]) + # At most one *current* lead per unit; history is unconstrained, which is + # what makes it usable as history. + op.create_index( + "uq_org_unit_leads_current", + "org_unit_leads", + ["org_unit_id"], + unique=True, + postgresql_where=sa.text("end_date IS NULL"), + ) + + op.create_table( + "user_roles", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "role_id", + UUID(as_uuid=True), + sa.ForeignKey("roles.id", ondelete="CASCADE"), + nullable=False, + ), + # NULL means tenant-wide, which is what every grant is today. + sa.Column( + "org_unit_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column( + "assigned_by_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("user_id", "role_id", "org_unit_id", name="uq_user_roles_scoped"), + ) + op.create_index("ix_user_roles_user_id", "user_roles", ["user_id"]) + op.create_index("ix_user_roles_role_id", "user_roles", ["role_id"]) + op.create_index("ix_user_roles_org_unit_id", "user_roles", ["org_unit_id"]) + op.create_index("ix_user_roles_lookup", "user_roles", ["user_id", "role_id"]) + # Every foreign key needs a supporting index or a delete on the parent + # sequentially scans this table. `test_index_coverage.py` enforces it. + op.create_index("ix_user_roles_assigned_by_id", "user_roles", ["assigned_by_id"]) + # Postgres treats NULLs as distinct in a UNIQUE constraint, so the triple + # above does not stop the same role being held tenant-wide twice. + op.create_index( + "uq_user_roles_tenant_wide", + "user_roles", + ["user_id", "role_id"], + unique=True, + postgresql_where=sa.text("org_unit_id IS NULL"), + ) + + # Backfill. Behaviour-neutral by construction: a null org unit is + # tenant-wide, which is the authority every user already has. + op.execute( + """ + INSERT INTO user_roles (id, user_id, role_id, org_unit_id, created_at) + SELECT gen_random_uuid(), u.id, u.role_id, NULL, now() + FROM users u + WHERE u.role_id IS NOT NULL + AND u.is_deleted = false + ON CONFLICT DO NOTHING + """ + ) + + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + # Safe to reverse: `users.role_id` was never dropped, so removing these + # tables returns the application to the grants it still reads today. + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + + op.drop_table("user_roles") + op.drop_table("org_unit_leads") + op.drop_table("user_org_units") + op.drop_table("org_units") diff --git a/alembic/versions/c1_1_user_roles_tenant_id.py b/alembic/versions/c1_1_user_roles_tenant_id.py new file mode 100644 index 0000000..f1cc814 --- /dev/null +++ b/alembic/versions/c1_1_user_roles_tenant_id.py @@ -0,0 +1,80 @@ +"""C3.1 — give user_roles a tenant_id, so isolation is structural + +`user_roles` was created without one. That is not a cosmetic omission: both +isolation layers *derive* their coverage from the presence of that column — +`app/core/tenant_filter.py` scopes any model that has it, and the RLS policy +compares it against the session variable. A table without it is protected by +neither, no matter how careful the queries are. + +It is the same shape of gap as `tenants`, which is where "any holder of +`superadmin.tenant.delete` can delete any tenant" lived: a table outside the +mechanism, guarded only by whatever each call site remembered to write. Three +places currently remember. Three is a number that only goes up, and the failure +mode when one forgets is a grant resolving across tenants. + +After this the explicit predicates stay — they are cheap and they document the +intent — but they stop being the only thing standing there. + +**Backfill from the user, not the role.** `roles.tenant_id` is nullable: a null +means a shared/global role template, and a grant of a shared role still belongs +to the tenant of the person holding it. Rows for tenant-less users (superadmins) +stay null, which the RLS policy already treats as globally visible — the same +way their `users` row is treated. + +Revision ID: c1_1_user_roles_tenant_id +Revises: c1_0_org_units_and_scoped_roles +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "c1_1_user_roles_tenant_id" +down_revision = "c1_0_org_units_and_scoped_roles" +branch_labels = None +depends_on = None + +TENANT_TABLES = ["user_roles"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + op.add_column( + "user_roles", + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=True, + ), + ) + op.create_index("ix_user_roles_tenant_id", "user_roles", ["tenant_id"]) + + op.execute( + """ + UPDATE user_roles ur + SET tenant_id = u.tenant_id + FROM users u + WHERE u.id = ur.user_id + """ + ) + + # Left nullable on purpose. A superadmin has no tenant, so their grants have + # none either; forcing NOT NULL would mean inventing one. + op.execute(f"ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE user_roles FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON user_roles") + op.execute(f"CREATE POLICY {POLICY} ON user_roles USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON user_roles") + op.execute("ALTER TABLE user_roles NO FORCE ROW LEVEL SECURITY") + op.execute("ALTER TABLE user_roles DISABLE ROW LEVEL SECURITY") + op.drop_index("ix_user_roles_tenant_id", table_name="user_roles") + op.drop_column("user_roles", "tenant_id") diff --git a/alembic/versions/c3_0_org_unit_path.py b/alembic/versions/c3_0_org_unit_path.py new file mode 100644 index 0000000..8e7b981 --- /dev/null +++ b/alembic/versions/c3_0_org_unit_path.py @@ -0,0 +1,77 @@ +"""C3 — a materialised path, so a subtree is a prefix match + +`ScopeService` resolved authority with a recursive CTE, once per access code per +request. That is correct and it is the thing that gets slower as the tree gets +deeper, in the way that shows up as "the admin page feels slow" rather than as +an error — the query-count budgets cannot catch it, because the *count* does not +change. + +A materialised path turns the walk into a prefix match: + + /a1b2.../c3d4.../e5f6.../ + +The subtree of a unit is every row whose path starts with that unit's path. +Indexed with `text_pattern_ops`, that is an index range scan. + +**Text, not `ltree`.** `ltree` is the tidier type and needs an extension, which +is a deployment prerequisite for a performance improvement — a poor trade. Text +with a prefix index needs nothing and behaves identically for this query. + +**Why the delimiters matter.** Ids are stored between slashes, so +`LIKE '/a/b/%'` cannot match `/a/bc/...`. Without them a prefix match would leak +across sibling branches whose ids share a leading substring — which for UUIDs is +rare, and rare is worse than never. + +The column is nullable and the CTE remains as a fallback for any row that has +not been backfilled, so this migration cannot break resolution even if the +backfill is incomplete. + +Revision ID: c3_0_org_unit_path +Revises: c5_0_access_groups +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c3_0_org_unit_path" +down_revision = "c5_0_access_groups" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("org_units", sa.Column("path", sa.Text(), nullable=True)) + + # Prefix matching needs `text_pattern_ops`; the default opclass supports + # equality and ordering but not `LIKE 'prefix%'` as an index scan. + op.execute( + "CREATE INDEX ix_org_units_path_prefix " + "ON org_units (path text_pattern_ops)" + ) + op.create_index("ix_org_units_tenant_path", "org_units", ["tenant_id", "path"]) + + # Backfill, walking down from the roots. Done once here so the application + # never has to compute a path it did not write. + op.execute( + """ + WITH RECURSIVE tree AS ( + SELECT id, '/' || id::text || '/' AS path + FROM org_units + WHERE parent_id IS NULL + UNION ALL + SELECT c.id, t.path || c.id::text || '/' + FROM org_units c + JOIN tree t ON c.parent_id = t.id + ) + UPDATE org_units u + SET path = tree.path + FROM tree + WHERE u.id = tree.id + """ + ) + + +def downgrade() -> None: + op.drop_index("ix_org_units_tenant_path", table_name="org_units") + op.execute("DROP INDEX IF EXISTS ix_org_units_path_prefix") + op.drop_column("org_units", "path") diff --git a/alembic/versions/c3abf44266de_enforce_b2_storage_provider.py b/alembic/versions/c3abf44266de_enforce_b2_storage_provider.py new file mode 100644 index 0000000..623435f --- /dev/null +++ b/alembic/versions/c3abf44266de_enforce_b2_storage_provider.py @@ -0,0 +1,40 @@ +"""enforce_b2_storage_provider + +Revision ID: c3abf44266de +Revises: bb224e0b642b +Create Date: 2026-03-17 10:54:51.326256 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c3abf44266de' +down_revision: Union[str, None] = 'bb224e0b642b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Update existing records from 'minio' to 'b2' + op.execute("UPDATE user_files SET storage_provider = 'b2' WHERE storage_provider = 'minio'") + op.execute("UPDATE drive_file_versions SET storage_provider = 'b2' WHERE storage_provider = 'minio'") + + # 2. Change server default to 'b2' + with op.batch_alter_table('user_files', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='b2') + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='b2') + + +def downgrade() -> None: + # 1. Change server default back to 'minio' + with op.batch_alter_table('user_files', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='minio') + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='minio') diff --git a/alembic/versions/c4_0_grant_expiry.py b/alembic/versions/c4_0_grant_expiry.py new file mode 100644 index 0000000..3a7331e --- /dev/null +++ b/alembic/versions/c4_0_grant_expiry.py @@ -0,0 +1,46 @@ +"""C4A — grants can expire + +`user_roles.expires_at`, nullable. Null means permanent, which is every existing +row, so this migration changes nobody's authority. + +Temporary elevation, contractor access and just-in-time grants all need this, and +all three are ordinary enterprise asks. Without it the only way to end a grant is +to remember to revoke it, and "remember to" is not an access control. + +Expired rows are **not** deleted. They stay, and they become history — which is +what C4B reads to answer "who had access in March". A swept-away row answers +nothing. + +Revision ID: c4_0_grant_expiry +Revises: c1_1_user_roles_tenant_id +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c4_0_grant_expiry" +down_revision = "c1_1_user_roles_tenant_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "user_roles", + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + ) + + # Partial: only live grants are ever queried by expiry, and indexing the + # permanent ones (which is all of them today) would be indexing a column + # that is null for every row. + op.create_index( + "ix_user_roles_expires_at", + "user_roles", + ["expires_at"], + postgresql_where=sa.text("expires_at IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_user_roles_expires_at", table_name="user_roles") + op.drop_column("user_roles", "expires_at") diff --git a/alembic/versions/c4_1_access_log_retention.py b/alembic/versions/c4_1_access_log_retention.py new file mode 100644 index 0000000..2368c2b --- /dev/null +++ b/alembic/versions/c4_1_access_log_retention.py @@ -0,0 +1,39 @@ +"""C4C — index the access timeline, so retention and history are cheap + +Two reads matter and neither had an index: + +- the history endpoint, filtering one tenant's `access` events by date; +- the retention sweeper, finding events older than the policy. + +Both are `(tenant_id, module, created_at)`. Partial on `module = 'access'`, +because ordinary activity is far more numerous and does not share these +queries — indexing it here would triple the index for no reader. + +Retention itself is a **setting**, not a constant: how long access records are +kept is the sort of thing a customer contract specifies, and it must be +answerable without a deploy. See `ACCESS_LOG_RETENTION_DAYS`. + +Revision ID: c4_1_access_log_retention +Revises: c4_0_grant_expiry +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c4_1_access_log_retention" +down_revision = "c4_0_grant_expiry" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "ix_activity_logs_access_timeline", + "activity_logs", + ["tenant_id", "created_at"], + postgresql_where=sa.text("module = 'access'"), + ) + + +def downgrade() -> None: + op.drop_index("ix_activity_logs_access_timeline", table_name="activity_logs") diff --git a/alembic/versions/c5_0_access_groups.py b/alembic/versions/c5_0_access_groups.py new file mode 100644 index 0000000..c1b5148 --- /dev/null +++ b/alembic/versions/c5_0_access_groups.py @@ -0,0 +1,161 @@ +"""C5.1 — access groups, and grants that can name one + +Two new tables, and one genuinely risky change: `user_roles.user_id` becomes +nullable, so a grant can name a **group** instead. + +That weakens an invariant every existing query relied on. Four readers exist and +all four were reviewed before this migration was written: + + * `app/core/scope.py` — resolution; gains a group branch + * `app/modules/auth/services/permission_service.py` — the coarse gate; likewise + * `app/modules/org/routes/org_routes.py` — lists a user's own grants; unchanged + behaviour, it simply will not list grants a user holds *through* a group + * `app/modules/org/services/access_maintenance.py` — the expiry sweeper; its + audit entry uses the principal, which may now be a group + +The `CHECK` is what keeps the pair honest. "Exactly one of two nullable columns" +is the kind of invariant that survives in the schema and rots in code — one new +call site that sets both, and the resolver counts the grant twice. + +Existing rows all have `user_id`, so the constraint holds the moment it is +added, and nobody's authority changes. + +Revision ID: c5_0_access_groups +Revises: c4_1_access_log_retention +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "c5_0_access_groups" +down_revision = "c4_1_access_log_retention" +branch_labels = None +depends_on = None + +TENANT_TABLES = ["access_groups", "user_access_groups"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + op.create_table( + "access_groups", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("tenant_id", "name", name="uq_access_groups_tenant_name"), + ) + op.create_index("ix_access_groups_tenant_id", "access_groups", ["tenant_id"]) + + op.create_table( + "user_access_groups", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "group_id", + UUID(as_uuid=True), + sa.ForeignKey("access_groups.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("user_id", "group_id", name="uq_user_access_groups"), + ) + op.create_index("ix_user_access_groups_tenant_id", "user_access_groups", ["tenant_id"]) + op.create_index("ix_user_access_groups_user_id", "user_access_groups", ["user_id"]) + op.create_index("ix_user_access_groups_group_id", "user_access_groups", ["group_id"]) + op.create_index( + "ix_user_access_groups_lookup", "user_access_groups", ["user_id", "group_id"] + ) + + # The risky half. + op.add_column( + "user_roles", + sa.Column( + "group_id", + UUID(as_uuid=True), + # CASCADE: deleting a group takes its grants with it. A grant whose + # principal no longer exists is a row nobody can see, revoke or + # audit — and it must not keep applying. + sa.ForeignKey("access_groups.id", ondelete="CASCADE"), + nullable=True, + ), + ) + op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=True) + op.create_index("ix_user_roles_group_id", "user_roles", ["group_id"]) + + op.create_check_constraint( + "ck_user_roles_exactly_one_principal", + "user_roles", + "(user_id IS NOT NULL) <> (group_id IS NOT NULL)", + ) + + # The tenant-wide uniqueness guard was written for user grants. Group grants + # need the same protection, and the original index cannot serve both + # because `user_id` is now null on half the rows. + op.create_index( + "uq_user_roles_group_tenant_wide", + "user_roles", + ["group_id", "role_id"], + unique=True, + postgresql_where=sa.text("org_unit_id IS NULL AND group_id IS NOT NULL"), + ) + op.create_index( + "uq_user_roles_group_scoped", + "user_roles", + ["group_id", "role_id", "org_unit_id"], + unique=True, + postgresql_where=sa.text("group_id IS NOT NULL"), + ) + + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + + # Group grants cannot survive the column being dropped, and they cannot be + # converted into user grants either — the whole point is that they name no + # single user. Removing them is the only honest reverse. + op.execute("DELETE FROM user_roles WHERE group_id IS NOT NULL") + + op.drop_index("uq_user_roles_group_scoped", table_name="user_roles") + op.drop_index("uq_user_roles_group_tenant_wide", table_name="user_roles") + op.drop_constraint("ck_user_roles_exactly_one_principal", "user_roles", type_="check") + op.drop_index("ix_user_roles_group_id", table_name="user_roles") + op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=False) + op.drop_column("user_roles", "group_id") + + op.drop_table("user_access_groups") + op.drop_table("access_groups") diff --git a/alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py b/alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py new file mode 100644 index 0000000..bc740a0 --- /dev/null +++ b/alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py @@ -0,0 +1,66 @@ +"""add tenant storage quota bytes + +Revision ID: c7d4e6f1a9b0 +Revises: e81a9d22ca5f +Create Date: 2026-03-07 09:25:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c7d4e6f1a9b0" +down_revision: Union[str, None] = "e81a9d22ca5f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tenants", + sa.Column("storage_quota_bytes", sa.BigInteger(), nullable=True), + ) + + # Preserve existing effective quota behavior by summing each tenant's + # current per-user quotas. For tenants with no users, default to 1 GB. + op.execute( + """ + UPDATE tenants t + SET storage_quota_bytes = COALESCE( + ( + SELECT SUM(COALESCE(usu.max_bytes_quota, 1073741824)) + FROM users u + LEFT JOIN user_storage_usage usu ON usu.user_id = u.id + WHERE u.tenant_id = t.id + ), + 1073741824 + ) + """ + ) + + op.alter_column( + "tenants", + "storage_quota_bytes", + existing_type=sa.BigInteger(), + nullable=False, + server_default=sa.text("1073741824"), + ) + + op.create_check_constraint( + "ck_tenants_storage_quota_bytes_positive", + "tenants", + "storage_quota_bytes > 0", + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_tenants_storage_quota_bytes_positive", + "tenants", + type_="check", + ) + op.drop_column("tenants", "storage_quota_bytes") diff --git a/alembic/versions/ca14c4ab10d9_merge_multiple_heads.py b/alembic/versions/ca14c4ab10d9_merge_multiple_heads.py new file mode 100644 index 0000000..4059914 --- /dev/null +++ b/alembic/versions/ca14c4ab10d9_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""Merge multiple heads + +Revision ID: ca14c4ab10d9 +Revises: b0a09cd13348, y001_add_user_mail_from +Create Date: 2026-05-08 13:47:42.707120 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ca14c4ab10d9' +down_revision: Union[str, None] = ('b0a09cd13348', 'y001_add_user_mail_from') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/cba70ab35793_create_chat_token_usage_table.py b/alembic/versions/cba70ab35793_create_chat_token_usage_table.py new file mode 100644 index 0000000..43ff6c4 --- /dev/null +++ b/alembic/versions/cba70ab35793_create_chat_token_usage_table.py @@ -0,0 +1,83 @@ +"""Create chat_token_usage table + +Revision ID: cba70ab35793 +Revises: cd27562afa2e +Create Date: 2026-04-16 13:39:46.842540 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cba70ab35793' +down_revision: Union[str, None] = 'cd27562afa2e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# (index_name, columns, unique, use_naming_convention) +# use_naming_convention=True wraps in batch_op.f(); False uses the raw string +# (matches the original auto-generated/hand-written split). +_INDEXES = [ + ('ix_chat_token_usage_chat_id', ['chat_id'], False, True), + ('ix_chat_token_usage_document_id', ['document_id'], False, True), + ('ix_chat_token_usage_id', ['id'], False, True), + ('ix_chat_token_usage_model_name', ['model_name'], False, True), + ('ix_chat_token_usage_model_time', ['model_name', 'timestamp'], False, False), + ('ix_chat_token_usage_phase', ['phase'], False, True), + ('ix_chat_token_usage_timestamp', ['timestamp'], False, True), + ('ix_chat_token_usage_user_id', ['user_id'], False, True), + ('ix_chat_token_usage_user_time', ['user_id', 'timestamp'], False, False), +] + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('chat_token_usage'): + op.create_table('chat_token_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('chat_id', sa.Integer(), nullable=True), + sa.Column('document_id', sa.String(length=255), nullable=True), + sa.Column('prompt_tokens', sa.BigInteger(), nullable=False), + sa.Column('completion_tokens', sa.BigInteger(), nullable=False), + sa.Column('total_tokens', sa.BigInteger(), nullable=False), + sa.Column('model_name', sa.String(length=100), nullable=False), + sa.Column('cost_usd', sa.DECIMAL(precision=12, scale=8), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('phase', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + existing = _index_names(bind, 'chat_token_usage') + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + for name, cols, unique, use_f in _INDEXES: + if name not in existing: + ref = batch_op.f(name) if use_f else name + batch_op.create_index(ref, cols, unique=unique) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table('chat_token_usage'): + existing = _index_names(bind, 'chat_token_usage') + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + for name, _, _, use_f in reversed(_INDEXES): + if name in existing: + ref = batch_op.f(name) if use_f else name + batch_op.drop_index(ref) + + op.drop_table('chat_token_usage') diff --git a/alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py b/alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py new file mode 100644 index 0000000..43d28ec --- /dev/null +++ b/alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py @@ -0,0 +1,116 @@ +"""Add chat token usage tracking + +Revision ID: cd27562afa2e +Revises: 1f4d2a8c9b77 +Create Date: 2026-04-16 13:05:09.572442 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'cd27562afa2e' +down_revision: Union[str, None] = '1f4d2a8c9b77' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, 'chatbot_documents'): + with op.batch_alter_table('chatbot_documents', schema=None) as batch_op: + batch_op.alter_column('chunks_count', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + + if _table_exists(bind, 'vector_chunks'): + with op.batch_alter_table('vector_chunks', schema=None) as batch_op: + batch_op.alter_column('document_id', + existing_type=sa.VARCHAR(), + nullable=False) + batch_op.alter_column('chunk_text', + existing_type=sa.VARCHAR(), + type_=sa.Text(), + existing_nullable=False) + batch_op.alter_column('chunk_index', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=False) + batch_op.drop_constraint('vector_chunks_user_id_fkey', type_='foreignkey') + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + if _table_exists(bind, 'vector_indices'): + with op.batch_alter_table('vector_indices', schema=None) as batch_op: + batch_op.alter_column('total_vectors', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=False) + batch_op.drop_constraint('vector_indices_user_id_fkey', type_='foreignkey') + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, 'vector_indices'): + with op.batch_alter_table('vector_indices', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('vector_indices_user_id_fkey', 'users', ['user_id'], ['id']) + batch_op.drop_index(batch_op.f('ix_vector_indices_user_id')) + batch_op.create_index('ix_vector_indices_user_id', ['user_id'], unique=True) + batch_op.create_index('ix_vector_indices_id', ['id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + nullable=True) + batch_op.alter_column('total_vectors', + existing_type=sa.INTEGER(), + nullable=True) + + if _table_exists(bind, 'vector_chunks'): + with op.batch_alter_table('vector_chunks', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('vector_chunks_user_id_fkey', 'users', ['user_id'], ['id']) + batch_op.create_index('ix_vector_chunks_id', ['id'], unique=False) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + nullable=True) + batch_op.alter_column('chunk_index', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.alter_column('chunk_text', + existing_type=sa.Text(), + type_=sa.VARCHAR(), + existing_nullable=False) + batch_op.alter_column('document_id', + existing_type=sa.VARCHAR(), + nullable=True) + + if _table_exists(bind, 'chatbot_documents'): + with op.batch_alter_table('chatbot_documents', schema=None) as batch_op: + batch_op.create_index('ix_chatbot_documents_id', ['id'], unique=False) + batch_op.create_index('ix_chatbot_documents_drive_file_id', ['drive_file_id'], unique=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + batch_op.alter_column('chunks_count', + existing_type=sa.INTEGER(), + nullable=True) diff --git a/alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py b/alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py new file mode 100644 index 0000000..915bffb --- /dev/null +++ b/alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py @@ -0,0 +1,52 @@ +"""add_updated_at_defaults + +Revision ID: cfbee4f91fc2 +Revises: 49c19df2c1d4 +Create Date: 2026-02-19 11:25:54.874662 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cfbee4f91fc2' +down_revision: Union[str, None] = '49c19df2c1d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('drive_folders', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=sa.text('now()'), + existing_nullable=False) + op.alter_column('drive_files', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=sa.text('now()'), + existing_nullable=False) + op.alter_column('projects', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=sa.text('now()'), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('projects', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=None, + existing_nullable=False) + op.alter_column('drive_files', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=None, + existing_nullable=False) + op.alter_column('drive_folders', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=None, + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/d1_0_plans_and_entitlements.py b/alembic/versions/d1_0_plans_and_entitlements.py new file mode 100644 index 0000000..e36b65e --- /dev/null +++ b/alembic/versions/d1_0_plans_and_entitlements.py @@ -0,0 +1,219 @@ +"""D1 — plans, limits, subscriptions and overrides + +The landing page advertises four priced plans and the backend had never heard of +a plan. Meanwhile `tenants` carried four limit columns set per tenant by hand, so +two customers on the same advertised plan could silently have different limits. + +This gives those limits something to be derived from. + +**Behaviour-neutral by construction.** Every tenant's four current values become +**overrides**, not plan limits — so whatever they have today is exactly what they +keep, whichever plan they are placed on. The plan is then chosen to match, or +`custom` when nothing does. Nobody's limits move. + +The `tenants` columns are **not** dropped. They stay for one release and remain +the last-resort fallback in `EntitlementService`, the same transition shape as +`users.role_id` and for the same reason: a rollback must not be able to remove +every tenant's quota. + +`plans` and `plan_limits` are deliberately **not** tenant-scoped — a catalogue is +shared, like `accesses`. `tenant_subscriptions` and `tenant_limit_overrides` are, +and are added to the RLS policy here. + +Revision ID: d1_0_plans_and_entitlements +Revises: c3_0_org_unit_path +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "d1_0_plans_and_entitlements" +down_revision = "c3_0_org_unit_path" +branch_labels = None +depends_on = None + +TENANT_TABLES = ["tenant_subscriptions", "tenant_limit_overrides"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + +# The four plans the pricing page already sells, with the limits it claims. +# Seeded here rather than in `scripts/seed.py` so that a database which has run +# migrations is one a customer could be placed on — the page and the schema +# start out agreeing. +# +# -1 means unlimited, matching the convention `tenants.envelope_limit` already +# uses. +GB = 1024 ** 3 +PLANS = [ + # code, name, price, public, order + ("starter", "Starter plan", "$12", True, 1), + ("professional", "Professional plan", "$22", True, 2), + ("elite", "Elite plan", "$29", True, 3), + ("custom", "Custom plan", "Contact Us", True, 4), +] +LIMITS = { + "starter": {"storage_bytes": 10 * GB, "seats": 5, + "chat_tokens_daily": 500_000, "envelopes": 25}, + "professional": {"storage_bytes": 100 * GB, "seats": 25, + "chat_tokens_daily": 2_000_000, "envelopes": 250}, + "elite": {"storage_bytes": 500 * GB, "seats": 100, + "chat_tokens_daily": 10_000_000, "envelopes": -1}, + "custom": {"storage_bytes": -1, "seats": -1, + "chat_tokens_daily": -1, "envelopes": -1}, +} + + +def upgrade() -> None: + op.create_table( + "plans", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("code", sa.String(50), nullable=False, unique=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("price_display", sa.String(50), nullable=True), + sa.Column("currency", sa.String(10), nullable=True), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_plans_code", "plans", ["code"]) + + op.create_table( + "plan_limits", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("plan_id", UUID(as_uuid=True), + sa.ForeignKey("plans.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(50), nullable=False), + sa.Column("value", sa.BigInteger(), nullable=False), + sa.UniqueConstraint("plan_id", "key", name="uq_plan_limits_plan_key"), + ) + op.create_index("ix_plan_limits_plan_id", "plan_limits", ["plan_id"]) + + op.create_table( + "tenant_subscriptions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + # RESTRICT: deleting a plan somebody is on would silently drop them to + # the defaults. Move them first, deliberately. + sa.Column("plan_id", UUID(as_uuid=True), + sa.ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("started_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancel_at_period_end", sa.Boolean(), nullable=False, + server_default=sa.false()), + sa.Column("external_ref", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + # One live subscription per tenant: a second "current" row is an + # ambiguity nothing can resolve. + sa.UniqueConstraint("tenant_id", name="uq_tenant_subscriptions_tenant"), + ) + op.create_index("ix_tenant_subscriptions_tenant_id", "tenant_subscriptions", ["tenant_id"]) + op.create_index("ix_tenant_subscriptions_plan_id", "tenant_subscriptions", ["plan_id"]) + op.create_index("ix_tenant_subscriptions_status", "tenant_subscriptions", ["status"]) + op.create_index("ix_tenant_subscriptions_external_ref", "tenant_subscriptions", ["external_ref"]) + + op.create_table( + "tenant_limit_overrides", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(50), nullable=False), + sa.Column("value", sa.BigInteger(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("tenant_id", "key", name="uq_tenant_limit_overrides"), + ) + op.create_index("ix_tenant_limit_overrides_tenant_id", "tenant_limit_overrides", ["tenant_id"]) + + # --- seed the catalogue ------------------------------------------------- + for code, name, price, public, order in PLANS: + op.execute( + sa.text( + "INSERT INTO plans (id, code, name, price_display, currency, " + "is_public, sort_order, created_at, updated_at) " + "VALUES (gen_random_uuid(), :code, :name, :price, 'USD', " + ":public, :order, now(), now())" + ).bindparams(code=code, name=name, price=price, public=public, order=order) + ) + for key, value in LIMITS[code].items(): + op.execute( + sa.text( + "INSERT INTO plan_limits (id, plan_id, key, value) " + "SELECT gen_random_uuid(), id, :key, :value FROM plans " + " WHERE code = :code" + ).bindparams(key=key, value=value, code=code) + ) + + # --- backfill, behaviour-neutral ---------------------------------------- + # + # Current values become **overrides**, so every tenant keeps exactly what it + # has regardless of which plan it lands on. The plan is a label until + # somebody removes the override. + op.execute( + """ + INSERT INTO tenant_limit_overrides (id, tenant_id, key, value, note, created_at) + SELECT gen_random_uuid(), t.id, v.key, v.value, + 'Carried over from the tenant row at D1; not a negotiated exception', + now() + FROM tenants t + CROSS JOIN LATERAL (VALUES + ('storage_bytes', t.storage_quota_bytes), + ('seats', COALESCE(t.max_users_allowed, -1)), + ('chat_tokens_daily', t.chat_token_daily_limit), + ('envelopes', t.envelope_limit) + ) AS v(key, value) + WHERE t.is_deleted = false + ON CONFLICT DO NOTHING + """ + ) + + # Every live tenant is placed on `custom`, not on a guessed plan. + # + # Matching limits to a plan would be inference, and inferring a commercial + # fact is how somebody ends up on Starter because their quota happened to + # look like it. `custom` is honest: nobody has said what they are on yet, + # and their overrides mean it changes nothing. + op.execute( + """ + INSERT INTO tenant_subscriptions (id, tenant_id, plan_id, status, + started_at, created_at, updated_at) + SELECT gen_random_uuid(), t.id, p.id, 'active', now(), now(), now() + FROM tenants t + CROSS JOIN (SELECT id FROM plans WHERE code = 'custom') p + WHERE t.is_deleted = false + ON CONFLICT DO NOTHING + """ + ) + + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + + # Safe to reverse: the `tenants` columns were never touched, so removing + # these tables returns the application to the limits it still reads. + op.drop_table("tenant_limit_overrides") + op.drop_table("tenant_subscriptions") + op.drop_table("plan_limits") + op.drop_table("plans") diff --git a/alembic/versions/d2_0_credit_limit_precision.py b/alembic/versions/d2_0_credit_limit_precision.py new file mode 100644 index 0000000..7267d2c --- /dev/null +++ b/alembic/versions/d2_0_credit_limit_precision.py @@ -0,0 +1,61 @@ +"""D2 — a credit limit stops being rounded to a whole number + +`chat_credit_quota.daily_credit_limit` is `bigint` in the database and +`DECIMAL(12, 2)` on the model. PostgreSQL resolves that disagreement by +rounding, silently, on write: + + INSERT ... VALUES (0.60::numeric(12,2)) -> stored: 1 + INSERT ... VALUES (10.75::numeric(12,2)) -> stored: 11 + INSERT ... VALUES (10.25::numeric(12,2)) -> stored: 10 + +So a tenant admin setting a limit of 0.60 credits gets 1 — a 67% overshoot — and +nothing anywhere reports it. Whether that has bitten depends on whether anyone +has entered a fraction, which is not a property worth relying on. + +Widening, so no value is at risk: every bigint currently stored is representable +in `numeric(12, 2)`. `credits_used_today` and `total_credits_ever` are left as +`bigint` deliberately — they count tokens, which are whole. + +The reverse casts back to `bigint` and therefore *does* round. That is the +honest reverse of a widening: going back to a narrower type cannot preserve what +the wider one held. It is written that way rather than left unimplemented so the +migration can actually be reversed, but rolling this back after fractional +limits have been set will round them. + +Found by `alembic revision --autogenerate`, which had never been run against +this schema, and which after the model-registry fix reports this as the single +remaining difference between the models and the database. + +Revision ID: d2_0_credit_limit_precision +Revises: d1_0_plans_and_entitlements +""" + +import sqlalchemy as sa +from alembic import op + +revision = "d2_0_credit_limit_precision" +down_revision = "d1_0_plans_and_entitlements" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "chat_credit_quota", + "daily_credit_limit", + existing_type=sa.BigInteger(), + type_=sa.DECIMAL(precision=12, scale=2), + existing_nullable=False, + postgresql_using="daily_credit_limit::numeric(12,2)", + ) + + +def downgrade() -> None: + op.alter_column( + "chat_credit_quota", + "daily_credit_limit", + existing_type=sa.DECIMAL(precision=12, scale=2), + type_=sa.BigInteger(), + existing_nullable=False, + postgresql_using="round(daily_credit_limit)::bigint", + ) diff --git a/alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py b/alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py new file mode 100644 index 0000000..9927d5b --- /dev/null +++ b/alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py @@ -0,0 +1,60 @@ +"""Add 10MB CHECK constraints on document content fields + +Revision ID: d2e2c55290ee +Revises: 9f6521a38bf3 +Create Date: 2026-03-03 22:17:00.564459 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd2e2c55290ee' +down_revision: Union[str, None] = '9f6521a38bf3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# 10 MB limit in bytes +_MAX = 10 * 1024 * 1024 # 10485760 + + +def upgrade() -> None: + # projects table — three content columns + op.create_check_constraint( + 'ck_project_markdown_max', 'projects', + f'octet_length(markdown_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_project_xml_max', 'projects', + f'octet_length(xml_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_project_html_max', 'projects', + f'octet_length(html_content) <= {_MAX}' + ) + + # project_versions table — three content columns + op.create_check_constraint( + 'ck_version_markdown_max', 'project_versions', + f'octet_length(markdown_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_version_xml_max', 'project_versions', + f'octet_length(xml_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_version_html_max', 'project_versions', + f'octet_length(html_content) <= {_MAX}' + ) + + +def downgrade() -> None: + op.drop_constraint('ck_version_html_max', 'project_versions', type_='check') + op.drop_constraint('ck_version_xml_max', 'project_versions', type_='check') + op.drop_constraint('ck_version_markdown_max', 'project_versions', type_='check') + op.drop_constraint('ck_project_html_max', 'projects', type_='check') + op.drop_constraint('ck_project_xml_max', 'projects', type_='check') + op.drop_constraint('ck_project_markdown_max', 'projects', type_='check') diff --git a/alembic/versions/d551c03861f4_rename_quota_to_credits.py b/alembic/versions/d551c03861f4_rename_quota_to_credits.py new file mode 100644 index 0000000..fc18046 --- /dev/null +++ b/alembic/versions/d551c03861f4_rename_quota_to_credits.py @@ -0,0 +1,38 @@ +"""rename_quota_to_credits + +Revision ID: d551c03861f4 +Revises: 540c970ff890 +Create Date: 2026-05-13 13:01:51.510123 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd551c03861f4' +down_revision: Union[str, None] = '540c970ff890' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Rename Table + op.rename_table('chat_token_quota', 'chat_credit_quota') + + # Rename Columns in chat_credit_quota + op.alter_column('chat_credit_quota', 'daily_limit', new_column_name='daily_credit_limit') + op.alter_column('chat_credit_quota', 'tokens_used_today', new_column_name='credits_used_today') + op.alter_column('chat_credit_quota', 'total_tokens_ever', new_column_name='total_credits_ever') + + +def downgrade() -> None: + # Rename Columns back + op.alter_column('chat_credit_quota', 'daily_credit_limit', new_column_name='daily_limit') + op.alter_column('chat_credit_quota', 'credits_used_today', new_column_name='tokens_used_today') + op.alter_column('chat_credit_quota', 'total_credits_ever', new_column_name='total_tokens_ever') + + # Rename Table back + op.rename_table('chat_credit_quota', 'chat_token_quota') diff --git a/alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py b/alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py new file mode 100644 index 0000000..9ee0286 --- /dev/null +++ b/alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py @@ -0,0 +1,34 @@ +"""Add envelope limits to tenants + +Revision ID: daf45366d608 +Revises: 611d6b99ad11 +Create Date: 2026-07-18 10:34:04.536445 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'daf45366d608' +down_revision: Union[str, None] = '611d6b99ad11' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('tenants', sa.Column('envelope_limit', sa.BigInteger(), nullable=False, server_default='-1')) + op.add_column('tenants', sa.Column('envelopes_used', sa.BigInteger(), nullable=False, server_default='0')) + op.add_column('tenants', sa.Column('envelope_reset_days', sa.Integer(), nullable=True)) + op.add_column('tenants', sa.Column('envelope_last_reset_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) + pass + + +def downgrade() -> None: + op.drop_column('tenants', 'envelope_last_reset_at') + op.drop_column('tenants', 'envelope_reset_days') + op.drop_column('tenants', 'envelopes_used') + op.drop_column('tenants', 'envelope_limit') + pass diff --git a/alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py b/alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py new file mode 100644 index 0000000..b66df2c --- /dev/null +++ b/alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py @@ -0,0 +1,38 @@ +"""add color to drive comments + +Revision ID: dbaa8ef522c0 +Revises: 8736ab6bd5af +Create Date: 2026-02-26 14:23:14.999888 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'dbaa8ef522c0' +down_revision: Union[str, None] = '8736ab6bd5af' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_comments', sa.Column('color', sa.String(length=20), nullable=True)) + op.alter_column('notifications', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_column('drive_comments', 'color') + # ### end Alembic commands ### diff --git a/alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py b/alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py new file mode 100644 index 0000000..0f745b9 --- /dev/null +++ b/alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py @@ -0,0 +1,28 @@ +"""device constraints and session updates + +Revision ID: dfae38e4ede3 +Revises: 9c49fe120ffb +Create Date: 2026-08-07 11:03:54.251473 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'dfae38e4ede3' +down_revision: Union[str, None] = '9c49fe120ffb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + op.create_unique_constraint('uq_device_user_fingerprint', 'devices', ['user_id', 'fingerprint']) + + +def downgrade() -> None: + op.drop_constraint('uq_device_user_fingerprint', 'devices', type_='unique') diff --git a/alembic/versions/e1_0_add_saas_plan_id.py b/alembic/versions/e1_0_add_saas_plan_id.py new file mode 100644 index 0000000..9cf4ff4 --- /dev/null +++ b/alembic/versions/e1_0_add_saas_plan_id.py @@ -0,0 +1,32 @@ +"""add saas_plan_id to plans + +Revision ID: e1_0_add_saas_plan_id +Revises: 9c4a95ac6726 +Create Date: 2026-09-01 + +Adds a nullable `saas_plan_id` column to the `plans` table so that DocQube +can trace each plan back to the SaaS SubscriptionPlan that originated it via +the provision webhook. Plans created directly inside DocQube will have NULL. +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "e1_0_add_saas_plan_id" +down_revision = "9c4a95ac6726" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "plans", + sa.Column("saas_plan_id", sa.String(255), nullable=True), + ) + op.create_index("ix_plans_saas_plan_id", "plans", ["saas_plan_id"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_plans_saas_plan_id", table_name="plans") + op.drop_column("plans", "saas_plan_id") diff --git a/alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py b/alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py new file mode 100644 index 0000000..dec3a3b --- /dev/null +++ b/alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py @@ -0,0 +1,116 @@ +"""migrate_roles_access_to_uuid + +Revision ID: e81a9d22ca5f +Revises: a2b3c4d5e6f7 +Create Date: 2026-03-05 14:42:53.769667 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e81a9d22ca5f' +down_revision: Union[str, None] = 'a2b3c4d5e6f7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- Users Table --- + # Drop FK pointing to roles + op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey') + + # --- RoleAccess Table --- + # Drop FKs + op.drop_constraint('role_accesses_role_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('role_accesses_access_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('pk_role_accesses', 'role_accesses', type_='primary') + + # --- Accesses Table --- + # FK was named 'fk_accesses_parent_id' in migration a2b3c4d5e6f7 + op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey') + + # --- Alter Columns to UUID --- + # Drop serial defaults before changing type + op.execute('ALTER TABLE accesses ALTER COLUMN id DROP DEFAULT') + op.execute('ALTER TABLE roles ALTER COLUMN id DROP DEFAULT') + + # Change types to UUID (tables should be empty) + op.execute('ALTER TABLE accesses ALTER COLUMN id TYPE uuid USING (gen_random_uuid())') + op.execute('ALTER TABLE accesses ALTER COLUMN parent_id TYPE uuid USING NULL') + + op.execute('ALTER TABLE roles ALTER COLUMN id TYPE uuid USING (gen_random_uuid())') + + op.execute('ALTER TABLE role_accesses ALTER COLUMN role_id TYPE uuid USING (gen_random_uuid())') + op.execute('ALTER TABLE role_accesses ALTER COLUMN access_id TYPE uuid USING (gen_random_uuid())') + + op.execute('ALTER TABLE users ALTER COLUMN role_id TYPE uuid USING NULL') + + # Set UUID defaults for primary keys + op.execute('ALTER TABLE accesses ALTER COLUMN id SET DEFAULT gen_random_uuid()') + op.execute('ALTER TABLE roles ALTER COLUMN id SET DEFAULT gen_random_uuid()') + + # Drop leftover serial sequences + op.execute('DROP SEQUENCE IF EXISTS accesses_id_seq CASCADE') + op.execute('DROP SEQUENCE IF EXISTS roles_id_seq CASCADE') + + # --- Re-add Constraints --- + # Accesses + op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE') + + # RoleAccess + op.create_primary_key('pk_role_accesses', 'role_accesses', ['role_id', 'access_id']) + op.create_foreign_key('role_accesses_role_id_fkey', 'role_accesses', 'roles', ['role_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key('role_accesses_access_id_fkey', 'role_accesses', 'accesses', ['access_id'], ['id'], ondelete='CASCADE') + + # Users + op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'], ondelete='SET NULL') + + +def downgrade() -> None: + # --- Users Table --- + op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey') + + # --- RoleAccess Table --- + op.drop_constraint('role_accesses_role_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('role_accesses_access_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('pk_role_accesses', 'role_accesses', type_='primary') + + # --- Accesses Table --- + op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey') + + # Truncate all tables (UUID -> INT cast is destructive) + op.execute('TRUNCATE TABLE role_accesses CASCADE') + op.execute('TRUNCATE TABLE users CASCADE') + op.execute('TRUNCATE TABLE accesses CASCADE') + op.execute('TRUNCATE TABLE roles CASCADE') + + # Drop UUID defaults + op.execute('ALTER TABLE accesses ALTER COLUMN id DROP DEFAULT') + op.execute('ALTER TABLE roles ALTER COLUMN id DROP DEFAULT') + + # Change types back to integer + op.execute('ALTER TABLE accesses ALTER COLUMN id TYPE integer USING 0') + op.execute('ALTER TABLE accesses ALTER COLUMN parent_id TYPE integer USING NULL') + op.execute('ALTER TABLE roles ALTER COLUMN id TYPE integer USING 0') + op.execute('ALTER TABLE role_accesses ALTER COLUMN role_id TYPE integer USING 0') + op.execute('ALTER TABLE role_accesses ALTER COLUMN access_id TYPE integer USING 0') + op.execute('ALTER TABLE users ALTER COLUMN role_id TYPE integer USING NULL') + + # Recreate serial sequences + op.execute("CREATE SEQUENCE accesses_id_seq OWNED BY accesses.id") + op.execute("ALTER TABLE accesses ALTER COLUMN id SET DEFAULT nextval('accesses_id_seq')") + op.execute("CREATE SEQUENCE roles_id_seq OWNED BY roles.id") + op.execute("ALTER TABLE roles ALTER COLUMN id SET DEFAULT nextval('roles_id_seq')") + + # --- Re-add Constraints --- + op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE') + + op.create_primary_key('pk_role_accesses', 'role_accesses', ['role_id', 'access_id']) + op.create_foreign_key('role_accesses_role_id_fkey', 'role_accesses', 'roles', ['role_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key('role_accesses_access_id_fkey', 'role_accesses', 'accesses', ['access_id'], ['id'], ondelete='CASCADE') + + op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'], ondelete='SET NULL') diff --git a/alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py b/alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py new file mode 100644 index 0000000..128e295 --- /dev/null +++ b/alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py @@ -0,0 +1,36 @@ +"""Add cascade deletes to drive_file_versions + +Revision ID: f055f9b157cb +Revises: a902935ea69e +Create Date: 2026-03-02 11:08:38.477933 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f055f9b157cb' +down_revision: Union[str, None] = 'a902935ea69e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('drive_file_versions_file_id_fkey', 'drive_file_versions', type_='foreignkey') + op.create_foreign_key(None, 'drive_file_versions', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('drive_stars_user_id_fkey', 'drive_stars', type_='foreignkey') + op.create_foreign_key(None, 'drive_stars', 'users', ['user_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'drive_stars', type_='foreignkey') + op.create_foreign_key('drive_stars_user_id_fkey', 'drive_stars', 'users', ['user_id'], ['id']) + op.drop_constraint(None, 'drive_file_versions', type_='foreignkey') + op.create_foreign_key('drive_file_versions_file_id_fkey', 'drive_file_versions', 'drive_files', ['file_id'], ['id']) + # ### end Alembic commands ### diff --git a/alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py b/alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py new file mode 100644 index 0000000..9073fa3 --- /dev/null +++ b/alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py @@ -0,0 +1,34 @@ +"""Add has_completed_tutorial to User + +Revision ID: fb6810c36ffb +Revises: 229359b0d1b2 +Create Date: 2026-04-02 11:27:02.719590 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fb6810c36ffb' +down_revision: Union[str, None] = '229359b0d1b2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('has_completed_tutorial', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('has_completed_tutorial') + + # ### end Alembic commands ### diff --git a/alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py b/alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py new file mode 100644 index 0000000..b3d8593 --- /dev/null +++ b/alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py @@ -0,0 +1,30 @@ +"""Add ip_address to drive_activities + +Revision ID: ff8052a97834 +Revises: 765b053e3dcd +Create Date: 2026-03-03 21:48:41.907463 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ff8052a97834' +down_revision: Union[str, None] = '765b053e3dcd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_activities', sa.Column('ip_address', sa.String(length=64), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('drive_activities', 'ip_address') + # ### end Alembic commands ### diff --git a/alembic/versions/ff9052a97835_add_multi_tenancy.py b/alembic/versions/ff9052a97835_add_multi_tenancy.py new file mode 100644 index 0000000..01dca5f --- /dev/null +++ b/alembic/versions/ff9052a97835_add_multi_tenancy.py @@ -0,0 +1,104 @@ +"""Add multi_tenancy + +Revision ID: ff9052a97835 +Revises: ff8052a97834 +Create Date: 2026-03-05 10:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = "ff9052a97835" +down_revision: Union[str, None] = "ff8052a97834" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create tenants table + op.execute( + """ + CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + """ + ) + + + # 3. Add tenant_id columns (nullable first) + tables = [ + "users", + "projects", + "drive_folders", + "drive_files", + "drive_activities", + "chatbot_documents", + "notifications", + "user_files", + ] + for table in tables: + op.execute( + f"ALTER TABLE {table} ADD COLUMN tenant_id UUID REFERENCES tenants(id);" + ) + + # 4. Backfill existing data + for table in tables: + op.execute( + f"UPDATE {table} SET tenant_id = '00000000-0000-0000-0000-000000000001';" + ) + + # 5. Set NOT NULL (except users) + not_null_tables = [ + "projects", + "drive_folders", + "drive_files", + "drive_activities", + "chatbot_documents", + "notifications", + "user_files", + ] + for table in not_null_tables: + op.execute(f"ALTER TABLE {table} ALTER COLUMN tenant_id SET NOT NULL;") + + # 6. Create indexes + for table in tables: + op.execute(f"CREATE INDEX ix_{table}_tenant_id ON {table}(tenant_id);") + + # 7. Constraint unique (tenant_id, email) + op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;") + op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_email;") + op.execute( + "ALTER TABLE users ADD CONSTRAINT uq_user_tenant_email UNIQUE (tenant_id, email);" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_user_tenant_email;") + op.execute("ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);") + + tables = [ + "users", + "projects", + "drive_folders", + "drive_files", + "drive_activities", + "chatbot_documents", + "notifications", + "user_files", + ] + for table in tables: + op.execute(f"DROP INDEX IF EXISTS ix_{table}_tenant_id;") + op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS tenant_id;") + + op.execute("DROP TABLE IF EXISTS tenants;") diff --git a/alembic/versions/g001_add_signing_requests_table.py b/alembic/versions/g001_add_signing_requests_table.py new file mode 100644 index 0000000..11afce5 --- /dev/null +++ b/alembic/versions/g001_add_signing_requests_table.py @@ -0,0 +1,38 @@ +"""add signing_requests table + +Revision ID: g001_signing +Revises: c7d4e6f1a9b0 +Create Date: 2026-03-06 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSON + +# revision identifiers +revision = "g001_signing" +down_revision = "c7d4e6f1a9b0" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "signing_requests", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id"), nullable=False, index=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("drive_file_id", sa.Integer, sa.ForeignKey("drive_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("docuseal_template_id", sa.Integer, nullable=True), + sa.Column("docuseal_submission_id", sa.Integer, nullable=True), + sa.Column("docuseal_slug", sa.String(255), nullable=True), + sa.Column("signing_type", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("submitters_json", JSON, nullable=True), + sa.Column("signed_file_id", sa.Integer, sa.ForeignKey("drive_files.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade(): + op.drop_table("signing_requests") diff --git a/alembic/versions/g002_replace_docuseal_with_placements.py b/alembic/versions/g002_replace_docuseal_with_placements.py new file mode 100644 index 0000000..065c06f --- /dev/null +++ b/alembic/versions/g002_replace_docuseal_with_placements.py @@ -0,0 +1,41 @@ +"""replace docuseal columns with placements_json + +Revision ID: g002_placements +Revises: g001_signing +Create Date: 2026-03-07 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + +# revision identifiers +revision = "g002_placements" +down_revision = "g001_signing" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column("signing_requests", "docuseal_template_id") + op.drop_column("signing_requests", "docuseal_submission_id") + op.drop_column("signing_requests", "docuseal_slug") + op.add_column( + "signing_requests", + sa.Column("placements_json", JSON, nullable=True), + ) + + +def downgrade(): + op.drop_column("signing_requests", "placements_json") + op.add_column( + "signing_requests", + sa.Column("docuseal_template_id", sa.Integer, nullable=True), + ) + op.add_column( + "signing_requests", + sa.Column("docuseal_submission_id", sa.Integer, nullable=True), + ) + op.add_column( + "signing_requests", + sa.Column("docuseal_slug", sa.String(255), nullable=True), + ) diff --git a/alembic/versions/h003_add_tenant_smtp_settings.py b/alembic/versions/h003_add_tenant_smtp_settings.py new file mode 100644 index 0000000..650633d --- /dev/null +++ b/alembic/versions/h003_add_tenant_smtp_settings.py @@ -0,0 +1,76 @@ +"""add tenant smtp settings + +Revision ID: h003_tenant_smtp +Revises: g002_placements +Create Date: 2026-03-09 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "h003_tenant_smtp" +down_revision: Union[str, None] = "g002_placements" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("tenant_smtp_configs"): + op.create_table( + "tenant_smtp_configs", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + nullable=False, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("smtp_host", sa.String(length=255), nullable=False), + sa.Column("smtp_port", sa.Integer(), nullable=False, server_default=sa.text("587")), + sa.Column("smtp_user", sa.String(length=255), nullable=True), + sa.Column("encrypted_smtp_password", sa.String(length=512), nullable=True), + sa.Column("smtp_secure", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("mail_from", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_by_id", sa.Integer(), nullable=True), + sa.Column("updated_by_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", name="uq_tenant_smtp_configs_tenant_id"), + ) + + existing_indexes = {idx["name"] for idx in inspector.get_indexes("tenant_smtp_configs")} + if "ix_tenant_smtp_configs_tenant_id" not in existing_indexes: + op.create_index( + "ix_tenant_smtp_configs_tenant_id", + "tenant_smtp_configs", + ["tenant_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_tenant_smtp_configs_tenant_id", table_name="tenant_smtp_configs") + op.drop_table("tenant_smtp_configs") diff --git a/alembic/versions/h004_add_signing_forensic_columns.py b/alembic/versions/h004_add_signing_forensic_columns.py new file mode 100644 index 0000000..424a3ac --- /dev/null +++ b/alembic/versions/h004_add_signing_forensic_columns.py @@ -0,0 +1,48 @@ +"""add forensic metadata columns to signing_requests + +Revision ID: h004_signing_forensic_v2 +Revises: h004_limits_soft_delete +Create Date: 2026-03-09 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + + +# revision identifiers, used by Alembic. +revision: str = "h004_signing_forensic_v2" +down_revision: Union[str, None] = "h004_limits_soft_delete" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Safe: only add if column doesn't already exist (idempotent for existing DBs) + conn = op.get_bind() + inspector = sa.inspect(conn) + existing = {c["name"] for c in inspector.get_columns("signing_requests")} + + if "ip_address" not in existing: + op.add_column( + "signing_requests", + sa.Column("ip_address", sa.String(64), nullable=True), + ) + if "user_agent" not in existing: + op.add_column( + "signing_requests", + sa.Column("user_agent", sa.String(512), nullable=True), + ) + if "signing_metadata" not in existing: + op.add_column( + "signing_requests", + sa.Column("signing_metadata", JSON, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("signing_requests", "signing_metadata") + op.drop_column("signing_requests", "user_agent") + op.drop_column("signing_requests", "ip_address") diff --git a/alembic/versions/h004_limits_and_soft_delete.py b/alembic/versions/h004_limits_and_soft_delete.py new file mode 100644 index 0000000..57f82f0 --- /dev/null +++ b/alembic/versions/h004_limits_and_soft_delete.py @@ -0,0 +1,108 @@ +"""add limits config and soft delete columns + +Revision ID: h004_limits_soft_delete +Revises: h003_tenant_smtp +Create Date: 2026-03-09 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "h004_limits_soft_delete" +down_revision: Union[str, None] = "h003_tenant_smtp" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")) + ) + op.add_column( + "users", sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")) + ) + op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("deleted_by_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_users_deleted_by_id_users", + "users", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + op.add_column( + "tenants", sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")) + ) + op.add_column("tenants", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("tenants", sa.Column("deleted_by_id", sa.Integer(), nullable=True)) + op.add_column("tenants", sa.Column("max_users_allowed", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_tenants_deleted_by_id_users", + "tenants", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + op.create_table( + "system_configurations", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + nullable=False, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("config_key", sa.String(length=100), nullable=False), + sa.Column("int_value", sa.BigInteger(), nullable=True), + sa.Column("bool_value", sa.Boolean(), nullable=True), + sa.Column("text_value", sa.Text(), nullable=True), + sa.Column("created_by_id", sa.Integer(), nullable=True), + sa.Column("updated_by_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("config_key", name="uq_system_configurations_config_key"), + ) + op.create_index( + "ix_system_configurations_config_key", + "system_configurations", + ["config_key"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_system_configurations_config_key", table_name="system_configurations") + op.drop_table("system_configurations") + + op.drop_constraint("fk_tenants_deleted_by_id_users", "tenants", type_="foreignkey") + op.drop_column("tenants", "max_users_allowed") + op.drop_column("tenants", "deleted_by_id") + op.drop_column("tenants", "deleted_at") + op.drop_column("tenants", "is_deleted") + + op.drop_constraint("fk_users_deleted_by_id_users", "users", type_="foreignkey") + op.drop_column("users", "deleted_by_id") + op.drop_column("users", "deleted_at") + op.drop_column("users", "is_deleted") + op.drop_column("users", "is_active") diff --git a/alembic/versions/i001_dual_bucket_storage.py b/alembic/versions/i001_dual_bucket_storage.py new file mode 100644 index 0000000..40a92fe --- /dev/null +++ b/alembic/versions/i001_dual_bucket_storage.py @@ -0,0 +1,56 @@ +"""Replace b2_bucket_name with b2_quarantine_bucket and b2_clean_bucket + +Revision ID: i001_dual_bucket_storage +Revises: 9557c7307575 +Create Date: 2026-03-17 08:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'i001_dual_bucket_storage' +down_revision = '9557c7307575' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add the two new bucket columns (nullable at first) + op.add_column( + 'tenant_storage_configs', + sa.Column('b2_quarantine_bucket', sa.String(255), nullable=True) + ) + op.add_column( + 'tenant_storage_configs', + sa.Column('b2_clean_bucket', sa.String(255), nullable=True) + ) + + # 2. Migrate existing b2_bucket_name to quarantine (best-effort) + op.execute(""" + UPDATE tenant_storage_configs + SET b2_quarantine_bucket = b2_bucket_name + WHERE b2_bucket_name IS NOT NULL + """) + + # 3. Drop the old column + op.drop_column('tenant_storage_configs', 'b2_bucket_name') + + +def downgrade() -> None: + # 1. Restore the old column + op.add_column( + 'tenant_storage_configs', + sa.Column('b2_bucket_name', sa.String(255), nullable=True) + ) + + # 2. Restore data from quarantine bucket (best-effort) + op.execute(""" + UPDATE tenant_storage_configs + SET b2_bucket_name = b2_quarantine_bucket + WHERE b2_quarantine_bucket IS NOT NULL + """) + + # 3. Drop the new columns + op.drop_column('tenant_storage_configs', 'b2_clean_bucket') + op.drop_column('tenant_storage_configs', 'b2_quarantine_bucket') diff --git a/alembic/versions/j001_comments_fix.py b/alembic/versions/j001_comments_fix.py new file mode 100644 index 0000000..ef7c144 --- /dev/null +++ b/alembic/versions/j001_comments_fix.py @@ -0,0 +1,31 @@ +"""Add rejected_at column to drive_comments + +Revision ID: j001_comments_fix +Revises: c3abf44266de +Create Date: 2026-03-24 + +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "j001_comments_fix" +down_revision: Union[str, tuple, None] = "c3abf44266de" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE drive_comments + ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP WITH TIME ZONE DEFAULT NULL + """) + + +def downgrade() -> None: + op.execute(""" + ALTER TABLE drive_comments + DROP COLUMN IF EXISTS rejected_at + """) diff --git a/alembic/versions/j002_allow_unlimited_tenant_quota.py b/alembic/versions/j002_allow_unlimited_tenant_quota.py new file mode 100644 index 0000000..745ee9e --- /dev/null +++ b/alembic/versions/j002_allow_unlimited_tenant_quota.py @@ -0,0 +1,38 @@ +"""allow unlimited tenant storage quota sentinel + +Revision ID: j002_unlimited_tenant_quota +Revises: j001_comments_fix +Create Date: 2026-03-25 +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "j002_unlimited_tenant_quota" +down_revision: Union[str, None] = "j001_comments_fix" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +CONSTRAINT_NAME = "ck_tenants_storage_quota_bytes_positive" + + +def upgrade() -> None: + op.drop_constraint(CONSTRAINT_NAME, "tenants", type_="check") + op.create_check_constraint( + CONSTRAINT_NAME, + "tenants", + "storage_quota_bytes = -1 OR storage_quota_bytes > 0", + ) + + +def downgrade() -> None: + op.drop_constraint(CONSTRAINT_NAME, "tenants", type_="check") + op.create_check_constraint( + CONSTRAINT_NAME, + "tenants", + "storage_quota_bytes > 0", + ) diff --git a/alembic/versions/k001_limit_signature_id_to_15_chars.py b/alembic/versions/k001_limit_signature_id_to_15_chars.py new file mode 100644 index 0000000..695957b --- /dev/null +++ b/alembic/versions/k001_limit_signature_id_to_15_chars.py @@ -0,0 +1,42 @@ +"""limit signature_id to 15 characters + +Revision ID: k001_limit_signature_id_15 +Revises: 56d090047e85 +Create Date: 2026-04-15 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "k001_limit_signature_id_15" +down_revision: Union[str, None] = "b029d2f5a0c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Truncate any existing values that exceed 15 chars before altering the column + op.execute( + "UPDATE signing_requests SET signature_id = LEFT(signature_id, 15) WHERE LENGTH(signature_id) > 15" + ) + op.alter_column( + "signing_requests", + "signature_id", + existing_type=sa.String(128), + type_=sa.String(15), + existing_nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "signing_requests", + "signature_id", + existing_type=sa.String(15), + type_=sa.String(128), + existing_nullable=True, + ) diff --git a/alembic/versions/k002_add_docs_tutorial_flag.py b/alembic/versions/k002_add_docs_tutorial_flag.py new file mode 100644 index 0000000..a074dbc --- /dev/null +++ b/alembic/versions/k002_add_docs_tutorial_flag.py @@ -0,0 +1,28 @@ +"""Add has_completed_docs_tutorial to User + +Revision ID: k002_add_docs_tutorial +Revises: l001_merge_heads +Create Date: 2026-04-17 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'k002_add_docs_tutorial' +down_revision: Union[str, None] = 'l001_merge_heads' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('has_completed_docs_tutorial', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('has_completed_docs_tutorial') diff --git a/alembic/versions/l001_merge_heads.py b/alembic/versions/l001_merge_heads.py new file mode 100644 index 0000000..7a28fe6 --- /dev/null +++ b/alembic/versions/l001_merge_heads.py @@ -0,0 +1,25 @@ +"""merge all heads into single linear chain + +Revision ID: l001_merge_heads +Revises: b029d2f5a0c7, k001_limit_signature_id_15 +Create Date: 2026-04-15 +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "l001_merge_heads" +down_revision: Union[str, tuple] = ("56d090047e85", "k001_limit_signature_id_15") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/m001_add_activity_logs_table.py b/alembic/versions/m001_add_activity_logs_table.py new file mode 100644 index 0000000..0ca9ede --- /dev/null +++ b/alembic/versions/m001_add_activity_logs_table.py @@ -0,0 +1,87 @@ +"""add activity_logs table + +Revision ID: m001_add_activity_logs +Revises: 9af99fc7ca0f +Create Date: 2026-04-20 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'm001_add_activity_logs' +down_revision: Union[str, None] = '9af99fc7ca0f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_INDEXES = [ + ("ix_activity_logs_tenant_created_at", ["tenant_id", "created_at"]), + ("ix_activity_logs_user_created_at", ["user_id", "created_at"]), + ("ix_activity_logs_module_action", ["module", "action"]), + ("ix_activity_logs_target_id", ["target_id"]), +] + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("activity_logs"): + op.create_table( + "activity_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("user_name", sa.String(length=255), nullable=True), + sa.Column("user_email", sa.String(length=255), nullable=True), + sa.Column("module", sa.String(length=50), nullable=False), + sa.Column("action", sa.String(length=100), nullable=False), + sa.Column("target_id", sa.String(length=255), nullable=False), + sa.Column("target_type", sa.String(length=50), nullable=False), + sa.Column("target_name", sa.String(length=512), nullable=True), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("ip_address", sa.String(length=64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("request_id", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + existing = _index_names(bind, "activity_logs") + for name, cols in _INDEXES: + if name not in existing: + op.create_index(name, "activity_logs", cols, unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table("activity_logs"): + existing = _index_names(bind, "activity_logs") + for name, _ in reversed(_INDEXES): + if name in existing: + op.drop_index(name, table_name="activity_logs") + + op.drop_table("activity_logs") diff --git a/alembic/versions/n001_add_user_designation.py b/alembic/versions/n001_add_user_designation.py new file mode 100644 index 0000000..c5bf857 --- /dev/null +++ b/alembic/versions/n001_add_user_designation.py @@ -0,0 +1,29 @@ +"""add designation column to users + +Revision ID: n001_add_user_designation +Revises: m001_add_activity_logs +Create Date: 2026-04-21 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'n001_add_user_designation' +down_revision: Union[str, None] = 'm001_add_activity_logs' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'users', + sa.Column('designation', sa.String(length=100), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('users', 'designation') diff --git a/alembic/versions/n002_add_signature_imprints.py b/alembic/versions/n002_add_signature_imprints.py new file mode 100644 index 0000000..6c4551c --- /dev/null +++ b/alembic/versions/n002_add_signature_imprints.py @@ -0,0 +1,62 @@ +"""add signature_imprints table + +Revision ID: n002_add_signature_imprints +Revises: n001_add_user_designation +Create Date: 2026-04-21 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'n002_add_signature_imprints' +down_revision: Union[str, None] = 'n001_add_user_designation' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'signature_imprints', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + 'signing_request_id', + postgresql.UUID(as_uuid=True), + sa.ForeignKey('signing_requests.id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column( + 'drive_file_id', + sa.Integer(), + sa.ForeignKey('drive_files.id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('page_number', sa.Integer(), nullable=False), + sa.Column('bbox_points', postgresql.JSON(), nullable=False), + sa.Column( + 'created_at', + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index( + 'ix_signature_imprints_file_page', + 'signature_imprints', + ['drive_file_id', 'page_number'], + ) + op.create_index( + 'ix_signature_imprints_signing_request', + 'signature_imprints', + ['signing_request_id'], + ) + + +def downgrade() -> None: + op.drop_index('ix_signature_imprints_signing_request', table_name='signature_imprints') + op.drop_index('ix_signature_imprints_file_page', table_name='signature_imprints') + op.drop_table('signature_imprints') diff --git a/alembic/versions/v3_extraction_data.py b/alembic/versions/v3_extraction_data.py new file mode 100644 index 0000000..27cd8a7 --- /dev/null +++ b/alembic/versions/v3_extraction_data.py @@ -0,0 +1,57 @@ +"""Add extraction_data table + +Revision ID: v3_extraction +Revises: l001_merge_heads +Create Date: 2026-04-18 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'v3_extraction' +down_revision: Union[str, None] = 'l001_merge_heads' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('extraction_data'): + op.create_table('extraction_data', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Integer(), nullable=False), + sa.Column('document_type', sa.String(length=50), nullable=True), + sa.Column('raw_text', sa.Text(), nullable=True), + sa.Column('cleaned_text', sa.Text(), nullable=True), + sa.Column('extracted_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('confidence_score', sa.Float(), nullable=True), + sa.Column('extraction_status', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['document_id'], ['drive_files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + if 'idx_extraction_data_document_id' not in _index_names(bind, 'extraction_data'): + op.create_index(op.f('idx_extraction_data_document_id'), 'extraction_data', ['document_id'], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table('extraction_data'): + if 'idx_extraction_data_document_id' in _index_names(bind, 'extraction_data'): + op.drop_index(op.f('idx_extraction_data_document_id'), table_name='extraction_data') + op.drop_table('extraction_data') diff --git a/alembic/versions/w001_versioning_minor_and_version_fk.py b/alembic/versions/w001_versioning_minor_and_version_fk.py new file mode 100644 index 0000000..6a9e156 --- /dev/null +++ b/alembic/versions/w001_versioning_minor_and_version_fk.py @@ -0,0 +1,137 @@ +"""add minor_version + version-level FKs for signing artefacts + +Revision ID: w001_versioning_minor +Revises: v3_extraction, n002_add_signature_imprints, 39d5fdfb5386 +Create Date: 2026-04-28 + +Schema changes: + - drive_file_versions.minor_version (String(8), nullable) + - signing_requests.drive_file_version_id (Integer, nullable, FK) + - signature_imprints.drive_file_version_id (Integer, nullable, FK) + - Composite index on drive_file_versions(file_id, version_number, minor_version) + +Also serves as the merge revision for the three existing heads +('v3_extraction', 'n002_add_signature_imprints', '39d5fdfb5386'). +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "w001_versioning_minor" +down_revision: Union[str, tuple] = ( + "v3_extraction", + "n002_add_signature_imprints", + "39d5fdfb5386", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _index_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + # 1) drive_file_versions.minor_version + if "minor_version" not in _column_names(bind, "drive_file_versions"): + op.add_column( + "drive_file_versions", + sa.Column("minor_version", sa.String(length=8), nullable=True), + ) + + if "ix_drive_file_versions_file_major_minor" not in _index_names( + bind, "drive_file_versions" + ): + op.create_index( + "ix_drive_file_versions_file_major_minor", + "drive_file_versions", + ["file_id", "version_number", "minor_version"], + unique=False, + ) + + # 2) signing_requests.drive_file_version_id + if "drive_file_version_id" not in _column_names(bind, "signing_requests"): + op.add_column( + "signing_requests", + sa.Column("drive_file_version_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_signing_requests_drive_file_version_id", + "signing_requests", + "drive_file_versions", + ["drive_file_version_id"], + ["id"], + ondelete="SET NULL", + ) + + # 3) signature_imprints.drive_file_version_id + if "drive_file_version_id" not in _column_names(bind, "signature_imprints"): + op.add_column( + "signature_imprints", + sa.Column("drive_file_version_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_signature_imprints_drive_file_version_id", + "signature_imprints", + "drive_file_versions", + ["drive_file_version_id"], + ["id"], + ondelete="SET NULL", + ) + if "ix_signature_imprints_version_id" not in _index_names( + bind, "signature_imprints" + ): + op.create_index( + "ix_signature_imprints_version_id", + "signature_imprints", + ["drive_file_version_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + + if "drive_file_version_id" in _column_names(bind, "signature_imprints"): + if "ix_signature_imprints_version_id" in _index_names( + bind, "signature_imprints" + ): + op.drop_index( + "ix_signature_imprints_version_id", + table_name="signature_imprints", + ) + op.drop_constraint( + "fk_signature_imprints_drive_file_version_id", + "signature_imprints", + type_="foreignkey", + ) + op.drop_column("signature_imprints", "drive_file_version_id") + + if "drive_file_version_id" in _column_names(bind, "signing_requests"): + op.drop_constraint( + "fk_signing_requests_drive_file_version_id", + "signing_requests", + type_="foreignkey", + ) + op.drop_column("signing_requests", "drive_file_version_id") + + if "minor_version" in _column_names(bind, "drive_file_versions"): + if "ix_drive_file_versions_file_major_minor" in _index_names( + bind, "drive_file_versions" + ): + op.drop_index( + "ix_drive_file_versions_file_major_minor", + table_name="drive_file_versions", + ) + op.drop_column("drive_file_versions", "minor_version") diff --git a/alembic/versions/x001_add_user_smtp_credentials.py b/alembic/versions/x001_add_user_smtp_credentials.py new file mode 100644 index 0000000..bca35ca --- /dev/null +++ b/alembic/versions/x001_add_user_smtp_credentials.py @@ -0,0 +1,48 @@ +"""add user smtp credential columns + +Revision ID: x001_user_smtp_credentials +Revises: w001_versioning_minor +Create Date: 2026-04-30 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "x001_user_smtp_credentials" +down_revision: Union[str, None] = "w001_versioning_minor" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "smtp_user" not in column_names: + op.add_column("users", sa.Column("smtp_user", sa.String(length=255), nullable=True)) + + if "encrypted_smtp_password" not in column_names: + op.add_column( + "users", + sa.Column("encrypted_smtp_password", sa.String(length=512), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "encrypted_smtp_password" in column_names: + op.drop_column("users", "encrypted_smtp_password") + + if "smtp_user" in column_names: + op.drop_column("users", "smtp_user") diff --git a/alembic/versions/y001_add_user_mail_from.py b/alembic/versions/y001_add_user_mail_from.py new file mode 100644 index 0000000..33f2130 --- /dev/null +++ b/alembic/versions/y001_add_user_mail_from.py @@ -0,0 +1,39 @@ +"""add user mail_from column + +Revision ID: y001_add_user_mail_from +Revises: x001_user_smtp_credentials +Create Date: 2026-04-30 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "y001_add_user_mail_from" +down_revision: Union[str, None] = "x001_user_smtp_credentials" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "mail_from" not in column_names: + op.add_column("users", sa.Column("mail_from", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "mail_from" in column_names: + op.drop_column("users", "mail_from") diff --git a/alembic/versions/z002_pref_lang.py b/alembic/versions/z002_pref_lang.py new file mode 100644 index 0000000..f377765 --- /dev/null +++ b/alembic/versions/z002_pref_lang.py @@ -0,0 +1,45 @@ +"""add preferred_language to users + +Revision ID: z002_pref_lang +Revises: ca14c4ab10d9 +Create Date: 2026-05-08 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "z002_pref_lang" +down_revision: Union[str, None] = "ca14c4ab10d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "preferred_language" not in column_names: + op.add_column( + "users", + sa.Column("preferred_language", sa.String(length=16), nullable=True), + ) + op.execute( + "UPDATE users SET preferred_language = 'en' WHERE preferred_language IS NULL" + ) + op.alter_column("users", "preferred_language", nullable=False) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "preferred_language" in column_names: + op.drop_column("users", "preferred_language") diff --git a/alembic/versions/z003_add_disable_pdf_compression_to_tenants.py b/alembic/versions/z003_add_disable_pdf_compression_to_tenants.py new file mode 100644 index 0000000..16ede8b --- /dev/null +++ b/alembic/versions/z003_add_disable_pdf_compression_to_tenants.py @@ -0,0 +1,41 @@ +"""add disable_pdf_compression to tenants + +Revision ID: z003_disable_pdf_compression +Revises: 7d7641cc1bb2 +Create Date: 2026-07-23 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "z003_disable_pdf_compression" +down_revision: Union[str, None] = "7d7641cc1bb2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "tenants") + + if "disable_pdf_compression" not in column_names: + op.add_column( + "tenants", + sa.Column("disable_pdf_compression", sa.Boolean(), nullable=False, server_default="false"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "tenants") + + if "disable_pdf_compression" in column_names: + op.drop_column("tenants", "disable_pdf_compression") diff --git a/alembic/versions/z004_add_global_workflows.py b/alembic/versions/z004_add_global_workflows.py new file mode 100644 index 0000000..bfbe030 --- /dev/null +++ b/alembic/versions/z004_add_global_workflows.py @@ -0,0 +1,20 @@ +"""dummy z004 + +Revision ID: z004_add_global_workflows +Revises: z003_disable_pdf_compression +Create Date: 2026-08-06 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'z004_add_global_workflows' +down_revision = 'z003_disable_pdf_compression' +branch_labels = None +depends_on = None + +def upgrade(): + pass + +def downgrade(): + pass diff --git a/alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py b/alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py new file mode 100644 index 0000000..83f5841 --- /dev/null +++ b/alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py @@ -0,0 +1,119 @@ +"""add virus scanning and quarantine tables + +Revision ID: z005_add_virus_scan +Revises: 73dff81eed72 +Create Date: 2026-08-31 19:15:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'z005_add_virus_scan' +down_revision = '73dff81eed72' +branch_labels = None +depends_on = None + + +def upgrade(): + # 1. virus_scan_settings table + op.create_table( + 'virus_scan_settings', + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), primary_key=True), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('file_types', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column('max_file_size_mb', sa.Integer(), nullable=False, server_default=sa.text('100')), + sa.Column('action_on_detect', sa.String(length=50), nullable=False, server_default=sa.text("'quarantine'")), + sa.Column('notify_admin', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('notify_uploader', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('auto_suspend_uploader', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('suspend_threshold', sa.Integer(), nullable=False, server_default=sa.text('3')), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + # 2. virus_scan_jobs table + op.create_table( + 'virus_scan_jobs', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('task_id', sa.String(length=255), nullable=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('file_id', sa.BigInteger(), nullable=True), + sa.Column('version_id', sa.BigInteger(), nullable=True), + sa.Column('uploader_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('storage_key', sa.String(length=1000), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False, server_default=sa.text('0')), + sa.Column('status', sa.String(length=50), nullable=False, server_default=sa.text("'pending'")), + sa.Column('retry_count', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_index('ix_virus_scan_jobs_tenant_id', 'virus_scan_jobs', ['tenant_id']) + op.create_index('ix_virus_scan_jobs_task_id', 'virus_scan_jobs', ['task_id']) + op.create_index('ix_virus_scan_jobs_status', 'virus_scan_jobs', ['status']) + + # 3. virus_scan_results table + op.create_table( + 'virus_scan_results', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('file_id', sa.BigInteger(), nullable=True), + sa.Column('version_id', sa.BigInteger(), nullable=True), + sa.Column('uploader_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('threat_name', sa.String(length=255), nullable=True), + sa.Column('action_taken', sa.String(length=50), nullable=False, server_default=sa.text("'none'")), + sa.Column('scan_duration_ms', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index('ix_virus_scan_results_tenant_id', 'virus_scan_results', ['tenant_id']) + op.create_index('ix_virus_scan_results_status', 'virus_scan_results', ['status']) + op.create_index('ix_virus_scan_results_created_at', 'virus_scan_results', ['created_at']) + + # 4. quarantined_files table + op.create_table( + 'quarantined_files', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('file_id', sa.BigInteger(), nullable=True), + sa.Column('version_id', sa.BigInteger(), nullable=True), + sa.Column('uploader_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False, server_default=sa.text('0')), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('threat_name', sa.String(length=255), nullable=False), + sa.Column('quarantine_storage_key', sa.String(length=1000), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False, server_default=sa.text("'quarantined'")), + sa.Column('quarantined_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('released_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('released_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + ) + op.create_index('ix_quarantined_files_tenant_id', 'quarantined_files', ['tenant_id']) + op.create_index('ix_quarantined_files_status', 'quarantined_files', ['status']) + + # 5. user_malware_counts table + op.create_table( + 'user_malware_counts', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('malware_count', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('last_detected_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('is_suspended', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('suspension_reason', sa.Text(), nullable=True), + ) + op.create_index('ix_user_malware_counts_user_id', 'user_malware_counts', ['user_id']) + op.create_index('ix_user_malware_counts_tenant_id', 'user_malware_counts', ['tenant_id']) + + +def downgrade(): + op.drop_table('user_malware_counts') + op.drop_table('quarantined_files') + op.drop_table('virus_scan_results') + op.drop_table('virus_scan_jobs') + op.drop_table('virus_scan_settings') diff --git a/app/ai_runner.py b/app/ai_runner.py new file mode 100644 index 0000000..72d38f0 --- /dev/null +++ b/app/ai_runner.py @@ -0,0 +1,85 @@ +import sys +import json +import logging +import os +import re + +sys.path.append(os.getcwd()) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("AI_Runner") + +def main(): + if len(sys.argv) < 4: + print(json.dumps({"status": "failed", "error": "Invalid arguments. Usage: python ai_runner.py "})) + sys.exit(1) + + input_path = sys.argv[1] + session_id = sys.argv[2] + user_id = sys.argv[3] + + try: + from app.core.model_manager import ModelManager + from app.modules.documents.processors.pdf.pdf_converter import UniversalContentIntelligence + from app.modules.documents.processors.pdf.image_extractor import ImageExtractor + + logger.info(f"🧠 Step 1: Running Marker AI on {input_path}") + with ModelManager() as ai: + result = ai.process_document(input_path) + + if hasattr(result, "markdown"): + content = result.markdown + elif isinstance(result, dict) and "markdown" in result: + content = result["markdown"] + else: + try: + from marker.output import text_from_rendered + content = text_from_rendered(result) + except: + content = str(result) + + logger.info("🔍 Step 2: Running Universal Content Intelligence") + metadata = UniversalContentIntelligence.extract_universal_metadata(content) + + figure_image_map = {} + pattern1 = r'!\[Image\s+(\d+)\]\((http[s]?://[^)]+)\)\s*\n\s*\*\*Figure\s+(\d+):' + matches1 = re.findall(pattern1, content, re.IGNORECASE | re.MULTILINE) + for _, image_url, figure_num in matches1: + figure_image_map[figure_num] = image_url + + pattern2 = r'!\[Image\s+(\d+)\]\(([^)]+)\)' + matches2 = re.findall(pattern2, content, re.IGNORECASE) + for image_num, image_url in matches2: + if image_num not in figure_image_map: + figure_image_map[image_num] = image_url + + logger.info("🖼️ Step 3: Extracting images") + images = [] + try: + extractor = ImageExtractor() + images = extractor.process_markdown_output_images(result, session_id=session_id, user_id=user_id) + logger.info(f"✅ Processed {len(images)} images") + except Exception as img_err: + logger.error(f"⚠️ Image extraction failed: {img_err}") + + print(json.dumps({ + "status": "success", + "markdown_content": content, + "session_id": session_id, + "images": images, + "metadata": { + "figures": figure_image_map, + "title": metadata.title if hasattr(metadata, 'title') else "" + } + })) + + except Exception as e: + logger.error(f"❌ AI Runner Failed: {e}") + print(json.dumps({ + "status": "failed", + "error": str(e) + })) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/app/ai_service/__init__.py b/app/ai_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ai_service/model_server.py b/app/ai_service/model_server.py new file mode 100644 index 0000000..2675fe2 --- /dev/null +++ b/app/ai_service/model_server.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from pydantic import BaseModel +from app.core.model_manager import ModelManager + +app = FastAPI(title="AI Model Server") + +model = ModelManager() + + +class ProcessRequest(BaseModel): + file_path: str + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/process") +def process_document(req: ProcessRequest): + """ + Heavy AI processing happens here. + This process should be run separately (e.g., on port 9000). + """ + result = model.process_document(req.file_path) + return {"result": result} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=9000) diff --git a/app/api/ws_router.py b/app/api/ws_router.py new file mode 100644 index 0000000..f6c918a --- /dev/null +++ b/app/api/ws_router.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, WebSocket, Query, WebSocketDisconnect +from jose import JWTError, jwt +from sqlalchemy.orm import Session +import logging + +from app.infrastructure.realtime.connection_manager import manager +from app.core.settings import settings +from typing import Optional + +from app.core.ws_auth import authenticate_websocket +from app.db.database import SessionLocal +from app.modules.auth.models.user_model import User +from app.modules.documents.models.document_model import Project +from app.core.token_blacklist import TokenBlacklist + +logger = logging.getLogger(__name__) + +router = APIRouter() + +def _authenticate_ws(token: str, db: Session) -> Optional[User]: + """ + Kept as a thin delegation, not deleted. + + Both WebSocket modules carried their own copy of this and drifted apart; + `app/core/ws_auth.py` is now the single implementation. The name stays + because the handshake below calls it and the probes resolve it by name -- + the point was to remove the second set of *rules*, not the local symbol. + """ + return authenticate_websocket(token, db) + +@router.websocket("/ws/{task_id}") +async def websocket_progress(websocket: WebSocket, task_id: str, token: str = Query(None)): + auth_token = token or websocket.cookies.get("docqube_access_token") + + if auth_token and TokenBlacklist.is_token_blacklisted(auth_token): + await websocket.accept() + await websocket.close(code=4001, reason="Token revoked") + return + + db = SessionLocal() + try: + user = _authenticate_ws(auth_token, db) if auth_token else None + if not user: + logger.warning(f"WebSocket progress auth failed for task {task_id}") + await websocket.close(code=4001, reason="Authentication failed") + return + + project = db.query(Project).filter(Project.session_id == task_id, Project.user_id == user.id).first() + if not project: + logger.warning(f"Unauthorized WebSocket progress access: User {user.id} task {task_id}") + await websocket.close(code=4003, reason="Access denied to this task") + return + + user_id = user.id + finally: + db.close() + + await manager.connect_task(task_id, websocket) + + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + logger.info(f"WebSocket progress disconnected: user={user_id} task={task_id}") + except Exception as e: + logger.error(f"WebSocket progress error: user={user_id} task={task_id}: {e}") + finally: + manager.disconnect(websocket) \ No newline at end of file diff --git a/app/assets/stylesheet/Stylesheet.deps.json b/app/assets/stylesheet/Stylesheet.deps.json new file mode 100644 index 0000000..2b4c555 --- /dev/null +++ b/app/assets/stylesheet/Stylesheet.deps.json @@ -0,0 +1,23 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v8.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v8.0": { + "Stylesheet/1.0.0": { + "runtime": { + "Stylesheet.dll": {} + } + } + } + }, + "libraries": { + "Stylesheet/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheet/Stylesheet.dll b/app/assets/stylesheet/Stylesheet.dll new file mode 100644 index 0000000000000000000000000000000000000000..8003c5ed6f935aad1c5a9a59bf266d9130e3d0e2 GIT binary patch literal 9216 zcmeHMYj7OLaqhV{jwHxCkN`!BQ5kM&I5%HLp8L-nQm!HK$}$)3jV=MpNv9spzIMIG$E=R#r=`SP|V)Ry{F9 zG%Eb`{@dHXUm5K^O3*UVPP7h`h(~?pF4PL1UOWsGR6fscw50u9Qwa~~TzqumQKD7y zzgCw~kA*VOJI?K1qHM5CtglWGEeCGgNifbXl5Nx9mh3;b(00iaLL zwQM&qSrnoZDci9#z@%-xXoT?GjHj;j!upi085S7XRyu))btUlBm0qI$I#f2FX1}7a zZ9WRpFJocan}`JPFh+a1vlbJ~_fh2m-|B}U8}KFLL{aEW!r%~XjnYHqZC@v&}^+{-iEc-8Yz=(M&-9!0GEZGwWyXyLaSHXkju9?a9A{n$cQ!t$XU;I z(1AmuO`%vQXl+1?Vj*iIYHJgd!?AF)r7$@V3nw=-DFTjIAQp^8EVu|(URn{0v_*in zGWyqy2Y?5vc(AE~uB_V%+mr04M=1UztzcjHXo&8A&TI3`Ub;X**dW%o2kFmcKhL!} z>O13yS1fopn!E*IS-5RE+3;}QxfSR`RL^i5D9a;lA!>=zwq^BP@K)R5CA_c)o`?nP zz0hT!1lY7H`7ufK#R7F&?Hu}&m_jtU4Ztok+Quj>!y?(|S;3c>x*gP8>C)P!AjN`K zin;6QSV|JuRy^NVU!%1=-==mSK`oypG4i)9^PS-+SbfF6O*BVCZH=&*37$jA=TGi{ zRw!y}_tSaw$l`Ob>7x%LbOS!8gPZI$U-EY5^_^k55(4w4NoOZW)~*`eeiMdUyK96^ z&Zai!4(37xSEO4kY~u=I|A0jh{}n=ub$1Q%T?qx?7v~<3tS*nv)@*9objy;_nP~q{ z-RMkER5r6^i2+PsTQ&e;_g*hRzR&4~?&KahKufYm*2<%hRvw2>q=ICxq$c}Nl^3+X zp2B{yJW5M)K=w2z2g^0iSIp0j39PH<9=jFo^1LxiNPDwP4wpp`Nf1fmNWcmmpk##O z!+}Ymej7I?ShuUX!tEgj{pXu+bFa(;Y*H5vBOT3bsPZV z;;8kNm<6%apT4(W@Tq{p=hwonRC{V?`_2w|HU-H5?Pf%1>mx+Jg8G{Jh2gwsjp7v`ydxSw&NAN##JM34Bs7Yxy537-&u5?o0)!D5_yVy8bu-}b!` z2+>{%0}^hQ@Cyd8V>lwjbsPk@JH!i(2mkqBmQuRz9L8YrEm{R zg?a*U`h(C_jIuSbh2nH2aMd5D{|v7OeSd)AAEP%;4+oj&a)70V1;hIU+w6*C!DiYd zo(AXN_@y3yS0qle5tjNIF7B4YypjGn{D6NWt@A&Cd94;wL$D{X5xh)^O8#FbfA~xE zmxz=oT|zrSzYW+-TVW@A-t#+0k3go^0_VcrvR%Rf)d}n%RxV78{azWvssa1EP%`u= z`WnEU5AVeddFTyv1<|THP?w}*!GRoLGdH|9~H#6s1 zy5K(|LbNZz*f*(DbmH<-lGs_g1V1#;<}KX!6rBh(iU!&(vCq;bKVzdF7WraW1D27P zH+lmZp4{5kgUrdZj_?K|KDq_rfJXZX7g* z3Hu}*k?^2|_erQqs7rVXP|!1gYv?C{A^NF=LR<{4qoQnoxeSkoJL${xJ%1P71aA)j z9t@4pYv>)P&EP*ouTzi6AbwcSJ7qY8HGGHO4dv-wY7`7v&)eIdX|d947@;R#b^C5p&b%0&^d8B^84VwfE6gp z^*JZUeM-WEq9GEZXJFgcq5so?HvoSGd!8>-!v42F`9wqj9*nG_7sRtc%n`kR0m|?C z-=){keve)kYs3El_!;^kMp-4L{yp@s^p3cfJ^+6ToJ z*l<>S5QvDgVo#_M@TG7Z@Of&%dBwiEEDlhsxQ$NIW4;v5DNCfNi53CZ)6;;h^c>(e zx&*kBegt?A{S1he?rx++1z+Q4_v+sn2B?&)8pQ77*Uo68XsLl5todHFm z$LS(%6hXut(VO81g%5GVT{{H=5<#~=b3agXLyaQHmep4cf@pD)kKHG!xvmDoly-9mT8pM+xVva_0E2)vur<|%x~aaYc{HiSN0VJr* z!wh?`W@bk0=0ZgzaF{sTH=H_?;HCmtJXt^1qIHkMwQ)`TjZBPJ`8o=}Ysyf9u8Eyg*%z`$-p?p2Be9Nk z&{j_~I+;#S+Zr#>^wIR_)HFMMLUrefx7nyQSH(HX)2?c}g}m(NO$MtnCuzntryY)E zXI=x7M=Wnpo*UxVba|&3-c+z2ylmK8quK(9avw<9RMr6MZEfsF1gz<**aS8=Y^%O6 zb<{CvOv_nz5m!)6%@G!azju`7<#|A0^43$!PT8vI@W3<%l{9PTrgPjFm{-j?ZD`Te zOpdbp?Iq0}wY^<)pIZHD9-{>W@s6XJUJQ6VZbsfl%d6pmY03r_9g@bpF@+_ zwVI95O|C%T^TFX$YD_h8%GfuwCAGwW3g^%QoWD;sv)K1Fv8*;||)ic35``sv*~|U(KAvv@Dxa8CODCm0d8Xt8=F1xO&E^7*G!U zssU-uUeGfd`m14-sw@z`s5n-^=3ehKLez3FEn&vm=QZ2aK^-!A6V_Sh&|b~$FS;z@ zg;qKCQxmqH!(Ljb^(+n78!#Nv&8&6WNev^AocjLivQ^IHnkTCi-a_LAH(zk8B)L3& zHeR5(la~P@QZ;nRXfhhBiD(cxLI!M=c?yRPR%)(bsP@oe9&Y1!c98Stw8e25XO6c4 z=QXU?ylSf%tg}Oz${vOD7InvUXnf{D1Z~}FT(ifev4dWo(R*6fiM&6n0R$;N+oeR4rNs7xQq?RYoQ>oA+`V za~Iud-GpB&E6ip{BW1&ZYh{DuxX&E z=4CFGT$<mQWT4sEZ}QUuG< zC)NyL^c?i_I4+(nkFlW-chWJ8n+B~Y`y9w>l7}|TfNIh-G~A$f%?)LmT#V>KMu%jc z3TRVo7IV*mmV~X>zhGiqObr&qNm`mt0lqL_-l<#A+SGxesR7H@a7k?|-$J?gABHs)kzRv=(6r z1sp2{MQg`DzB334$^#!CqcB1TzD42Bqf3N{MaoBHCwJkk7={GcnEM3nAJa3oF7+|<&BT&^2arlm+^e- zPv8F|9`7kUMm6V`6BbTmujS>>HYMj|EZfj$+LVfK_!Arc+LQsDjRjllHnoClt45nL zQJBH)YQI*TvQBDd_e^J}x;wM`j*h!_?$X-tysP#4!MqoT%9xm*4@OnWMBgC)v&~$- z@M7@-iLnW0`2?PC;DhWp_;;Ri;W^=l0lE!O7ry^>;R}C#!RNdsWimaOzPw+2JMqfs zy`|wRZ~bcE#Rb+eaQAU`CvpvLdedXa(=wIwOUCiO0iYB5!1#gfNB3?AHhn-leY_R| z$E}$MkIM)+ZfY(fWIHaboP6teQu*u*rT6vSzWWX;55KN5JWp}Ur%zluHvX;O{O?#t z{0aFP)3f?_xUG2YYhmT57>|yejBJXF7p{tO`7}-5Qf2SeTS)1>R{rOJ`|!O(+E>D_ zE;K<@TB=mWIbq4Jqch(%L{L2b`RlrPh47mej*=&PiM~@8FW>R)LeF8G&C`HGG>O&- zexW-6d<1aV`?Ze#F!25r*6%&2WE+W(n9r^GIQ86o6Oxn(<;f+PL;o1G=nzL9oWDEInT?5JVp^S zQjwW7sr`mI4Ly6LT*cx+aO3U-D|O_!diy?1z8&YOsM>iL^0?#I&8q|1xV`%Dcq_rr zp5a_=qvqQ}&C(lC!oiI6$SXlt+SRsG|#u=`?Wxn*UMe&otke5anFA2gYWoOmBXqUn3aM! zUFy-A4EvSH9U%HgFXW|v-oN_DjUh-@{7*^OKjo{kx)!z%kIQjvF`g%O3mjJJ3I}pU2yQxYS#K0 z?YTW62oe^^Z+09NU1-&nWS3J0JRy7>0JSnMU#|^Bhn7(6)?Oi*nP5?vKj>-z*lKcx_ X5TN{<+pBd+>KEST|2OdG=7IkO^ZiH@ literal 0 HcmV?d00001 diff --git a/app/assets/stylesheet/Stylesheet.runtimeconfig.json b/app/assets/stylesheet/Stylesheet.runtimeconfig.json new file mode 100644 index 0000000..1dc0145 --- /dev/null +++ b/app/assets/stylesheet/Stylesheet.runtimeconfig.json @@ -0,0 +1,18 @@ +{ + "runtimeOptions": { + "tfm": "net8.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "8.0.0" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "8.0.0" + } + ], + "configProperties": { + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": true + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheet/jats-html.xsl b/app/assets/stylesheet/jats-html.xsl new file mode 100644 index 0000000..02632ed --- /dev/null +++ b/app/assets/stylesheet/jats-html.xsl @@ -0,0 +1,4447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <xsl:variable name="authors"> + <xsl:call-template name="author-string"/> + </xsl:variable> + <xsl:value-of select="normalize-space(string($authors))"/> + <xsl:if test="normalize-space(string($authors))">: + </xsl:if> + JATS-HTML Viewer + <!--<xsl:value-of select="/article/front/article-meta/title-group/article-title[1]"/>--> + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+
+ + + +
+ +
+
+ + + --> + + + +
+ + + Floating objects + + + +
+
+ + + + + + + +
+ + + + + +
+ + + + + + + + +