"""Unified contacts model — company or person with inline addresses. Revision ID: 0021 Revises: 0020 Create Date: 2026-07-19 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID, TSVECTOR, JSON revision = "0021_unified_contacts" down_revision = "0020_notifications_updated_at" def upgrade(): # 1. Drop old company_contacts join table op.execute("DROP TABLE IF EXISTS company_contacts CASCADE") # 2. Drop old contacts table (will recreate with new schema) op.execute("DROP TABLE IF EXISTS contacts CASCADE") # 3. Drop old companies table op.execute("DROP TABLE IF EXISTS companies CASCADE") # 4. Create contacts table (without default_person_id/admin_contactperson_id FKs first) op.create_table( "contacts", sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), sa.Column("tenant_id", UUID(as_uuid=True), nullable=False, index=True), # Identity & Type sa.Column("type", sa.String(20), nullable=False, server_default="company"), sa.Column("displayname", sa.String(255), nullable=False, server_default=""), sa.Column("name", sa.String(255), nullable=True), sa.Column("firstname", sa.String(100), nullable=True), sa.Column("surname", sa.String(100), nullable=True), sa.Column("surfix", sa.String(50), nullable=True), sa.Column("ext_name_line", sa.String(255), nullable=True), sa.Column("gender", sa.String(20), nullable=True), # Customer / Accounting sa.Column("code", sa.String(100), nullable=True), sa.Column("accounting_code", sa.String(100), nullable=True), sa.Column("vendor_accounting_code", sa.String(100), nullable=True), # Mailing Address sa.Column("mailing_street", sa.String(255), nullable=True), sa.Column("mailing_number", sa.String(20), nullable=True), sa.Column("mailing_unit_number", sa.String(50), nullable=True), sa.Column("mailing_district", sa.String(100), nullable=True), sa.Column("mailing_extra_address_line", sa.String(255), nullable=True), sa.Column("mailing_postalcode", sa.String(20), nullable=True), sa.Column("mailing_city", sa.String(100), nullable=True), sa.Column("mailing_state", sa.String(100), nullable=True), sa.Column("mailing_country", sa.String(2), nullable=True), # Visit Address sa.Column("visit_street", sa.String(255), nullable=True), sa.Column("visit_number", sa.String(20), nullable=True), sa.Column("visit_unit_number", sa.String(50), nullable=True), sa.Column("visit_district", sa.String(100), nullable=True), sa.Column("visit_extra_address_line", sa.String(255), nullable=True), sa.Column("visit_postalcode", sa.String(20), nullable=True), sa.Column("visit_city", sa.String(100), nullable=True), sa.Column("visit_state", sa.String(100), nullable=True), # Invoice Address sa.Column("invoice_street", sa.String(255), nullable=True), sa.Column("invoice_number", sa.String(20), nullable=True), sa.Column("invoice_unit_number", sa.String(50), nullable=True), sa.Column("invoice_district", sa.String(100), nullable=True), sa.Column("invoice_extra_address_line", sa.String(255), nullable=True), sa.Column("invoice_postalcode", sa.String(20), nullable=True), sa.Column("invoice_city", sa.String(100), nullable=True), sa.Column("invoice_state", sa.String(100), nullable=True), sa.Column("invoice_country", sa.String(2), nullable=True), # General country sa.Column("country", sa.String(2), nullable=True), # Communication sa.Column("phone_1", sa.String(50), nullable=True), sa.Column("phone_2", sa.String(50), nullable=True), sa.Column("email_1", sa.String(255), nullable=True), sa.Column("email_2", sa.String(255), nullable=True), sa.Column("website", sa.String(500), nullable=True), # Financial & Tax sa.Column("vat_code", sa.String(50), nullable=True), sa.Column("fiscal_code", sa.String(50), nullable=True), sa.Column("commerce_code", sa.String(100), nullable=True), sa.Column("purchase_number", sa.String(100), nullable=True), sa.Column("bic", sa.String(50), nullable=True), sa.Column("bank_account", sa.String(50), nullable=True), # Discounts sa.Column("discount_crew", sa.Float, nullable=False, server_default="0"), sa.Column("discount_transport", sa.Float, nullable=False, server_default="0"), sa.Column("discount_rental", sa.Float, nullable=False, server_default="0"), sa.Column("discount_sale", sa.Float, nullable=False, server_default="0"), sa.Column("discount_subrent", sa.Float, nullable=False, server_default="0"), sa.Column("discount_total", sa.Float, nullable=False, server_default="0"), # Geo sa.Column("latitude", sa.Float, nullable=True), sa.Column("longitude", sa.Float, nullable=True), # Notes & Warnings sa.Column("projectnote", sa.Text, nullable=True), sa.Column("projectnote_title", sa.String(255), nullable=True), sa.Column("contact_warning", sa.Text, nullable=True), sa.Column("tags", sa.String(500), nullable=True), sa.Column("image", sa.Text, nullable=True), # Custom fields sa.Column("custom", JSON, nullable=True, server_default=sa.text("'{}'::json")), # FTS sa.Column("search_tsv", TSVECTOR, sa.Computed( "to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))", persisted=True, ), nullable=True), # Audit sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), 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("deleted_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"]) op.create_index("ix_contacts_tenant_type", "contacts", ["tenant_id", "type"]) op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "name"]) op.create_index("ix_contacts_tenant_displayname", "contacts", ["tenant_id", "displayname"]) op.create_index("ix_contacts_email", "contacts", ["email_1"]) op.create_index("ix_contacts_code", "contacts", ["code"]) op.create_index("ix_contacts_search_vec", "contacts", ["search_tsv"], postgresql_using="gin") # 5. Create contactpersons table op.create_table( "contactpersons", sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), sa.Column("tenant_id", UUID(as_uuid=True), nullable=False, index=True), sa.Column("contact_id", UUID(as_uuid=True), sa.ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False), sa.Column("displayname", sa.String(255), nullable=False, server_default=""), sa.Column("firstname", sa.String(100), nullable=True), sa.Column("middle_name", sa.String(100), nullable=True), sa.Column("lastname", sa.String(100), nullable=True), sa.Column("function", sa.String(255), nullable=True), sa.Column("phone", sa.String(50), nullable=True), sa.Column("mobilephone", sa.String(50), nullable=True), sa.Column("email", sa.String(255), nullable=True), sa.Column("street", sa.String(255), nullable=True), sa.Column("number", sa.String(20), nullable=True), sa.Column("postalcode", sa.String(20), nullable=True), sa.Column("city", sa.String(100), nullable=True), sa.Column("state", sa.String(100), nullable=True), sa.Column("country", sa.String(2), nullable=True), sa.Column("tags", sa.String(500), nullable=True), sa.Column("custom", JSON, nullable=True, server_default=sa.text("'{}'::json")), sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), 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("deleted_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_contactpersons_tenant_deleted", "contactpersons", ["tenant_id", "deleted_at"]) op.create_index("ix_contactpersons_contact", "contactpersons", ["contact_id"]) op.create_index("ix_contactpersons_email", "contactpersons", ["email"]) # 6. Add FK columns to contacts that reference contactpersons op.add_column("contacts", sa.Column("default_person_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True)) op.add_column("contacts", sa.Column("admin_contactperson_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True)) def downgrade(): op.drop_table("contacts") op.drop_table("contactpersons")