Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
@@ -3,27 +3,73 @@
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-07-19
|
||||
|
||||
SAFE MIGRATION: Old tables are renamed (not dropped), data is migrated
|
||||
via INSERT ... SELECT, and old tables are preserved for rollback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, TSVECTOR, JSON
|
||||
|
||||
revision: str = "0021_unified_contacts"
|
||||
down_revision: Union[str, None] = "0020"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
revision = "0021_unified_contacts"
|
||||
down_revision = "0020"
|
||||
logger = logging.getLogger("alembic.migration.0021")
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 1. Drop old company_contacts join table
|
||||
op.execute("DROP TABLE IF EXISTS company_contacts CASCADE")
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""Check whether *table_name* exists in the public schema."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table_name},
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
# 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")
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
"""Check whether *column_name* exists on *table_name*."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' "
|
||||
"AND table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table_name, "c": column_name},
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
# 4. Create contacts table (without default_person_id/admin_contactperson_id FKs first)
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Rename old tables instead of dropping ──────────────────────
|
||||
# Only rename if the table exists and the _old version doesn't.
|
||||
old_tables = ["company_contacts", "contacts", "companies"]
|
||||
renamed: list[str] = []
|
||||
|
||||
for tbl in old_tables:
|
||||
old_name = f"{tbl}_old"
|
||||
if _table_exists(conn, tbl) and not _table_exists(conn, old_name):
|
||||
op.execute(f'ALTER TABLE "{tbl}" RENAME TO "{old_name}"')
|
||||
renamed.append(old_name)
|
||||
logger.info("Renamed %s → %s", tbl, old_name)
|
||||
elif _table_exists(conn, old_name):
|
||||
logger.info("%s already exists — skipping rename of %s", old_name, tbl)
|
||||
else:
|
||||
logger.info("Table %s does not exist — nothing to rename", tbl)
|
||||
|
||||
# ── 2. Create new contacts table ──────────────────────────────────
|
||||
op.create_table(
|
||||
"contacts",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
@@ -123,7 +169,7 @@ def upgrade():
|
||||
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
|
||||
# ── 3. Create contactpersons table ────────────────────────────────
|
||||
op.create_table(
|
||||
"contactpersons",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
@@ -155,11 +201,175 @@ def upgrade():
|
||||
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
|
||||
# ── 4. 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))
|
||||
|
||||
# ── 5. Migrate data from old tables ────────────────────────────────
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("contacts")
|
||||
# 5a. companies_old → contacts (type='company')
|
||||
if _table_exists(conn, "companies_old"):
|
||||
# Build column list dynamically based on what exists in companies_old
|
||||
company_cols = {
|
||||
"id": "id",
|
||||
"tenant_id": "tenant_id",
|
||||
"name": "name",
|
||||
"phone": "phone_1",
|
||||
"email": "email_1",
|
||||
"website": "website",
|
||||
"description": "projectnote",
|
||||
"deleted_at": "deleted_at",
|
||||
"created_by": "created_by",
|
||||
"updated_by": "updated_by",
|
||||
"created_at": "created_at",
|
||||
"updated_at": "updated_at",
|
||||
}
|
||||
# account_number → code (check if it exists)
|
||||
if _column_exists(conn, "companies_old", "account_number"):
|
||||
company_cols["account_number"] = "code"
|
||||
# industry → tags (check if it exists)
|
||||
if _column_exists(conn, "companies_old", "industry"):
|
||||
company_cols["industry"] = "tags"
|
||||
|
||||
select_cols = []
|
||||
insert_cols = []
|
||||
for old_col, new_col in company_cols.items():
|
||||
select_cols.append(old_col)
|
||||
insert_cols.append(new_col)
|
||||
|
||||
# Build the INSERT ... SELECT statement
|
||||
select_list = ", ".join(f'"{c}"' for c in select_cols)
|
||||
# Add computed columns
|
||||
select_list += ", 'company' AS type, "
|
||||
# displayname = name
|
||||
if "name" in select_cols:
|
||||
select_list += '"name" AS displayname'
|
||||
else:
|
||||
select_list += "'' AS displayname"
|
||||
|
||||
insert_list = ", ".join(f'"{c}"' for c in insert_cols) + ', "type", "displayname"'
|
||||
|
||||
sql = f'INSERT INTO contacts ({insert_list}) SELECT {select_list} FROM companies_old'
|
||||
op.execute(sql)
|
||||
|
||||
row_count = conn.execute(sa.text("SELECT COUNT(*) FROM companies_old")).scalar()
|
||||
logger.info("Migrated %d rows from companies_old → contacts (type='company')", row_count or 0)
|
||||
|
||||
# 5b. contacts_old → contacts (type='person')
|
||||
if _table_exists(conn, "contacts_old"):
|
||||
# Map old contact columns to new contacts columns
|
||||
contact_cols = {
|
||||
"id": "id",
|
||||
"tenant_id": "tenant_id",
|
||||
"first_name": "firstname",
|
||||
"last_name": "surname",
|
||||
"email": "email_1",
|
||||
"phone": "phone_1",
|
||||
"deleted_at": "deleted_at",
|
||||
"created_by": "created_by",
|
||||
"updated_by": "updated_by",
|
||||
"created_at": "created_at",
|
||||
"updated_at": "updated_at",
|
||||
}
|
||||
# mobile → phone_2
|
||||
if _column_exists(conn, "contacts_old", "mobile"):
|
||||
contact_cols["mobile"] = "phone_2"
|
||||
# notes → projectnote
|
||||
if _column_exists(conn, "contacts_old", "notes"):
|
||||
contact_cols["notes"] = "projectnote"
|
||||
|
||||
select_cols = []
|
||||
insert_cols = []
|
||||
for old_col, new_col in contact_cols.items():
|
||||
select_cols.append(old_col)
|
||||
insert_cols.append(new_col)
|
||||
|
||||
select_list = ", ".join(f'"{c}"' for c in select_cols)
|
||||
# Add computed columns
|
||||
select_list += ", 'person' AS type, "
|
||||
# displayname = first_name || ' ' || last_name
|
||||
if _column_exists(conn, "contacts_old", "first_name") and _column_exists(conn, "contacts_old", "last_name"):
|
||||
select_list += "COALESCE(first_name, '') || ' ' || COALESCE(last_name, '') AS displayname"
|
||||
elif _column_exists(conn, "contacts_old", "first_name"):
|
||||
select_list += "first_name AS displayname"
|
||||
else:
|
||||
select_list += "'' AS displayname"
|
||||
|
||||
insert_list = ", ".join(f'"{c}"' for c in insert_cols) + ', "type", "displayname"'
|
||||
|
||||
sql = f'INSERT INTO contacts ({insert_list}) SELECT {select_list} FROM contacts_old'
|
||||
op.execute(sql)
|
||||
|
||||
row_count = conn.execute(sa.text("SELECT COUNT(*) FROM contacts_old")).scalar()
|
||||
logger.info("Migrated %d rows from contacts_old → contacts (type='person')", row_count or 0)
|
||||
|
||||
# 5c. company_contacts_old → contactpersons
|
||||
# Each row links a company to a person. In the new schema, contactpersons
|
||||
# are persons attached to a company contact. We map:
|
||||
# contact_id (FK to contacts) = company_id (the company, now a contact)
|
||||
# person details come from the old contacts table
|
||||
if _table_exists(conn, "company_contacts_old") and _table_exists(conn, "contacts_old"):
|
||||
sql = """
|
||||
INSERT INTO contactpersons (
|
||||
id, tenant_id, contact_id, displayname,
|
||||
firstname, lastname, function, phone, email,
|
||||
tags, created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
cc.tenant_id,
|
||||
cc.company_id,
|
||||
COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, ''),
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
cc.role_at_company,
|
||||
c.phone,
|
||||
c.email,
|
||||
CASE WHEN cc.is_primary THEN 'primary' ELSE NULL END,
|
||||
cc.created_at,
|
||||
cc.updated_at,
|
||||
cc.deleted_at
|
||||
FROM company_contacts_old cc
|
||||
JOIN contacts_old c ON cc.contact_id = c.id
|
||||
"""
|
||||
op.execute(sql)
|
||||
|
||||
row_count = conn.execute(sa.text("SELECT COUNT(*) FROM company_contacts_old")).scalar()
|
||||
logger.info("Migrated %d rows from company_contacts_old → contactpersons", row_count or 0)
|
||||
|
||||
# ── 6. Enable RLS on new tables ───────────────────────────────────
|
||||
for table_name in ["contacts", "contactpersons"]:
|
||||
op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
|
||||
op.execute(f'DROP POLICY IF EXISTS tenant_isolation ON "{table_name}"')
|
||||
op.execute(
|
||||
f'CREATE POLICY tenant_isolation ON "{table_name}" '
|
||||
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop RLS policies on new tables
|
||||
for table_name in ["contactpersons", "contacts"]:
|
||||
op.execute(f'DROP POLICY IF EXISTS tenant_isolation ON "{table_name}"')
|
||||
op.execute(f'ALTER TABLE "{table_name}" DISABLE ROW LEVEL SECURITY')
|
||||
|
||||
# Drop FK columns from contacts
|
||||
op.drop_column("contacts", "admin_contactperson_id")
|
||||
op.drop_column("contacts", "default_person_id")
|
||||
|
||||
# Drop new tables
|
||||
op.drop_table("contactpersons")
|
||||
op.drop_table("contacts")
|
||||
|
||||
# Restore old tables by renaming _old suffix back
|
||||
for tbl in ["companies", "contacts", "company_contacts"]:
|
||||
old_name = f"{tbl}_old"
|
||||
if _table_exists(conn, old_name) and not _table_exists(conn, tbl):
|
||||
op.execute(f'ALTER TABLE "{old_name}" RENAME TO "{tbl}"')
|
||||
logger.info("Restored %s → %s", old_name, tbl)
|
||||
elif _table_exists(conn, old_name) and _table_exists(conn, tbl):
|
||||
# Both exist — drop the _old version (new table takes precedence)
|
||||
op.execute(f'DROP TABLE "{old_name}" CASCADE')
|
||||
logger.info("Dropped leftover %s (new %s already exists)", old_name, tbl)
|
||||
|
||||
@@ -4,77 +4,183 @@ Revision ID: 0027
|
||||
Revises: 0026_mail_salt_security
|
||||
Create Date: 2026-07-23
|
||||
|
||||
SAFE MIGRATION: When both company_id and contact_id columns exist in mails,
|
||||
company_id values are copied to contact_id (where contact_id IS NULL) before
|
||||
the column is dropped. A backup column is created to track which rows were
|
||||
originally linked to companies for safe downgrade.
|
||||
|
||||
Changes:
|
||||
- UPDATE entity_links SET entity_type='contact' WHERE entity_type='company'
|
||||
- UPDATE tag_assignments SET entity_type='contact' WHERE entity_type='company'
|
||||
- UPDATE calendar_entry_links SET entity_type='contact' WHERE entity_type='company'
|
||||
- UPDATE addresses SET entity_type='contact' WHERE entity_type='company'
|
||||
- ALTER TABLE mails RENAME COLUMN company_id TO contact_id (if exists)
|
||||
- mails: copy company_id → contact_id WHERE contact_id IS NULL, then drop company_id
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0027_unify_company_to_contact"
|
||||
down_revision: Union[str, None] = "0026_mail_salt_security"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
revision = "0027_unify_company_to_contact"
|
||||
down_revision = "0026_mail_salt_security"
|
||||
logger = logging.getLogger("alembic.migration.0027")
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Update entity_links: company -> contact
|
||||
op.execute(
|
||||
"UPDATE entity_links SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||
)
|
||||
# Update tag_assignments: company -> contact
|
||||
op.execute(
|
||||
"UPDATE tag_assignments SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||
)
|
||||
# Update calendar_entry_links: company -> contact
|
||||
op.execute(
|
||||
"UPDATE calendar_entry_links SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||
)
|
||||
# Update addresses: company -> contact
|
||||
op.execute(
|
||||
"UPDATE addresses SET entity_type = 'contact' WHERE entity_type = 'company'"
|
||||
)
|
||||
# Rename company_id to contact_id in mails (if column exists)
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
"""Check whether *column_name* exists on *table_name* in public schema."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' "
|
||||
"AND table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table_name, "c": column_name},
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""Check whether *table_name* exists in public schema."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table_name},
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
has_company_id = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns WHERE table_name='mails' AND column_name='company_id'")
|
||||
).fetchone()
|
||||
has_contact_id = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns WHERE table_name='mails' AND column_name='contact_id'")
|
||||
).fetchone()
|
||||
|
||||
# ── 1. Update entity_type: 'company' → 'contact' across link tables ──
|
||||
|
||||
if _table_exists(conn, "entity_links"):
|
||||
result = conn.execute(
|
||||
sa.text("UPDATE entity_links SET entity_type = 'contact' WHERE entity_type = 'company'")
|
||||
)
|
||||
logger.info("Updated %d rows in entity_links (company → contact)", result.rowcount)
|
||||
|
||||
if _table_exists(conn, "tag_assignments"):
|
||||
result = conn.execute(
|
||||
sa.text("UPDATE tag_assignments SET entity_type = 'contact' WHERE entity_type = 'company'")
|
||||
)
|
||||
logger.info("Updated %d rows in tag_assignments (company → contact)", result.rowcount)
|
||||
|
||||
if _table_exists(conn, "calendar_entry_links"):
|
||||
result = conn.execute(
|
||||
sa.text("UPDATE calendar_entry_links SET entity_type = 'contact' WHERE entity_type = 'company'")
|
||||
)
|
||||
logger.info("Updated %d rows in calendar_entry_links (company → contact)", result.rowcount)
|
||||
|
||||
if _table_exists(conn, "addresses"):
|
||||
result = conn.execute(
|
||||
sa.text("UPDATE addresses SET entity_type = 'contact' WHERE entity_type = 'company'")
|
||||
)
|
||||
logger.info("Updated %d rows in addresses (company → contact)", result.rowcount)
|
||||
|
||||
# ── 2. Mails: unify company_id into contact_id ──────────────────────
|
||||
if not _table_exists(conn, "mails"):
|
||||
logger.info("Table 'mails' does not exist — skipping column migration")
|
||||
return
|
||||
|
||||
has_company_id = _column_exists(conn, "mails", "company_id")
|
||||
has_contact_id = _column_exists(conn, "mails", "contact_id")
|
||||
|
||||
if has_company_id and has_contact_id:
|
||||
# Both columns exist: drop company_id (contact_id already present)
|
||||
# Both columns exist: copy company_id → contact_id WHERE contact_id IS NULL
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"UPDATE mails SET contact_id = company_id "
|
||||
"WHERE contact_id IS NULL AND company_id IS NOT NULL"
|
||||
)
|
||||
)
|
||||
logger.info("Copied %d rows from company_id → contact_id in mails", result.rowcount)
|
||||
|
||||
# Create a backup marker column to track rows originally linked via company_id
|
||||
# This enables a targeted downgrade (only revert these rows, not all contact rows)
|
||||
if not _column_exists(conn, "mails", "_orig_company_id"):
|
||||
op.add_column("mails", sa.Column("_orig_company_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
||||
# Record which rows had company_id set (these came from companies)
|
||||
op.execute(
|
||||
"UPDATE mails SET _orig_company_id = company_id WHERE company_id IS NOT NULL"
|
||||
)
|
||||
logger.info("Created _orig_company_id backup column for downgrade tracking")
|
||||
|
||||
# Now safe to drop company_id
|
||||
op.drop_column("mails", "company_id")
|
||||
elif has_company_id:
|
||||
logger.info("Dropped column company_id from mails")
|
||||
|
||||
elif has_company_id and not has_contact_id:
|
||||
# Only company_id exists: simple rename
|
||||
op.alter_column("mails", "company_id", new_column_name="contact_id")
|
||||
logger.info("Renamed company_id → contact_id in mails")
|
||||
|
||||
else:
|
||||
logger.info("No company_id column in mails — nothing to do")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Revert entity_links: contact -> company (only for rows that were originally company)
|
||||
op.execute(
|
||||
"UPDATE entity_links SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||
)
|
||||
# Revert tag_assignments: contact -> company
|
||||
op.execute(
|
||||
"UPDATE tag_assignments SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||
)
|
||||
# Revert calendar_entry_links: contact -> company
|
||||
op.execute(
|
||||
"UPDATE calendar_entry_links SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||
)
|
||||
# Revert addresses: contact -> company
|
||||
op.execute(
|
||||
"UPDATE addresses SET entity_type = 'company' WHERE entity_type = 'contact'"
|
||||
)
|
||||
# Rename contact_id back to company_id in mails (only if contact_id exists and company_id doesn't)
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
has_contact_id = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns WHERE table_name='mails' AND column_name='contact_id'")
|
||||
).fetchone()
|
||||
has_company_id = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns WHERE table_name='mails' AND column_name='company_id'")
|
||||
).fetchone()
|
||||
|
||||
# ── 1. Revert mails: contact_id → company_id ────────────────────────
|
||||
if not _table_exists(conn, "mails"):
|
||||
return
|
||||
|
||||
has_contact_id = _column_exists(conn, "mails", "contact_id")
|
||||
has_company_id = _column_exists(conn, "mails", "company_id")
|
||||
has_orig = _column_exists(conn, "mails", "_orig_company_id")
|
||||
|
||||
if has_contact_id and not has_company_id:
|
||||
op.alter_column("mails", "contact_id", new_column_name="company_id")
|
||||
if has_orig:
|
||||
# Targeted revert: only restore rows that originally came from company_id
|
||||
# Re-add company_id column
|
||||
op.add_column("mails", sa.Column("company_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
||||
# Restore company_id from the backup marker where it was originally set
|
||||
op.execute(
|
||||
"UPDATE mails SET company_id = _orig_company_id WHERE _orig_company_id IS NOT NULL"
|
||||
)
|
||||
# Clear contact_id for rows that were originally company links
|
||||
# (only where contact_id matches the original company_id, i.e. it was copied)
|
||||
op.execute(
|
||||
"UPDATE mails SET contact_id = NULL "
|
||||
"WHERE _orig_company_id IS NOT NULL AND contact_id = _orig_company_id"
|
||||
)
|
||||
# Drop the backup marker
|
||||
op.drop_column("mails", "_orig_company_id")
|
||||
logger.info("Restored company_id from _orig_company_id backup (targeted revert)")
|
||||
else:
|
||||
# No backup column — simple rename (fallback for clean installs)
|
||||
op.alter_column("mails", "contact_id", new_column_name="company_id")
|
||||
logger.info("Renamed contact_id → company_id in mails (no backup marker)")
|
||||
|
||||
# ── 2. Revert entity_type: 'contact' → 'company' ────────────────────
|
||||
# NOTE: This is a lossy revert — we cannot distinguish rows that were
|
||||
# originally 'company' from rows that were always 'contact'. This only
|
||||
# reverts rows that are currently 'contact' back to 'company'.
|
||||
# A proper revert requires application-level audit logs.
|
||||
|
||||
if _table_exists(conn, "entity_links"):
|
||||
conn.execute(
|
||||
sa.text("UPDATE entity_links SET entity_type = 'company' WHERE entity_type = 'contact'")
|
||||
)
|
||||
if _table_exists(conn, "tag_assignments"):
|
||||
conn.execute(
|
||||
sa.text("UPDATE tag_assignments SET entity_type = 'company' WHERE entity_type = 'contact'")
|
||||
)
|
||||
if _table_exists(conn, "calendar_entry_links"):
|
||||
conn.execute(
|
||||
sa.text("UPDATE calendar_entry_links SET entity_type = 'company' WHERE entity_type = 'contact'")
|
||||
)
|
||||
if _table_exists(conn, "addresses"):
|
||||
conn.execute(
|
||||
sa.text("UPDATE addresses SET entity_type = 'company' WHERE entity_type = 'contact'")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""FORCE Row Level Security + WITH CHECK on all tenant-scoped tables.
|
||||
|
||||
Revision ID: 0028_rls_force
|
||||
Revises: 0027_unify_company_to_contact
|
||||
Create Date: 2026-07-25
|
||||
|
||||
This migration:
|
||||
1. Discovers all tables in the public schema that have a tenant_id column.
|
||||
2. ALTER TABLE ... FORCE ROW LEVEL SECURITY on each (ensures RLS applies to table owners too).
|
||||
3. Drops existing tenant_isolation policies and recreates them with both
|
||||
USING and WITH CHECK clauses so writes are also filtered by tenant.
|
||||
4. Covers core tables AND plugin tables (anything with tenant_id).
|
||||
|
||||
Idempotent: safe to run multiple times.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0028_rls_force"
|
||||
down_revision: Union[str, None] = "0027_unify_company_to_contact"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
logger = logging.getLogger("alembic.migration.0028_rls_force")
|
||||
|
||||
|
||||
def _discover_tenant_tables(conn) -> list[str]:
|
||||
"""Return all table names in the public schema that have a tenant_id column."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT table_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND column_name = 'tenant_id' "
|
||||
"ORDER BY table_name"
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
def _discover_existing_policies(conn, table_name: str) -> list[str]:
|
||||
"""Return all policy names on *table_name* that contain 'tenant' or 'isolation'."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT policyname FROM pg_policies "
|
||||
"WHERE schemaname = 'public' AND tablename = :t"
|
||||
),
|
||||
{"t": table_name},
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
tenant_tables = _discover_tenant_tables(conn)
|
||||
logger.info("Discovered %d tenant-scoped tables: %s", len(tenant_tables), tenant_tables)
|
||||
|
||||
for table_name in tenant_tables:
|
||||
# 1. Enable RLS (idempotent — ENABLE is safe to repeat)
|
||||
op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
|
||||
|
||||
# 2. FORCE RLS — ensures policies apply even to table owners/superusers
|
||||
# who would otherwise bypass RLS
|
||||
op.execute(f'ALTER TABLE "{table_name}" FORCE ROW LEVEL SECURITY')
|
||||
|
||||
# 3. Drop ALL existing policies on this table that relate to tenant isolation
|
||||
existing_policies = _discover_existing_policies(conn, table_name)
|
||||
for policy_name in existing_policies:
|
||||
op.execute(f'DROP POLICY IF EXISTS "{policy_name}" ON "{table_name}"')
|
||||
logger.info("Dropped policy %s on %s", policy_name, table_name)
|
||||
|
||||
# 4. Create new policy with both USING and WITH CHECK
|
||||
# USING: filters rows visible in SELECT/UPDATE/DELETE
|
||||
# WITH CHECK: enforces tenant_id on INSERT/UPDATE
|
||||
op.execute(
|
||||
f'CREATE POLICY tenant_isolation ON "{table_name}" '
|
||||
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid) "
|
||||
f"WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid)"
|
||||
)
|
||||
logger.info("Created policy tenant_isolation on %s (USING + WITH CHECK)", table_name)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert FORCE RLS and restore USING-only policies (matching 0015 behavior)."""
|
||||
conn = op.get_bind()
|
||||
tenant_tables = _discover_tenant_tables(conn)
|
||||
|
||||
for table_name in tenant_tables:
|
||||
# Drop the USING+WITH CHECK policy
|
||||
op.execute(f'DROP POLICY IF EXISTS tenant_isolation ON "{table_name}"')
|
||||
|
||||
# Remove FORCE but keep ENABLE (matching pre-0028 state)
|
||||
op.execute(f'ALTER TABLE "{table_name}" NO FORCE ROW LEVEL SECURITY')
|
||||
|
||||
# Recreate USING-only policy (matching original 0015 behavior)
|
||||
op.execute(
|
||||
f'CREATE POLICY tenant_isolation ON "{table_name}" '
|
||||
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid)"
|
||||
)
|
||||
logger.info("Reverted %s to USING-only policy (removed FORCE, removed WITH CHECK)", table_name)
|
||||
@@ -18,7 +18,7 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
|
||||
revision = "0028_user_preferences"
|
||||
down_revision = "0027_unify_company_to_contact"
|
||||
down_revision = "0028_rls_force"
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Cross-tenant referential integrity: composite FKs on (tenant_id, contact_id).
|
||||
|
||||
Revision ID: 0036_cross_tenant_fk
|
||||
Revises: 0035_comm_search_index
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Changes:
|
||||
1. Add UNIQUE (tenant_id, id) on contacts — prerequisite for composite FK.
|
||||
2. Replace contactpersons.contact_id FK with composite (tenant_id, contact_id)
|
||||
→ contacts(tenant_id, id).
|
||||
3. Replace contact_merge_history.source_contact_id FK with composite
|
||||
(tenant_id, source_contact_id) → contacts(tenant_id, id).
|
||||
4. Replace contact_merge_history.target_contact_id FK with composite
|
||||
(tenant_id, target_contact_id) → contacts(tenant_id, id).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "0036_cross_tenant_fk"
|
||||
down_revision: Union[str, None] = "0035_comm_search_index"
|
||||
branch_labels: Union[str, None] = None
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def _constraint_exists(name: str) -> str:
|
||||
"""Return SQL that checks if a constraint exists."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.table_constraints "
|
||||
f"WHERE constraint_name = '{name}'"
|
||||
)
|
||||
|
||||
|
||||
def _fk_exists(name: str) -> str:
|
||||
"""Return SQL that checks if a foreign key constraint exists."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.table_constraints "
|
||||
f"WHERE constraint_name = '{name}' AND constraint_type = 'FOREIGN KEY'"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Add UNIQUE (tenant_id, id) on contacts ──────────────────────────
|
||||
unique_name = "uq_contacts_tenant_id"
|
||||
result = conn.execute(sa.text(_constraint_exists(unique_name))).fetchone()
|
||||
if result is None:
|
||||
op.execute(
|
||||
f"ALTER TABLE contacts ADD CONSTRAINT {unique_name} "
|
||||
f"UNIQUE (tenant_id, id)"
|
||||
)
|
||||
|
||||
# ── 2. contactpersons: replace single-column FK with composite FK ──────
|
||||
# Find and drop the existing FK on contactpersons.contact_id
|
||||
old_cp_fk_result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT conname FROM pg_constraint c "
|
||||
"JOIN pg_class cls ON c.conrelid = cls.oid "
|
||||
"JOIN pg_namespace nsp ON c.connamespace = nsp.oid "
|
||||
"WHERE cls.relname = 'contactpersons' "
|
||||
"AND nsp.nspname = 'public' "
|
||||
"AND c.contype = 'f' "
|
||||
"AND EXISTS ("
|
||||
" SELECT 1 FROM pg_attribute a "
|
||||
" WHERE a.attrelid = c.conrelid AND a.attname = 'contact_id' "
|
||||
" AND a.attnum = ANY(c.conkey)"
|
||||
")"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if old_cp_fk_result is not None:
|
||||
old_cp_fk_name = old_cp_fk_result[0]
|
||||
op.execute(f"ALTER TABLE contactpersons DROP CONSTRAINT IF EXISTS {old_cp_fk_name}")
|
||||
|
||||
# Add composite FK on contactpersons (tenant_id, contact_id) → contacts(tenant_id, id)
|
||||
cp_composite_fk = "fk_contactpersons_tenant_contact"
|
||||
result = conn.execute(sa.text(_fk_exists(cp_composite_fk))).fetchone()
|
||||
if result is None:
|
||||
op.execute(
|
||||
f"ALTER TABLE contactpersons ADD CONSTRAINT {cp_composite_fk} "
|
||||
f"FOREIGN KEY (tenant_id, contact_id) "
|
||||
f"REFERENCES contacts (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# ── 3. contact_merge_history: replace source_contact_id FK ─────────────
|
||||
old_src_fk_result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT conname FROM pg_constraint c "
|
||||
"JOIN pg_class cls ON c.conrelid = cls.oid "
|
||||
"JOIN pg_namespace nsp ON c.connamespace = nsp.oid "
|
||||
"WHERE cls.relname = 'contact_merge_history' "
|
||||
"AND nsp.nspname = 'public' "
|
||||
"AND c.contype = 'f' "
|
||||
"AND EXISTS ("
|
||||
" SELECT 1 FROM pg_attribute a "
|
||||
" WHERE a.attrelid = c.conrelid AND a.attname = 'source_contact_id' "
|
||||
" AND a.attnum = ANY(c.conkey)"
|
||||
")"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if old_src_fk_result is not None:
|
||||
old_src_fk_name = old_src_fk_result[0]
|
||||
op.execute(f"ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS {old_src_fk_name}")
|
||||
|
||||
src_composite_fk = "fk_merge_history_tenant_source"
|
||||
result = conn.execute(sa.text(_fk_exists(src_composite_fk))).fetchone()
|
||||
if result is None:
|
||||
op.execute(
|
||||
f"ALTER TABLE contact_merge_history ADD CONSTRAINT {src_composite_fk} "
|
||||
f"FOREIGN KEY (tenant_id, source_contact_id) "
|
||||
f"REFERENCES contacts (tenant_id, id) ON DELETE SET NULL"
|
||||
)
|
||||
|
||||
# ── 4. contact_merge_history: replace target_contact_id FK ──────────────
|
||||
old_tgt_fk_result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT conname FROM pg_constraint c "
|
||||
"JOIN pg_class cls ON c.conrelid = cls.oid "
|
||||
"JOIN pg_namespace nsp ON c.connamespace = nsp.oid "
|
||||
"WHERE cls.relname = 'contact_merge_history' "
|
||||
"AND nsp.nspname = 'public' "
|
||||
"AND c.contype = 'f' "
|
||||
"AND EXISTS ("
|
||||
" SELECT 1 FROM pg_attribute a "
|
||||
" WHERE a.attrelid = c.conrelid AND a.attname = 'target_contact_id' "
|
||||
" AND a.attnum = ANY(c.conkey)"
|
||||
")"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if old_tgt_fk_result is not None:
|
||||
old_tgt_fk_name = old_tgt_fk_result[0]
|
||||
op.execute(f"ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS {old_tgt_fk_name}")
|
||||
|
||||
tgt_composite_fk = "fk_merge_history_tenant_target"
|
||||
result = conn.execute(sa.text(_fk_exists(tgt_composite_fk))).fetchone()
|
||||
if result is None:
|
||||
op.execute(
|
||||
f"ALTER TABLE contact_merge_history ADD CONSTRAINT {tgt_composite_fk} "
|
||||
f"FOREIGN KEY (tenant_id, target_contact_id) "
|
||||
f"REFERENCES contacts (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Restore single-column FKs and remove composite FKs
|
||||
|
||||
# ── contact_merge_history: target ──
|
||||
op.execute("ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS fk_merge_history_tenant_target")
|
||||
op.execute(
|
||||
"ALTER TABLE contact_merge_history ADD CONSTRAINT "
|
||||
"contact_merge_history_target_contact_id_fkey "
|
||||
"FOREIGN KEY (target_contact_id) REFERENCES contacts (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# ── contact_merge_history: source ──
|
||||
op.execute("ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS fk_merge_history_tenant_source")
|
||||
op.execute(
|
||||
"ALTER TABLE contact_merge_history ADD CONSTRAINT "
|
||||
"contact_merge_history_source_contact_id_fkey "
|
||||
"FOREIGN KEY (source_contact_id) REFERENCES contacts (id) ON DELETE SET NULL"
|
||||
)
|
||||
|
||||
# ── contactpersons ──
|
||||
op.execute("ALTER TABLE contactpersons DROP CONSTRAINT IF EXISTS fk_contactpersons_tenant_contact")
|
||||
op.execute(
|
||||
"ALTER TABLE contactpersons ADD CONSTRAINT "
|
||||
"contactpersons_contact_id_fkey "
|
||||
"FOREIGN KEY (contact_id) REFERENCES contacts (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# ── Remove unique (tenant_id, id) on contacts ──
|
||||
op.execute("ALTER TABLE contacts DROP CONSTRAINT IF EXISTS uq_contacts_tenant_id")
|
||||
@@ -0,0 +1,191 @@
|
||||
"""User-Tenant model cleanup: single source of truth for membership and role.
|
||||
|
||||
Revision ID: 0037_user_tenant_model
|
||||
Revises: 0036_cross_tenant_fk
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Changes:
|
||||
1. Make users.email globally unique (drop composite uq_users_tenant_email, add UNIQUE on email).
|
||||
2. Drop tenant_id, role, role_id columns from users table (with data migration to user_tenants).
|
||||
3. Add role column to user_tenants (built-in role string: admin/editor/viewer).
|
||||
4. Add status column to user_tenants (active/invited/disabled).
|
||||
5. Migrate existing data: copy users.tenant_id + users.role_id → user_tenants (if not already present).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "0037_user_tenant_model"
|
||||
down_revision: Union[str, None] = "0036_cross_tenant_fk"
|
||||
branch_labels: Union[str, None] = None
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def _constraint_exists(name: str, table: str) -> str:
|
||||
"""Return SQL that checks if a constraint exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.table_constraints "
|
||||
f"WHERE constraint_name = '{name}' AND table_name = '{table}'"
|
||||
)
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> str:
|
||||
"""Return SQL that checks if a column exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = '{column}'"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Add UNIQUE constraint on users.email (globally unique) ───────────
|
||||
# First check if a unique constraint on email already exists
|
||||
email_unique_result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = 'uq_users_email' AND table_name = 'users'"
|
||||
)
|
||||
).fetchone()
|
||||
if email_unique_result is None:
|
||||
# Check if there's a unique index on email already
|
||||
email_index_result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_indexes "
|
||||
"WHERE tablename = 'users' AND indexname = 'ix_users_email' "
|
||||
"AND unique = true"
|
||||
)
|
||||
).fetchone()
|
||||
if email_index_result is None:
|
||||
op.execute("ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email)")
|
||||
|
||||
# ── 2. Drop composite unique constraint uq_users_tenant_email ───────────
|
||||
result = conn.execute(sa.text(_constraint_exists("uq_users_tenant_email", "users"))).fetchone()
|
||||
if result is not None:
|
||||
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_tenant_email")
|
||||
|
||||
# ── 3. Add role column to user_tenants ─────────────────────────────────
|
||||
role_col_result = conn.execute(sa.text(_column_exists("user_tenants", "role"))).fetchone()
|
||||
if role_col_result is None:
|
||||
op.add_column("user_tenants", sa.Column("role", sa.String(50), nullable=False, server_default="viewer"))
|
||||
|
||||
# ── 4. Add status column to user_tenants ───────────────────────────────
|
||||
status_col_result = conn.execute(sa.text(_column_exists("user_tenants", "status"))).fetchone()
|
||||
if status_col_result is None:
|
||||
op.add_column("user_tenants", sa.Column("status", sa.String(20), nullable=False, server_default="active"))
|
||||
|
||||
# ── 5. Data migration: copy tenant_id, role, role_id from users to user_tenants ─
|
||||
# Only create UserTenant rows that don't already exist
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO user_tenants (user_id, tenant_id, is_default, role, role_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
u.id,
|
||||
u.tenant_id,
|
||||
TRUE,
|
||||
COALESCE(u.role, 'viewer'),
|
||||
u.role_id,
|
||||
'active',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_tenants ut
|
||||
WHERE ut.user_id = u.id AND ut.tenant_id = u.tenant_id
|
||||
)
|
||||
AND u.tenant_id IS NOT NULL
|
||||
"""))
|
||||
|
||||
# Update existing UserTenant rows with role from users table (if they don't have one set yet)
|
||||
conn.execute(sa.text("""
|
||||
UPDATE user_tenants ut
|
||||
SET role = COALESCE(u.role, 'viewer'),
|
||||
role_id = COALESCE(ut.role_id, u.role_id)
|
||||
FROM users u
|
||||
WHERE ut.user_id = u.id
|
||||
AND ut.tenant_id = u.tenant_id
|
||||
"""))
|
||||
|
||||
# ── 6. Drop role_id FK from users (if it exists) ───────────────────────
|
||||
# Find and drop the FK on users.role_id
|
||||
role_id_fk_result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT conname FROM pg_constraint c "
|
||||
"JOIN pg_class cls ON c.conrelid = cls.oid "
|
||||
"JOIN pg_namespace nsp ON c.connamespace = nsp.oid "
|
||||
"WHERE cls.relname = 'users' "
|
||||
"AND nsp.nspname = 'public' "
|
||||
"AND c.contype = 'f' "
|
||||
"AND EXISTS ("
|
||||
" SELECT 1 FROM pg_attribute a "
|
||||
" WHERE a.attrelid = c.conrelid AND a.attname = 'role_id' "
|
||||
" AND a.attnum = ANY(c.conkey)"
|
||||
")"
|
||||
)
|
||||
).fetchone()
|
||||
if role_id_fk_result is not None:
|
||||
fk_name = role_id_fk_result[0]
|
||||
op.execute(f"ALTER TABLE users DROP CONSTRAINT IF EXISTS {fk_name}")
|
||||
|
||||
# ── 7. Drop tenant_id, role, role_id columns from users ────────────────
|
||||
# Drop tenant_id
|
||||
tenant_col_result = conn.execute(sa.text(_column_exists("users", "tenant_id"))).fetchone()
|
||||
if tenant_col_result is not None:
|
||||
# Drop any indexes on tenant_id first
|
||||
op.execute("DROP INDEX IF EXISTS ix_users_tenant_id")
|
||||
op.drop_column("users", "tenant_id")
|
||||
|
||||
# Drop role
|
||||
role_col_result = conn.execute(sa.text(_column_exists("users", "role"))).fetchone()
|
||||
if role_col_result is not None:
|
||||
op.drop_column("users", "role")
|
||||
|
||||
# Drop role_id
|
||||
role_id_col_result = conn.execute(sa.text(_column_exists("users", "role_id"))).fetchone()
|
||||
if role_id_col_result is not None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_users_role_id")
|
||||
op.drop_column("users", "role_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── Re-add tenant_id, role, role_id to users ───────────────────────────
|
||||
tenant_col_result = conn.execute(sa.text(_column_exists("users", "tenant_id"))).fetchone()
|
||||
if tenant_col_result is None:
|
||||
op.add_column("users", sa.Column("tenant_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_users_tenant_id", "users", ["tenant_id"])
|
||||
|
||||
role_col_result = conn.execute(sa.text(_column_exists("users", "role"))).fetchone()
|
||||
if role_col_result is None:
|
||||
op.add_column("users", sa.Column("role", sa.String(50), nullable=False, server_default="viewer"))
|
||||
|
||||
role_id_col_result = conn.execute(sa.text(_column_exists("users", "role_id"))).fetchone()
|
||||
if role_id_col_result is None:
|
||||
op.add_column("users", sa.Column("role_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_users_role_id", "users", ["role_id"])
|
||||
# Re-add FK
|
||||
op.create_foreign_key("fk_users_role_id", "users", "roles", ["role_id"], ["id"], ondelete="SET NULL")
|
||||
|
||||
# ── Restore data from user_tenants to users (default tenant) ────────────
|
||||
conn.execute(sa.text("""
|
||||
UPDATE users u
|
||||
SET tenant_id = ut.tenant_id,
|
||||
role = ut.role,
|
||||
role_id = ut.role_id
|
||||
FROM user_tenants ut
|
||||
WHERE ut.user_id = u.id AND ut.is_default = TRUE
|
||||
"""))
|
||||
|
||||
# ── Re-add composite unique constraint ─────────────────────────────────
|
||||
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_email")
|
||||
op.execute("ALTER TABLE users ADD CONSTRAINT uq_users_tenant_email UNIQUE (tenant_id, email)")
|
||||
|
||||
# ── Drop role and status columns from user_tenants ──────────────────────
|
||||
op.drop_column("user_tenants", "status")
|
||||
op.drop_column("user_tenants", "role")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add content_hash column to files table for SHA-256 dedup and integrity.
|
||||
|
||||
Revision ID: 0038_dms_content_hash
|
||||
Revises: 0037_user_tenant_model
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Changes:
|
||||
1. Add content_hash (String(64), nullable) column to files table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "0038_dms_content_hash"
|
||||
down_revision: Union[str, None] = "0037_user_tenant_model"
|
||||
branch_labels: Union[str, None] = None
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> str:
|
||||
"""Return SQL that checks if a column exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = '{column}'"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(_column_exists("files", "content_hash"))).fetchone()
|
||||
if result is None:
|
||||
op.add_column("files", sa.Column("content_hash", sa.String(64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(_column_exists("files", "content_hash"))).fetchone()
|
||||
if result is not None:
|
||||
op.drop_column("files", "content_hash")
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Normalize contact model: fix surfix typo, Float→Numeric(5,2) discounts, JSON→JSONB, unique constraints.
|
||||
|
||||
Revision ID: 0039_contact_normalize
|
||||
Revises: 0038_dms_content_hash
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Changes:
|
||||
1. Rename column surfix → suffix on contacts table.
|
||||
2. Convert discount_* columns from Float to Numeric(5,2) with CHECK constraints (0-100).
|
||||
3. Convert custom columns from JSON to JSONB on contacts and contactpersons.
|
||||
4. Add partial unique constraints: (tenant_id, code) and (tenant_id, accounting_code) where NOT NULL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "0039_contact_normalize"
|
||||
down_revision: Union[str, None] = "0038_dms_content_hash"
|
||||
branch_labels: Union[str, None] = None
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
DISCOUNT_COLUMNS = [
|
||||
"discount_crew",
|
||||
"discount_transport",
|
||||
"discount_rental",
|
||||
"discount_sale",
|
||||
"discount_subrent",
|
||||
"discount_total",
|
||||
]
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> str:
|
||||
"""Return SQL that checks if a column exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = '{column}'"
|
||||
)
|
||||
|
||||
|
||||
def _constraint_exists(table: str, constraint: str) -> str:
|
||||
"""Return SQL that checks if a constraint exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.table_constraints "
|
||||
f"WHERE table_name = '{table}' AND constraint_name = '{constraint}'"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1a. Rename surfix → suffix ──
|
||||
result = conn.execute(sa.text(_column_exists("contacts", "surfix"))).fetchone()
|
||||
if result:
|
||||
op.alter_column("contacts", "surfix", new_column_name="suffix")
|
||||
|
||||
# ── 1b. Convert discount_* from Float to Numeric(5,2) with CHECK ──
|
||||
for col in DISCOUNT_COLUMNS:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE contacts ALTER COLUMN {col} "
|
||||
f"TYPE NUMERIC(5,2) USING {col}::numeric(5,2)"
|
||||
)
|
||||
)
|
||||
# Add CHECK constraint if not exists
|
||||
ck_name = f"ck_contacts_{col}_range"
|
||||
ck_exists = conn.execute(
|
||||
sa.text(_constraint_exists("contacts", ck_name))
|
||||
).fetchone()
|
||||
if not ck_exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE contacts ADD CONSTRAINT {ck_name} "
|
||||
f"CHECK ({col} BETWEEN 0 AND 100)"
|
||||
)
|
||||
)
|
||||
|
||||
# ── 1c. JSON → JSONB for contacts.custom ──
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'contacts' AND column_name = 'custom'"
|
||||
)
|
||||
).fetchone()
|
||||
if result and result[0] == "json":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contacts ALTER COLUMN custom "
|
||||
"TYPE JSONB USING custom::jsonb"
|
||||
)
|
||||
)
|
||||
|
||||
# ── 1d. JSON → JSONB for contactpersons.custom ──
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'contactpersons' AND column_name = 'custom'"
|
||||
)
|
||||
).fetchone()
|
||||
if result and result[0] == "json":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contactpersons ALTER COLUMN custom "
|
||||
"TYPE JSONB USING custom::jsonb"
|
||||
)
|
||||
)
|
||||
|
||||
# ── 1e. Partial unique constraints ──
|
||||
# (tenant_id, code) where code IS NOT NULL
|
||||
uq_code_exists = conn.execute(
|
||||
sa.text(_constraint_exists("contacts", "uq_contacts_tenant_code"))
|
||||
).fetchone()
|
||||
if not uq_code_exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE UNIQUE INDEX uq_contacts_tenant_code "
|
||||
"ON contacts (tenant_id, code) WHERE code IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
# (tenant_id, accounting_code) where accounting_code IS NOT NULL
|
||||
uq_acct_exists = conn.execute(
|
||||
sa.text(_constraint_exists("contacts", "uq_contacts_tenant_accounting_code"))
|
||||
).fetchone()
|
||||
if not uq_acct_exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE UNIQUE INDEX uq_contacts_tenant_accounting_code "
|
||||
"ON contacts (tenant_id, accounting_code) WHERE accounting_code IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop unique indexes
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS uq_contacts_tenant_accounting_code"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS uq_contacts_tenant_code"))
|
||||
|
||||
# JSONB → JSON
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contactpersons ALTER COLUMN custom "
|
||||
"TYPE JSON USING custom::json"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contacts ALTER COLUMN custom TYPE JSON USING custom::json"
|
||||
)
|
||||
)
|
||||
|
||||
# Drop CHECK constraints and revert Numeric → Float
|
||||
for col in DISCOUNT_COLUMNS:
|
||||
ck_name = f"ck_contacts_{col}_range"
|
||||
conn.execute(sa.text(f"ALTER TABLE contacts DROP CONSTRAINT IF EXISTS {ck_name}"))
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE contacts ALTER COLUMN {col} "
|
||||
f"TYPE FLOAT USING {col}::float"
|
||||
)
|
||||
)
|
||||
|
||||
# Rename suffix → surfix
|
||||
result = conn.execute(sa.text(_column_exists("contacts", "suffix"))).fetchone()
|
||||
if result:
|
||||
op.alter_column("contacts", "suffix", new_column_name="surfix")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Create event_outbox table for transactional outbox pattern.
|
||||
|
||||
Revision ID: 0040_outbox
|
||||
Revises: 0039_contact_normalize
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Stores domain events in a durable table so they survive process crashes,
|
||||
restarts, and multi-replica deployments. A background worker polls the
|
||||
outbox and publishes events to the in-process event bus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "0040_outbox"
|
||||
down_revision: Union[str, None] = "0039_contact_normalize"
|
||||
branch_labels: Union[str, None] = None
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Ensure pgcrypto extension for gen_random_uuid()
|
||||
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS event_outbox (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
event_name VARCHAR(255) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 5,
|
||||
next_retry_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
published_at TIMESTAMPTZ
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Index for the worker query: WHERE status = 'pending' ORDER BY next_retry_at
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_outbox_status "
|
||||
"ON event_outbox (status, next_retry_at)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_outbox_tenant "
|
||||
"ON event_outbox (tenant_id)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_outbox_tenant"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_outbox_status"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS event_outbox"))
|
||||
Reference in New Issue
Block a user