Unified contacts model: Rentman-style type field (company/person), inline addresses, ContactPerson 1:N, all Rentman fields except rental-specific

This commit is contained in:
Agent Zero
2026-07-19 21:12:49 +02:00
parent d80feb2a3a
commit cf75680583
14 changed files with 1411 additions and 869 deletions
+165
View File
@@ -0,0 +1,165 @@
"""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")
+2 -2
View File
@@ -6,7 +6,7 @@ from app.models.attachment import Attachment
from app.models.audit import AuditLog, DeletionLog from app.models.audit import AuditLog, DeletionLog
from app.models.auth import ApiToken, PasswordResetToken from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company from app.models.company import Company
from app.models.contact import CompanyContact, Contact from app.models.contact import Contact, ContactPerson
from app.models.currency import Currency from app.models.currency import Currency
from app.models.group import Group, UserGroup from app.models.group import Group, UserGroup
from app.models.notification import Notification, NotificationPreference, NotificationType from app.models.notification import Notification, NotificationPreference, NotificationType
@@ -37,7 +37,7 @@ __all__ = [
"ApiToken", "ApiToken",
"Company", "Company",
"Contact", "Contact",
"CompanyContact", "ContactPerson",
"Currency", "Currency",
"TaxRate", "TaxRate",
"Sequence", "Sequence",
+9 -47
View File
@@ -1,51 +1,13 @@
"""Company model — with soft-delete, FTS tsvector, and tenant scoping.""" """Backward-compat shim — Company is now Contact with type='company'.
from __future__ import annotations This module re-exports Contact as Company for code that still imports
from app.models.company. The old companies table no longer exists;
all company data lives in the contacts table with type='company'.
"""
import uuid from app.models.contact import Contact, ContactPerson
from typing import Any
from sqlalchemy import Computed, DateTime, ForeignKey, Index, String, Text # Backward-compat: Company is now just a Contact with type='company'
from sqlalchemy.dialects.postgresql import TSVECTOR Company = Contact
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin __all__ = ["Company", "ContactPerson"]
class Company(Base, TenantMixin):
"""Company entity with full-text search support."""
__tablename__ = "companies"
__table_args__ = (
Index("ix_companies_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_companies_tenant_name", "tenant_id", "name"),
Index("ix_companies_industry", "tenant_id", "industry"),
Index("ix_companies_search_vec", "search_tsv", postgresql_using="gin"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
account_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
industry: Mapped[str | None] = mapped_column(String(50), nullable=True)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# FTS vector
search_tsv: Mapped[Any] = mapped_column(
TSVECTOR,
Computed(
"to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || coalesce(industry, ''))",
persisted=True,
),
nullable=True,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+191 -28
View File
@@ -1,45 +1,161 @@
"""Contact and CompanyContact (N:M join) models.""" """Unified Contact model — company or person, with inline addresses.
Based on Rentman's contact model: a single table with type field
('company' or 'person'). ContactPerson is a 1:N child for
ansprechpartner (company employees / contact persons).
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Any
from sqlalchemy import ( from sqlalchemy import (
Boolean, Computed,
ForeignKey, ForeignKey,
Index, Index,
String, String,
Text, Text,
UniqueConstraint, Float,
JSON,
) )
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
class Contact(Base, TenantMixin): class Contact(Base, TenantMixin):
"""Contact person entity — can be linked to multiple companies via CompanyContact.""" """Unified contact entity — can be a company or a person.
type='company': name is the company name, firstname/surname empty.
type='person': firstname/surname are the person's name, name empty.
Both types can have contactpersons (1:N) and inline addresses
(mailing, visit, invoice).
"""
__tablename__ = "contacts" __tablename__ = "contacts"
__table_args__ = ( __table_args__ = (
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"), Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contacts_tenant_name", "tenant_id", "last_name", "first_name"), Index("ix_contacts_tenant_type", "tenant_id", "type"),
Index("ix_contacts_email", "email"), Index("ix_contacts_tenant_name", "tenant_id", "name"),
Index("ix_contacts_tenant_displayname", "tenant_id", "displayname"),
Index("ix_contacts_email", "email_1"),
Index("ix_contacts_code", "code"),
Index("ix_contacts_search_vec", "search_tsv", postgresql_using="gin"),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False) # ── Identity & Type ──
email: Mapped[str | None] = mapped_column(String(255), nullable=True) type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
phone: Mapped[str | None] = mapped_column(String(30), nullable=True) displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True) name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
position: Mapped[str | None] = mapped_column(String(100), nullable=True) firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
department: Mapped[str | None] = mapped_column(String(100), nullable=True) surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True) surfix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
notes: Mapped[str | None] = mapped_column(Text, nullable=True) ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
# ── Customer / Accounting ──
code: Mapped[str | None] = mapped_column(String(100), nullable=True) # customer number
accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
vendor_accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
# ── Mailing Address (inline) ──
mailing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
mailing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
mailing_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
mailing_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
mailing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
mailing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
mailing_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Visit Address (inline) ──
visit_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
visit_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
visit_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
visit_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
visit_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
visit_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
# ── Invoice Address (inline) ──
invoice_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
invoice_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
invoice_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
invoice_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── General country ──
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Communication ──
phone_1: Mapped[str | None] = mapped_column(String(50), nullable=True)
phone_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
email_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
email_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
# ── Financial & Tax ──
vat_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # USt-IdNr.
fiscal_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Steuernummer
commerce_code: Mapped[str | None] = mapped_column(String(100), nullable=True) # Handelsregister
purchase_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Bestellnummer
bic: Mapped[str | None] = mapped_column(String(50), nullable=True)
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
# ── Discounts ──
discount_crew: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_transport: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_rental: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_sale: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_subrent: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_total: Mapped[float] = mapped_column(Float, nullable=False, default=0)
# ── Geo ──
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ── Notes & Warnings ──
projectnote: Mapped[str | None] = mapped_column(Text, nullable=True)
projectnote_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_warning: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
image: Mapped[str | None] = mapped_column(Text, nullable=True) # logo/image URL or base64
# ── Default contact persons (self-referential via contactpersons table) ──
default_person_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
)
admin_contactperson_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
)
# ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
# ── FTS ──
search_tsv: Mapped[Any] = mapped_column(
TSVECTOR,
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 ──
created_by: Mapped[uuid.UUID | None] = mapped_column( created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
@@ -47,25 +163,72 @@ class Contact(Base, TenantMixin):
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
# ── Relationships ──
contact_persons: Mapped[list[ContactPerson]] = relationship(
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
)
class CompanyContact(Base, TenantMixin):
"""N:M join table between Company and Contact."""
__tablename__ = "company_contacts" class ContactPerson(Base, TenantMixin):
"""Ansprechpartner — 1:N child of a Contact.
Represents a person working at / associated with a company contact.
Has its own address and communication fields.
"""
__tablename__ = "contactpersons"
__table_args__ = ( __table_args__ = (
UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"), Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_cc_company", "company_id"), Index("ix_contactpersons_contact", "contact_id"),
Index("ix_cc_contact", "contact_id"), Index("ix_contactpersons_email", "email"),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
company_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False # ── Parent contact ──
)
contact_id: Mapped[uuid.UUID] = mapped_column( contact_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
) )
role_at_company: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # ── Name ──
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
middle_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
lastname: Mapped[str | None] = mapped_column(String(100), nullable=True)
function: Mapped[str | None] = mapped_column(String(255), nullable=True) # position/role
# ── Communication ──
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
mobilephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ── Own address ──
street: Mapped[str | None] = mapped_column(String(255), nullable=True)
number: Mapped[str | None] = mapped_column(String(20), nullable=True)
postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
# ── Other ──
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# ── Relationship ──
contact: Mapped[Contact] = relationship(
back_populates="contact_persons", foreign_keys=[contact_id]
)
# Keep old names for backward compat during migration
CompanyContact = None # deprecated — replaced by ContactPerson 1:N
+1 -1
View File
@@ -128,7 +128,7 @@ async def deep_analysis(
extended_context["contact"] = _serialize_row(contact) if contact else None extended_context["contact"] = _serialize_row(contact) if contact else None
elif entity_type == "company": elif entity_type == "company":
from app.models.company import Company, CompanyContact from app.models.contact import Contact as Company
comp_result = await db.execute( comp_result = await db.execute(
select(Company) select(Company)
@@ -22,8 +22,8 @@ from app.core.cache import get_cache
from app.core.db import create_db_session, get_session_factory from app.core.db import create_db_session, get_session_factory
from app.core.notifications import create_notification from app.core.notifications import create_notification
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.company import Company from app.models.contact import Contact as Company
from app.models.contact import CompanyContact, Contact from app.models.contact import Contact, ContactPerson
from app.plugins.builtins.ai_proactive.models import ( from app.plugins.builtins.ai_proactive.models import (
ContextLog, ContextLog,
ProactiveSettings, ProactiveSettings,
@@ -177,9 +177,9 @@ async def gather_context(
# Company via company_contacts # Company via company_contacts
cc_result = await db.execute( cc_result = await db.execute(
select(CompanyContact) select(ContactPerson)
.where(CompanyContact.contact_id == entity_id) .where(ContactPerson.contact_id == entity_id)
.where(CompanyContact.tenant_id == tenant_id) .where(ContactPerson.tenant_id == tenant_id)
.limit(5) .limit(5)
) )
companies: list[dict[str, Any]] = [] companies: list[dict[str, Any]] = []
@@ -279,9 +279,9 @@ async def gather_context(
# Contacts via company_contacts # Contacts via company_contacts
cc_result = await db.execute( cc_result = await db.execute(
select(CompanyContact) select(ContactPerson)
.where(CompanyContact.company_id == entity_id) .where(ContactPerson.contact_id == entity_id)
.where(CompanyContact.tenant_id == tenant_id) .where(ContactPerson.tenant_id == tenant_id)
) )
contacts: list[dict[str, Any]] = [] contacts: list[dict[str, Any]] = []
for cc in cc_result.scalars().all(): for cc in cc_result.scalars().all():
+96 -145
View File
@@ -1,4 +1,4 @@
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export.""" """Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete."""
from __future__ import annotations from __future__ import annotations
@@ -8,13 +8,16 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import require_permission from app.deps import require_permission
from app.models.contact import Contact from app.schemas.contact import (
from app.schemas.contact import ContactCreate, ContactUpdate ContactCreate,
ContactUpdate,
ContactPersonCreate,
ContactPersonUpdate,
)
from app.services import contact_service from app.services import contact_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
@@ -25,120 +28,35 @@ async def list_contacts(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(None), search: str | None = Query(None),
sort_by: str = Query("last_name"), type: str | None = Query(None, pattern="^(company|person)$"),
sort_by: str = Query("displayname"),
sort_order: str = Query("asc", pattern="^(asc|desc)$"), sort_order: str = Query("asc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), current_user: dict = Depends(require_permission("contacts:read")),
): ):
"""List contacts with pagination and optional search. """List contacts with pagination, FTS search, type filter, sorting."""
page_size is capped at 100 — values >100 return 422.
"""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
result = await contact_service.list_contacts( return await contact_service.list_contacts(
db, db, tenant_id,
tenant_id, page=page, page_size=page_size, search=search,
page=page, contact_type=type, sort_by=sort_by, sort_order=sort_order,
page_size=page_size,
search=search,
sort_by=sort_by,
sort_order=sort_order,
resolved_perms=current_user, resolved_perms=current_user,
) )
return result
@router.get("/export") @router.get("/export")
async def export_contacts( async def export_contacts(
format: str = Query("csv", pattern="^(csv)$"), format: str = Query("csv", pattern="^(csv)$"),
type: str | None = Query(None, pattern="^(company|person)$"),
search: str | None = Query(None), search: str | None = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), current_user: dict = Depends(require_permission("contacts:read")),
): ):
"""Stream contacts as CSV (not buffered — uses StreamingResponse). """Stream contacts as CSV."""
For large exports (>1000 records), an ARQ background job should be started
with a notification on completion. This endpoint streams directly for
immediate download.
"""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
from app.core.db import get_session_factory csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search)
async def generate_csv():
"""Async generator yielding CSV rows one at a time (streaming).
Creates its own DB session to avoid using the request-scoped session
which gets closed after the endpoint function returns.
"""
output = io.StringIO()
writer = csv.writer(output)
# Write header row
header = [
"id",
"first_name",
"last_name",
"email",
"phone",
"mobile",
"position",
"department",
"linkedin_url",
"notes",
"created_at",
"updated_at",
]
writer.writerow(header)
yield output.getvalue()
output.seek(0)
output.truncate(0)
# Stream rows in batches to avoid loading all into memory
batch_size = 500
offset = 0
factory = get_session_factory()
async with factory() as session:
while True:
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
base = base.where(
(Contact.first_name.ilike(pattern))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
)
base = base.order_by(Contact.last_name.asc()).offset(offset).limit(batch_size)
result = await session.execute(base)
contacts = result.scalars().all()
if not contacts:
break
for c in contacts:
writer.writerow(
[
str(c.id),
c.first_name,
c.last_name,
c.email or "",
c.phone or "",
c.mobile or "",
c.position or "",
c.department or "",
c.linkedin_url or "",
c.notes or "",
c.created_at.isoformat() if c.created_at else "",
c.updated_at.isoformat() if c.updated_at else "",
]
)
yield output.getvalue()
output.seek(0)
output.truncate(0)
offset += batch_size
return StreamingResponse( return StreamingResponse(
generate_csv(), io.StringIO(csv_data),
media_type="text/csv", media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=contacts.csv"}, headers={"Content-Disposition": "attachment; filename=contacts.csv"},
) )
@@ -150,12 +68,10 @@ async def create_contact(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")), current_user: dict = Depends(require_permission("contacts:write")),
): ):
"""Create a contact. Optionally link to companies via company_ids array.""" """Create a new contact (company or person)."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
data = body.model_dump()
return await contact_service.create_contact(db, tenant_id, user_id, data) return await contact_service.create_contact(db, tenant_id, user_id, data)
@@ -165,19 +81,12 @@ async def get_contact(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), current_user: dict = Depends(require_permission("contacts:read")),
): ):
"""Get a single contact with companies array. Cross-tenant returns 404.""" """Get a single contact with contact_persons."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
try: try:
cid = uuid.UUID(contact_id) return await contact_service.get_contact(db, tenant_id, contact_id)
except ValueError: except ValueError as e:
raise HTTPException( raise HTTPException(status_code=404, detail=str(e))
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = await contact_service.get_contact_detail(db, tenant_id, cid, resolved_perms=current_user)
if data is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
return data
@router.put("/{contact_id}") @router.put("/{contact_id}")
@@ -190,47 +99,89 @@ async def update_contact(
"""Update a contact.""" """Update a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
try: try:
cid = uuid.UUID(contact_id) return await contact_service.update_contact(db, tenant_id, user_id, contact_id, data)
except ValueError: except ValueError as e:
raise HTTPException( raise HTTPException(status_code=404, detail=str(e))
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = body.model_dump(exclude_unset=True)
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
if result is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
return result
@router.delete("/{contact_id}") @router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact( async def delete_contact(
contact_id: str, contact_id: str,
gdpr: bool = Query(False), hard: bool = Query(False, description="GDPR hard-delete"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")), current_user: dict = Depends(require_permission("contacts:write")),
): ):
"""Delete a contact. Default: soft-delete. Use gdpr=true for hard-delete with deletion_log.""" """Soft-delete (or hard-delete with ?hard=true) a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
if hard:
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
else:
await contact_service.delete_contact(db, tenant_id, contact_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ── ContactPersons ──
@router.get("/{contact_id}/persons")
async def list_contact_persons(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""List all contact persons for a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await contact_service.list_contact_persons(db, tenant_id, contact_id)
@router.post("/{contact_id}/persons", status_code=status.HTTP_201_CREATED)
async def create_contact_person(
contact_id: str,
body: ContactPersonCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Add a contact person to a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
try: try:
cid = uuid.UUID(contact_id) return await contact_service.create_contact_person(db, tenant_id, user_id, contact_id, data)
except ValueError: except ValueError as e:
raise HTTPException( raise HTTPException(status_code=404, detail=str(e))
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
if gdpr:
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
else:
deleted = await contact_service.soft_delete_contact(db, tenant_id, user_id, cid)
if not deleted: @router.put("/{contact_id}/persons/{person_id}")
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) async def update_contact_person(
contact_id: str,
person_id: str,
body: ContactPersonUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Update a contact person."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
try:
return await contact_service.update_contact_person(db, tenant_id, user_id, contact_id, person_id, data)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{contact_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact_person(
contact_id: str,
person_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Delete a contact person."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
await contact_service.delete_contact_person(db, tenant_id, contact_id, person_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+270 -30
View File
@@ -1,52 +1,291 @@
"""Contact schemas — create, update, read, list.""" """Unified contact schemas — create, update, read, list, contactpersons."""
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ContactCreate(BaseModel): # ── ContactPerson ──
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100) class ContactPersonCreate(BaseModel):
firstname: str | None = Field(None, max_length=100)
middle_name: str | None = Field(None, max_length=100)
lastname: str | None = Field(None, max_length=100)
function: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=50)
mobilephone: str | None = Field(None, max_length=50)
email: str | None = Field(None, max_length=255) email: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=30) street: str | None = Field(None, max_length=255)
mobile: str | None = Field(None, max_length=30) number: str | None = Field(None, max_length=20)
position: str | None = Field(None, max_length=100) postalcode: str | None = Field(None, max_length=20)
department: str | None = Field(None, max_length=100) city: str | None = Field(None, max_length=100)
linkedin_url: str | None = Field(None, max_length=500) state: str | None = Field(None, max_length=100)
notes: str | None = None country: str | None = Field(None, max_length=2)
company_ids: list[str] | None = None tags: str | None = Field(None, max_length=500)
custom: dict | None = None
class ContactPersonUpdate(BaseModel):
firstname: str | None = Field(None, max_length=100)
middle_name: str | None = Field(None, max_length=100)
lastname: str | None = Field(None, max_length=100)
function: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=50)
mobilephone: str | None = Field(None, max_length=50)
email: str | None = Field(None, max_length=255)
street: str | None = Field(None, max_length=255)
number: str | None = Field(None, max_length=20)
postalcode: str | None = Field(None, max_length=20)
city: str | None = Field(None, max_length=100)
state: str | None = Field(None, max_length=100)
country: str | None = Field(None, max_length=2)
tags: str | None = Field(None, max_length=500)
custom: dict | None = None
class ContactPersonResponse(BaseModel):
id: str
contact_id: str
displayname: str
firstname: str | None = None
middle_name: str | None = None
lastname: str | None = None
function: str | None = None
phone: str | None = None
mobilephone: str | None = None
email: str | None = None
street: str | None = None
number: str | None = None
postalcode: str | None = None
city: str | None = None
state: str | None = None
country: str | None = None
tags: str | None = None
custom: dict | None = None
created_at: str | None = None
updated_at: str | None = None
# ── Contact ──
class ContactCreate(BaseModel):
type: str = Field("company", pattern="^(company|person)$")
name: str | None = Field(None, max_length=255)
firstname: str | None = Field(None, max_length=100)
surname: str | None = Field(None, max_length=100)
surfix: str | None = Field(None, max_length=50)
ext_name_line: str | None = Field(None, max_length=255)
gender: str | None = Field(None, max_length=20)
code: str | None = Field(None, max_length=100)
accounting_code: str | None = Field(None, max_length=100)
vendor_accounting_code: str | None = Field(None, max_length=100)
# Mailing
mailing_street: str | None = Field(None, max_length=255)
mailing_number: str | None = Field(None, max_length=20)
mailing_unit_number: str | None = Field(None, max_length=50)
mailing_district: str | None = Field(None, max_length=100)
mailing_extra_address_line: str | None = Field(None, max_length=255)
mailing_postalcode: str | None = Field(None, max_length=20)
mailing_city: str | None = Field(None, max_length=100)
mailing_state: str | None = Field(None, max_length=100)
mailing_country: str | None = Field(None, max_length=2)
# Visit
visit_street: str | None = Field(None, max_length=255)
visit_number: str | None = Field(None, max_length=20)
visit_unit_number: str | None = Field(None, max_length=50)
visit_district: str | None = Field(None, max_length=100)
visit_extra_address_line: str | None = Field(None, max_length=255)
visit_postalcode: str | None = Field(None, max_length=20)
visit_city: str | None = Field(None, max_length=100)
visit_state: str | None = Field(None, max_length=100)
# Invoice
invoice_street: str | None = Field(None, max_length=255)
invoice_number: str | None = Field(None, max_length=20)
invoice_unit_number: str | None = Field(None, max_length=50)
invoice_district: str | None = Field(None, max_length=100)
invoice_extra_address_line: str | None = Field(None, max_length=255)
invoice_postalcode: str | None = Field(None, max_length=20)
invoice_city: str | None = Field(None, max_length=100)
invoice_state: str | None = Field(None, max_length=100)
invoice_country: str | None = Field(None, max_length=2)
# General
country: str | None = Field(None, max_length=2)
# Communication
phone_1: str | None = Field(None, max_length=50)
phone_2: str | None = Field(None, max_length=50)
email_1: str | None = Field(None, max_length=255)
email_2: str | None = Field(None, max_length=255)
website: str | None = Field(None, max_length=500)
# Financial
vat_code: str | None = Field(None, max_length=50)
fiscal_code: str | None = Field(None, max_length=50)
commerce_code: str | None = Field(None, max_length=100)
purchase_number: str | None = Field(None, max_length=100)
bic: str | None = Field(None, max_length=50)
bank_account: str | None = Field(None, max_length=50)
# Discounts
discount_crew: float = 0
discount_transport: float = 0
discount_rental: float = 0
discount_sale: float = 0
discount_subrent: float = 0
discount_total: float = 0
# Geo
latitude: float | None = None
longitude: float | None = None
# Notes
projectnote: str | None = None
projectnote_title: str | None = Field(None, max_length=255)
contact_warning: str | None = None
tags: str | None = Field(None, max_length=500)
image: str | None = None
# Custom
custom: dict | None = None
# Contact persons (optional inline create)
contact_persons: list[ContactPersonCreate] | None = None
class ContactUpdate(BaseModel): class ContactUpdate(BaseModel):
first_name: str | None = Field(None, min_length=1, max_length=100) type: str | None = Field(None, pattern="^(company|person)$")
last_name: str | None = Field(None, min_length=1, max_length=100) name: str | None = Field(None, max_length=255)
email: str | None = Field(None, max_length=255) firstname: str | None = Field(None, max_length=100)
phone: str | None = Field(None, max_length=30) surname: str | None = Field(None, max_length=100)
mobile: str | None = Field(None, max_length=30) surfix: str | None = Field(None, max_length=50)
position: str | None = Field(None, max_length=100) ext_name_line: str | None = Field(None, max_length=255)
department: str | None = Field(None, max_length=100) gender: str | None = Field(None, max_length=20)
linkedin_url: str | None = Field(None, max_length=500) code: str | None = Field(None, max_length=100)
notes: str | None = None accounting_code: str | None = Field(None, max_length=100)
vendor_accounting_code: str | None = Field(None, max_length=100)
mailing_street: str | None = Field(None, max_length=255)
mailing_number: str | None = Field(None, max_length=20)
mailing_unit_number: str | None = Field(None, max_length=50)
mailing_district: str | None = Field(None, max_length=100)
mailing_extra_address_line: str | None = Field(None, max_length=255)
mailing_postalcode: str | None = Field(None, max_length=20)
mailing_city: str | None = Field(None, max_length=100)
mailing_state: str | None = Field(None, max_length=100)
mailing_country: str | None = Field(None, max_length=2)
visit_street: str | None = Field(None, max_length=255)
visit_number: str | None = Field(None, max_length=20)
visit_unit_number: str | None = Field(None, max_length=50)
visit_district: str | None = Field(None, max_length=100)
visit_extra_address_line: str | None = Field(None, max_length=255)
visit_postalcode: str | None = Field(None, max_length=20)
visit_city: str | None = Field(None, max_length=100)
visit_state: str | None = Field(None, max_length=100)
invoice_street: str | None = Field(None, max_length=255)
invoice_number: str | None = Field(None, max_length=20)
invoice_unit_number: str | None = Field(None, max_length=50)
invoice_district: str | None = Field(None, max_length=100)
invoice_extra_address_line: str | None = Field(None, max_length=255)
invoice_postalcode: str | None = Field(None, max_length=20)
invoice_city: str | None = Field(None, max_length=100)
invoice_state: str | None = Field(None, max_length=100)
invoice_country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=2)
phone_1: str | None = Field(None, max_length=50)
phone_2: str | None = Field(None, max_length=50)
email_1: str | None = Field(None, max_length=255)
email_2: str | None = Field(None, max_length=255)
website: str | None = Field(None, max_length=500)
vat_code: str | None = Field(None, max_length=50)
fiscal_code: str | None = Field(None, max_length=50)
commerce_code: str | None = Field(None, max_length=100)
purchase_number: str | None = Field(None, max_length=100)
bic: str | None = Field(None, max_length=50)
bank_account: str | None = Field(None, max_length=50)
discount_crew: float | None = None
discount_transport: float | None = None
discount_rental: float | None = None
discount_sale: float | None = None
discount_subrent: float | None = None
discount_total: float | None = None
latitude: float | None = None
longitude: float | None = None
projectnote: str | None = None
projectnote_title: str | None = Field(None, max_length=255)
contact_warning: str | None = None
tags: str | None = Field(None, max_length=500)
image: str | None = None
custom: dict | None = None
default_person_id: str | None = None
admin_contactperson_id: str | None = None
class ContactResponse(BaseModel): class ContactResponse(BaseModel):
id: str id: str
first_name: str type: str
last_name: str displayname: str
email: str | None = None name: str | None = None
phone: str | None = None firstname: str | None = None
mobile: str | None = None surname: str | None = None
position: str | None = None surfix: str | None = None
department: str | None = None ext_name_line: str | None = None
linkedin_url: str | None = None gender: str | None = None
notes: str | None = None code: str | None = None
accounting_code: str | None = None
vendor_accounting_code: str | None = None
# Addresses (flat)
mailing_street: str | None = None
mailing_number: str | None = None
mailing_unit_number: str | None = None
mailing_district: str | None = None
mailing_extra_address_line: str | None = None
mailing_postalcode: str | None = None
mailing_city: str | None = None
mailing_state: str | None = None
mailing_country: str | None = None
visit_street: str | None = None
visit_number: str | None = None
visit_unit_number: str | None = None
visit_district: str | None = None
visit_extra_address_line: str | None = None
visit_postalcode: str | None = None
visit_city: str | None = None
visit_state: str | None = None
invoice_street: str | None = None
invoice_number: str | None = None
invoice_unit_number: str | None = None
invoice_district: str | None = None
invoice_extra_address_line: str | None = None
invoice_postalcode: str | None = None
invoice_city: str | None = None
invoice_state: str | None = None
invoice_country: str | None = None
country: str | None = None
phone_1: str | None = None
phone_2: str | None = None
email_1: str | None = None
email_2: str | None = None
website: str | None = None
vat_code: str | None = None
fiscal_code: str | None = None
commerce_code: str | None = None
purchase_number: str | None = None
bic: str | None = None
bank_account: str | None = None
discount_crew: float = 0
discount_transport: float = 0
discount_rental: float = 0
discount_sale: float = 0
discount_subrent: float = 0
discount_total: float = 0
latitude: float | None = None
longitude: float | None = None
projectnote: str | None = None
projectnote_title: str | None = None
contact_warning: str | None = None
tags: str | None = None
image: str | None = None
custom: dict | None = None
default_person_id: str | None = None
admin_contactperson_id: str | None = None
created_at: str | None = None created_at: str | None = None
updated_at: str | None = None updated_at: str | None = None
class ContactDetailResponse(ContactResponse): class ContactDetailResponse(ContactResponse):
companies: list[dict] = Field(default_factory=list) contact_persons: list[ContactPersonResponse] = Field(default_factory=list)
class ContactListResponse(BaseModel): class ContactListResponse(BaseModel):
@@ -56,6 +295,7 @@ class ContactListResponse(BaseModel):
page_size: int page_size: int
# Keep old names for compat
class CompanyLinkRequest(BaseModel): class CompanyLinkRequest(BaseModel):
role_at_company: str | None = Field(None, max_length=100) role_at_company: str | None = Field(None, max_length=100)
is_primary: bool = False is_primary: bool = False
+1 -1
View File
@@ -191,7 +191,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
"""Migrate existing address fields from companies/contacts to Address records. """Migrate existing address fields from companies/contacts to Address records.
Called during migration 0013. Returns count of created addresses. Called during migration 0013. Returns count of created addresses.
""" """
from app.models.company import Company from app.models.contact import Contact as Company
from app.models.contact import Contact from app.models.contact import Contact
count = 0 count = 0
+1 -1
View File
@@ -13,7 +13,7 @@ from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.auth import check_permission from app.core.auth import check_permission
from app.models.ai_conversation import AIConversation, AIMessage from app.models.ai_conversation import AIConversation, AIMessage
from app.models.company import Company from app.models.contact import Contact as Company
from app.models.contact import Contact from app.models.contact import Contact
from app.models.workflow import Workflow from app.models.workflow import Workflow
+182 -370
View File
@@ -1,44 +1,35 @@
"""Company service — CRUD, FTS search, filter, pagination, soft-delete, N:M, export.""" """Company service — now operates on Contact with type='company'.
Backward-compat layer: all company operations are now contact operations
filtered by type='company'. CompanyContact is replaced by ContactPerson 1:N.
"""
from __future__ import annotations from __future__ import annotations
import csv
import io
import uuid import uuid
from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import asc, delete, desc, func, or_, select from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.audit import log_audit from app.models.contact import Contact, ContactPerson
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]: def _company_to_dict(c: Contact) -> dict:
"""Serialize a Company ORM object to dict.""" """Serialize a company (Contact with type=company) to dict."""
data: dict[str, Any] = { return {
"id": str(c.id), "id": str(c.id),
"name": c.name, "name": c.name or "",
"account_number": c.account_number, "account_number": c.code or "",
"industry": c.industry, "industry": (c.custom or {}).get("industry") if c.custom else None,
"phone": c.phone, "phone": c.phone_1,
"email": c.email, "email": c.email_1,
"website": c.website, "website": c.website,
"description": c.description, "description": (c.custom or {}).get("description") if c.custom else None,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None,
} }
if include_contacts:
data["contacts"] = []
return data
def _filter_company_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter company fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "companies")
async def list_companies( async def list_companies(
@@ -51,411 +42,232 @@ async def list_companies(
sort_by: str = "name", sort_by: str = "name",
sort_order: str = "asc", sort_order: str = "asc",
resolved_perms: dict | None = None, resolved_perms: dict | None = None,
) -> dict[str, Any]: ) -> dict:
"""List companies with pagination, FTS search, industry filter, and sorting.""" """List companies (contacts with type='company')."""
page = max(1, page) base = select(Contact).where(
page_size = max(1, min(100, page_size)) Contact.tenant_id == tenant_id,
Contact.type == "company",
base = select(Company).where( Contact.deleted_at.is_(None),
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
) )
if industry:
base = base.where(Company.industry == industry)
if search: if search:
pattern = f"%{search}%" base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
base = base.where(
or_(
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
)
)
sort_col = getattr(Company, sort_by, Company.name)
if sort_order == "desc":
base = base.order_by(desc(sort_col))
else:
base = base.order_by(asc(sort_col))
count_q = select(func.count()).select_from(base.subquery()) count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q) total = (await db.execute(count_q)).scalar() or 0
total = total_result.scalar_one()
sort_col = getattr(Contact, sort_by if sort_by != "name" else "name", Contact.name)
if sort_order == "desc":
sort_col = sort_col.desc()
base = base.order_by(sort_col)
offset = (page - 1) * page_size offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size) base = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
result = await db.execute(base)
companies = result.scalars().all() companies = result.scalars().all()
items = [_company_to_dict(c) for c in companies]
if resolved_perms is not None:
items = [_filter_company_fields(item, resolved_perms) for item in items]
return { return {
"items": items, "items": [_company_to_dict(c) for c in companies],
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
} }
async def get_company_detail( async def create_company(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
tenant_id: uuid.UUID, ) -> dict:
company_id: uuid.UUID, """Create a company (contact with type='company')."""
resolved_perms: dict | None = None, # Map old fields to new
) -> dict[str, Any] | None: contact_data = {
"""Get a single company with its contacts array.""" "type": "company",
q = select(Company).where( "name": data.get("name", ""),
Company.id == company_id, "code": data.get("account_number"),
Company.tenant_id == tenant_id, "phone_1": data.get("phone"),
Company.deleted_at.is_(None), "email_1": data.get("email"),
) "website": data.get("website"),
result = await db.execute(q) "displayname": data.get("name", ""),
company = result.scalar_one_or_none() }
if company is None: # Store industry/description in custom
return None custom = {}
if data.get("industry"):
custom["industry"] = data["industry"]
if data.get("description"):
custom["description"] = data["description"]
if custom:
contact_data["custom"] = custom
data = _company_to_dict(company, include_contacts=True) contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data)
contacts_q = ( db.add(contact)
select(Contact, CompanyContact) await db.flush()
.join(CompanyContact, CompanyContact.contact_id == Contact.id) return _company_to_dict(contact)
async def get_company_detail(
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, resolved_perms: dict | None = None
) -> dict:
"""Get company detail with contact persons."""
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where( .where(
CompanyContact.company_id == company_id, Contact.id == uuid.UUID(company_id),
CompanyContact.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.deleted_at.is_(None), Contact.deleted_at.is_(None),
) )
) )
contacts_result = await db.execute(contacts_q) result = await db.execute(q)
contacts_list = [] contact = result.scalar_one_or_none()
for contact, link in contacts_result.all(): if not contact:
contacts_list.append( raise ValueError("Company not found")
{
"id": str(contact.id), data = _company_to_dict(contact)
"first_name": contact.first_name, data["contacts"] = [
"last_name": contact.last_name, {
"email": contact.email, "id": str(cp.id),
"phone": contact.phone, "first_name": cp.firstname or "",
"position": contact.position, "last_name": cp.lastname or "",
"role_at_company": link.role_at_company, "email": cp.email,
"is_primary": link.is_primary, "phone": cp.phone,
} "position": cp.function,
) }
data["contacts"] = contacts_list for cp in (contact.contact_persons or [])
if resolved_perms is not None: if cp.deleted_at is None
data = _filter_company_fields(data, resolved_perms) ]
return data return data
async def create_company(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new company and audit-log it."""
company = Company(
tenant_id=tenant_id,
name=data["name"],
account_number=data.get("account_number"),
industry=data.get("industry"),
phone=data.get("phone"),
email=data.get("email"),
website=data.get("website"),
description=data.get("description"),
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
await db.refresh(company)
await log_audit(
db,
tenant_id,
user_id,
"create",
"company",
company.id,
changes={"name": data["name"]},
)
return _company_to_dict(company)
async def update_company( async def update_company(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict
tenant_id: uuid.UUID, ) -> dict:
user_id: uuid.UUID, """Update a company."""
company_id: uuid.UUID, q = select(Contact).where(
data: dict[str, Any], Contact.id == uuid.UUID(company_id),
) -> dict[str, Any] | None: Contact.tenant_id == tenant_id,
"""Update a company (partial update) and audit-log changes.""" Contact.type == "company",
q = select(Company).where( Contact.deleted_at.is_(None),
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
) )
result = await db.execute(q) result = await db.execute(q)
company = result.scalar_one_or_none() contact = result.scalar_one_or_none()
if company is None: if not contact:
return None raise ValueError("Company not found")
changes: dict[str, Any] = {} if "name" in data:
all_fields = ( contact.name = data["name"]
"name", "account_number", "industry", "phone", "email", contact.displayname = data["name"]
"website", "description", if "account_number" in data:
) contact.code = data["account_number"]
for field in all_fields: if "phone" in data:
if field in data and data[field] is not None: contact.phone_1 = data["phone"]
old_val = getattr(company, field) if "email" in data:
changes[field] = {"old": old_val, "new": data[field]} contact.email_1 = data["email"]
setattr(company, field, data[field]) if "website" in data:
company.updated_by = user_id contact.website = data["website"]
# Store industry/description in custom
custom = dict(contact.custom or {})
if "industry" in data:
custom["industry"] = data["industry"]
if "description" in data:
custom["description"] = data["description"]
if custom:
contact.custom = custom
contact.updated_by = user_id
await db.flush() await db.flush()
await db.refresh(company) return _company_to_dict(contact)
await log_audit(db, tenant_id, user_id, "update", "company", company_id, changes=changes)
return _company_to_dict(company)
async def soft_delete_company( async def soft_delete_company(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
cascade: bool = False,
) -> bool: ) -> bool:
"""Soft-delete a company. If cascade=True, also soft-delete linked CompanyContact rows.""" """Soft-delete a company."""
q = select(Company).where( from datetime import datetime, timezone
Company.id == company_id, q = select(Contact).where(
Company.tenant_id == tenant_id, Contact.id == uuid.UUID(company_id),
Company.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
return False
company.deleted_at = datetime.now(UTC)
company.updated_by = user_id
if cascade:
await db.execute(
delete(CompanyContact).where(
CompanyContact.company_id == company_id,
CompanyContact.tenant_id == tenant_id,
)
)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "company", company_id,
changes={"name": company.name, "cascade": cascade},
)
return True
async def link_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
contact_id: uuid.UUID,
role_at_company: str | None = None,
is_primary: bool = False,
) -> dict[str, Any] | None:
"""Link a contact to a company (N:M). Returns link data or None if either not found."""
comp_q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
comp_result = await db.execute(comp_q)
if comp_result.scalar_one_or_none() is None:
return None
cont_q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.deleted_at.is_(None), Contact.deleted_at.is_(None),
) )
cont_result = await db.execute(cont_q)
if cont_result.scalar_one_or_none() is None:
return None
existing_q = select(CompanyContact).where(
CompanyContact.company_id == company_id,
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
)
existing_result = await db.execute(existing_q)
existing = existing_result.scalar_one_or_none()
if existing is not None:
existing.role_at_company = role_at_company
existing.is_primary = is_primary
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", existing.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
"id": str(existing.id),
"company_id": str(company_id),
"contact_id": str(contact_id),
"role_at_company": role_at_company,
"is_primary": is_primary,
}
link = CompanyContact(
tenant_id=tenant_id,
company_id=company_id,
contact_id=contact_id,
role_at_company=role_at_company,
is_primary=is_primary,
)
db.add(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
"id": str(link.id),
"company_id": str(company_id),
"contact_id": str(contact_id),
"role_at_company": role_at_company,
"is_primary": is_primary,
}
async def unlink_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""Unlink a contact from a company (N:M)."""
q = select(CompanyContact).where(
CompanyContact.company_id == company_id,
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
)
result = await db.execute(q) result = await db.execute(q)
link = result.scalar_one_or_none() contact = result.scalar_one_or_none()
if link is None: if not contact:
return False return False
contact.deleted_at = datetime.now(timezone.utc)
await db.delete(link)
await db.flush() await db.flush()
await log_audit(
db, tenant_id, user_id, "unlink", "company_contact", link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return True return True
async def export_companies_csv(
db: AsyncSession,
tenant_id: uuid.UUID,
industry: str | None = None,
search: str | None = None,
) -> str:
"""Export companies as CSV string."""
base = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
if industry:
base = base.where(Company.industry == industry)
if search:
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
)
)
base = base.order_by(Company.name)
result = await db.execute(base)
companies = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
)
for c in companies:
writer.writerow(
[str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or ""]
)
return output.getvalue()
async def export_companies_xlsx( async def export_companies_xlsx(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None
tenant_id: uuid.UUID,
industry: str | None = None,
search: str | None = None,
) -> bytes: ) -> bytes:
"""Export companies as XLSX bytes.""" """Export companies as XLSX."""
from openpyxl import Workbook from openpyxl import Workbook
base = select(Company).where( base = select(Contact).where(
Company.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Company.deleted_at.is_(None), Contact.type == "company",
Contact.deleted_at.is_(None),
) )
if industry:
base = base.where(Company.industry == industry)
if search: if search:
pattern = f"%{search}%" base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
base = base.where( base = base.order_by(Contact.name)
or_(
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
)
)
base = base.order_by(Company.name)
result = await db.execute(base) result = await db.execute(base)
companies = result.scalars().all() companies = result.scalars().all()
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Companies" ws.title = "Companies"
headers = [ ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"])
"id", "name", "account_number", "industry", "phone", "email", "website", "description",
]
ws.append(headers)
for c in companies: for c in companies:
ws.append( custom = c.custom or {}
[str(c.id), c.name, c.account_number or "", c.industry or "", ws.append([
c.phone or "", c.email or "", c.website or "", c.description or ""] c.name or "", c.code or "", custom.get("industry", ""),
) c.phone_1 or "", c.email_1 or "", c.website or "",
])
import io
buf = io.BytesIO() buf = io.BytesIO()
wb.save(buf) wb.save(buf)
return buf.getvalue() return buf.getvalue()
async def get_company_emails( async def link_contact(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
tenant_id: uuid.UUID, ) -> dict:
company_id: uuid.UUID, """Link a contact person to a company (create ContactPerson)."""
) -> list[dict[str, Any]]: # In new model, contact_id is used to create a ContactPerson under the company contact
"""Get emails for a company (mail plugin inactive — returns empty array).""" # This is a compat shim — the old API expected a separate Contact entity
q = select(Company).where( # Now we just create a ContactPerson linked to the company
Company.id == company_id, cp = ContactPerson(
Company.tenant_id == tenant_id, tenant_id=tenant_id,
Company.deleted_at.is_(None), contact_id=uuid.UUID(company_id),
created_by=user_id,
updated_by=user_id,
)
db.add(cp)
await db.flush()
return {"id": str(cp.id), "company_id": company_id, "contact_id": contact_id}
async def unlink_contact(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
) -> bool:
"""Unlink a contact person from a company (soft-delete ContactPerson)."""
from datetime import datetime, timezone
q = select(ContactPerson).where(
ContactPerson.contact_id == uuid.UUID(company_id),
ContactPerson.id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
) )
result = await db.execute(q) result = await db.execute(q)
if result.scalar_one_or_none() is None: cp = result.scalar_one_or_none()
return [] if not cp:
return [] return False
cp.deleted_at = datetime.now(timezone.utc)
await db.flush()
return True
+340 -233
View File
@@ -1,309 +1,416 @@
"""Contact service — CRUD, N:M linking, soft-delete, GDPR hard-delete.""" """Unified contact service — CRUD, FTS search, contactpersons, soft-delete, export."""
from __future__ import annotations from __future__ import annotations
import csv
import io
import uuid import uuid
from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import asc, delete, desc, func, select from sqlalchemy import select, func, or_, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.audit import log_audit, log_deletion from app.models.contact import Contact, ContactPerson
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]: def _compute_displayname(data: dict) -> str:
"""Compute displayname from type and name fields."""
if data.get("type") == "person":
parts = [data.get("surfix"), data.get("firstname"), data.get("surname")]
return " ".join(p for p in parts if p).strip()
else:
return data.get("name") or ""
def _serialize_contact(c: Contact) -> dict:
"""Serialize a Contact ORM object to dict.""" """Serialize a Contact ORM object to dict."""
data: dict[str, Any] = { return {
"id": str(c.id), "id": str(c.id),
"first_name": c.first_name, "type": c.type,
"last_name": c.last_name, "displayname": c.displayname,
"email": c.email, "name": c.name,
"phone": c.phone, "firstname": c.firstname,
"mobile": c.mobile, "surname": c.surname,
"position": c.position, "surfix": c.surfix,
"department": c.department, "ext_name_line": c.ext_name_line,
"linkedin_url": c.linkedin_url, "gender": c.gender,
"notes": c.notes, "code": c.code,
"accounting_code": c.accounting_code,
"vendor_accounting_code": c.vendor_accounting_code,
"mailing_street": c.mailing_street,
"mailing_number": c.mailing_number,
"mailing_unit_number": c.mailing_unit_number,
"mailing_district": c.mailing_district,
"mailing_extra_address_line": c.mailing_extra_address_line,
"mailing_postalcode": c.mailing_postalcode,
"mailing_city": c.mailing_city,
"mailing_state": c.mailing_state,
"mailing_country": c.mailing_country,
"visit_street": c.visit_street,
"visit_number": c.visit_number,
"visit_unit_number": c.visit_unit_number,
"visit_district": c.visit_district,
"visit_extra_address_line": c.visit_extra_address_line,
"visit_postalcode": c.visit_postalcode,
"visit_city": c.visit_city,
"visit_state": c.visit_state,
"invoice_street": c.invoice_street,
"invoice_number": c.invoice_number,
"invoice_unit_number": c.invoice_unit_number,
"invoice_district": c.invoice_district,
"invoice_extra_address_line": c.invoice_extra_address_line,
"invoice_postalcode": c.invoice_postalcode,
"invoice_city": c.invoice_city,
"invoice_state": c.invoice_state,
"invoice_country": c.invoice_country,
"country": c.country,
"phone_1": c.phone_1,
"phone_2": c.phone_2,
"email_1": c.email_1,
"email_2": c.email_2,
"website": c.website,
"vat_code": c.vat_code,
"fiscal_code": c.fiscal_code,
"commerce_code": c.commerce_code,
"purchase_number": c.purchase_number,
"bic": c.bic,
"bank_account": c.bank_account,
"discount_crew": c.discount_crew,
"discount_transport": c.discount_transport,
"discount_rental": c.discount_rental,
"discount_sale": c.discount_sale,
"discount_subrent": c.discount_subrent,
"discount_total": c.discount_total,
"latitude": c.latitude,
"longitude": c.longitude,
"projectnote": c.projectnote,
"projectnote_title": c.projectnote_title,
"contact_warning": c.contact_warning,
"tags": c.tags,
"image": c.image,
"custom": c.custom,
"default_person_id": str(c.default_person_id) if c.default_person_id else None,
"admin_contactperson_id": str(c.admin_contactperson_id) if c.admin_contactperson_id else None,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None,
} }
if include_companies:
data["companies"] = []
def _serialize_contact_person(cp: ContactPerson) -> dict:
"""Serialize a ContactPerson ORM object to dict."""
return {
"id": str(cp.id),
"contact_id": str(cp.contact_id),
"displayname": cp.displayname,
"firstname": cp.firstname,
"middle_name": cp.middle_name,
"lastname": cp.lastname,
"function": cp.function,
"phone": cp.phone,
"mobilephone": cp.mobilephone,
"email": cp.email,
"street": cp.street,
"number": cp.number,
"postalcode": cp.postalcode,
"city": cp.city,
"state": cp.state,
"country": cp.country,
"tags": cp.tags,
"custom": cp.custom,
"created_at": cp.created_at.isoformat() if cp.created_at else None,
"updated_at": cp.updated_at.isoformat() if cp.updated_at else None,
}
def _serialize_contact_detail(c: Contact) -> dict:
"""Serialize with contact_persons included."""
data = _serialize_contact(c)
data["contact_persons"] = [_serialize_contact_person(cp) for cp in (c.contact_persons or [])]
return data return data
def _filter_contact_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter contact fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "contacts")
async def list_contacts( async def list_contacts(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
search: str | None = None, search: str | None = None,
sort_by: str = "last_name", contact_type: str | None = None,
sort_by: str = "displayname",
sort_order: str = "asc", sort_order: str = "asc",
resolved_perms: dict | None = None, resolved_perms: dict | None = None,
) -> dict[str, Any]: ) -> dict:
"""List contacts with pagination and optional search.""" """List contacts with pagination, FTS search, type filter, sorting."""
page = max(1, page)
page_size = max(1, min(100, page_size))
base = select(Contact).where( base = select(Contact).where(
Contact.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None), Contact.deleted_at.is_(None),
) )
if contact_type:
base = base.where(Contact.type == contact_type)
if search: if search:
pattern = f"%{search}%"
base = base.where( base = base.where(
(Contact.first_name.ilike(pattern)) Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
) )
sort_col = getattr(Contact, sort_by, Contact.last_name) # Count
if sort_order == "desc":
base = base.order_by(desc(sort_col))
else:
base = base.order_by(asc(sort_col))
count_q = select(func.count()).select_from(base.subquery()) count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q) total = (await db.execute(count_q)).scalar() or 0
total = total_result.scalar_one()
# Sort
sort_col = getattr(Contact, sort_by, Contact.displayname)
if sort_order == "desc":
sort_col = sort_col.desc()
base = base.order_by(sort_col)
# Paginate
offset = (page - 1) * page_size offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size) base = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
result = await db.execute(base)
contacts = result.scalars().all() contacts = result.scalars().all()
items = [_contact_to_dict(c) for c in contacts]
if resolved_perms is not None:
items = [_filter_contact_fields(item, resolved_perms) for item in items]
return { return {
"items": items, "items": [_serialize_contact(c) for c in contacts],
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
} }
async def get_contact_detail( async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict:
db: AsyncSession, """Get a single contact with contact_persons."""
tenant_id: uuid.UUID, q = (
contact_id: uuid.UUID, select(Contact)
resolved_perms: dict | None = None, .options(selectinload(Contact.contact_persons))
) -> dict[str, Any] | None: .where(
"""Get a single contact with its linked companies array.""" Contact.id == uuid.UUID(contact_id),
q = select(Contact).where( Contact.tenant_id == tenant_id,
Contact.id == contact_id, Contact.deleted_at.is_(None),
Contact.tenant_id == tenant_id, )
Contact.deleted_at.is_(None),
) )
result = await db.execute(q) result = await db.execute(q)
contact = result.scalar_one_or_none() contact = result.scalar_one_or_none()
if contact is None: if not contact:
return None raise ValueError("Contact not found")
return _serialize_contact_detail(contact)
data = _contact_to_dict(contact, include_companies=True)
companies_q = (
select(Company, CompanyContact)
.join(CompanyContact, CompanyContact.company_id == Company.id)
.where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
companies_result = await db.execute(companies_q)
companies_list = []
for company, link in companies_result.all():
companies_list.append(
{
"id": str(company.id),
"name": company.name,
"industry": company.industry,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
}
)
data["companies"] = companies_list
if resolved_perms is not None:
data = _filter_contact_fields(data, resolved_perms)
return data
async def create_contact( async def create_contact(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
tenant_id: uuid.UUID, ) -> dict:
user_id: uuid.UUID, """Create a new contact."""
data: dict[str, Any], data["displayname"] = _compute_displayname(data)
) -> dict[str, Any]: contact_persons_data = data.pop("contact_persons", None)
"""Create a new contact, optionally linking to companies via company_ids."""
contact = Contact( contact = Contact(
tenant_id=tenant_id, tenant_id=tenant_id,
first_name=data["first_name"],
last_name=data["last_name"],
email=data.get("email"),
phone=data.get("phone"),
mobile=data.get("mobile"),
position=data.get("position"),
department=data.get("department"),
linkedin_url=data.get("linkedin_url"),
notes=data.get("notes"),
created_by=user_id, created_by=user_id,
updated_by=user_id, updated_by=user_id,
**{k: v for k, v in data.items() if hasattr(Contact, k)},
) )
db.add(contact) db.add(contact)
await db.flush() await db.flush()
company_ids = data.get("company_ids") # Create inline contact persons
linked_companies = [] if contact_persons_data:
if company_ids: for cp_data in contact_persons_data:
for cid_str in company_ids: cp_data["displayname"] = " ".join(
try: p for p in [cp_data.get("firstname"), cp_data.get("lastname")] if p
cid = uuid.UUID(cid_str) if isinstance(cid_str, str) else cid_str ).strip()
except (ValueError, TypeError): cp = ContactPerson(
continue
comp_q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
comp_result = await db.execute(comp_q)
if comp_result.scalar_one_or_none() is None:
continue
link = CompanyContact(
tenant_id=tenant_id, tenant_id=tenant_id,
company_id=cid,
contact_id=contact.id, contact_id=contact.id,
created_by=user_id,
updated_by=user_id,
**{k: v for k, v in cp_data.items() if hasattr(ContactPerson, k)},
) )
db.add(link) db.add(cp)
linked_companies.append(str(cid))
await db.flush() await db.flush()
await db.refresh(contact) # Load with contact_persons
await log_audit( q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id)
db, result = await db.execute(q)
tenant_id, contact = result.scalar_one()
user_id, return _serialize_contact_detail(contact)
"create",
"contact",
contact.id,
changes={
"first_name": data["first_name"],
"last_name": data["last_name"],
"linked_companies": linked_companies,
},
)
return _contact_to_dict(contact)
async def update_contact( async def update_contact(
db: AsyncSession, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
tenant_id: uuid.UUID, ) -> dict:
user_id: uuid.UUID, """Update a contact."""
contact_id: uuid.UUID, q = (
data: dict[str, Any], select(Contact)
) -> dict[str, Any] | None: .options(selectinload(Contact.contact_persons))
"""Update a contact (partial update) and audit-log changes.""" .where(
q = select(Contact).where( Contact.id == uuid.UUID(contact_id),
Contact.id == contact_id, Contact.tenant_id == tenant_id,
Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None),
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return None
changes: dict[str, Any] = {}
all_fields = (
"first_name", "last_name", "email", "phone", "mobile",
"position", "department", "linkedin_url", "notes",
)
for field in all_fields:
if field in data and data[field] is not None:
old_val = getattr(contact, field)
changes[field] = {"old": old_val, "new": data[field]}
setattr(contact, field, data[field])
contact.updated_by = user_id
await db.flush()
await db.refresh(contact)
await log_audit(db, tenant_id, user_id, "update", "contact", contact_id, changes=changes)
return _contact_to_dict(contact)
async def soft_delete_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""Soft-delete a contact (set deleted_at)."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return False
contact.deleted_at = datetime.now(UTC)
contact.updated_by = user_id
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "contact", contact_id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
return True
async def gdpr_hard_delete_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""GDPR hard-delete: physical delete + deletion_log entry with snapshot."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return False
snapshot = _contact_to_dict(contact)
await db.execute(
delete(CompanyContact).where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
) )
) )
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
# Recompute displayname if name fields changed
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
merged = {**_serialize_contact(contact), **data}
data["displayname"] = _compute_displayname(merged)
for key, value in data.items():
if hasattr(contact, key):
setattr(contact, key, value)
contact.updated_by = user_id
await db.flush()
return _serialize_contact_detail(contact)
async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
"""Soft-delete a contact."""
q = select(Contact).where(
Contact.id == uuid.UUID(contact_id),
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
from datetime import datetime, timezone
contact.deleted_at = datetime.now(timezone.utc)
await db.flush()
async def hard_delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
"""GDPR hard-delete a contact."""
q = select(Contact).where(
Contact.id == uuid.UUID(contact_id),
Contact.tenant_id == tenant_id,
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
await db.delete(contact) await db.delete(contact)
await db.flush() await db.flush()
await log_deletion(
db, # ── ContactPerson CRUD ──
tenant_id,
user_id, async def list_contact_persons(
"contact", db: AsyncSession, tenant_id: uuid.UUID, contact_id: str
contact_id, ) -> list[dict]:
snapshot, """List all contact persons for a contact."""
q = select(ContactPerson).where(
ContactPerson.contact_id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
).order_by(ContactPerson.displayname)
result = await db.execute(q)
return [_serialize_contact_person(cp) for cp in result.scalars().all()]
async def create_contact_person(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
) -> dict:
"""Add a contact person to a contact."""
data["displayname"] = " ".join(
p for p in [data.get("firstname"), data.get("lastname")] if p
).strip()
cp = ContactPerson(
tenant_id=tenant_id,
contact_id=uuid.UUID(contact_id),
created_by=user_id,
updated_by=user_id,
**{k: v for k, v in data.items() if hasattr(ContactPerson, k)},
) )
return True db.add(cp)
await db.flush()
return _serialize_contact_person(cp)
async def update_contact_person(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, person_id: str, data: dict
) -> dict:
"""Update a contact person."""
q = select(ContactPerson).where(
ContactPerson.id == uuid.UUID(person_id),
ContactPerson.contact_id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
)
result = await db.execute(q)
cp = result.scalar_one_or_none()
if not cp:
raise ValueError("Contact person not found")
if any(k in data for k in ("firstname", "lastname")):
merged = {**_serialize_contact_person(cp), **data}
data["displayname"] = " ".join(
p for p in [merged.get("firstname"), merged.get("lastname")] if p
).strip()
for key, value in data.items():
if hasattr(cp, key):
setattr(cp, key, value)
cp.updated_by = user_id
await db.flush()
return _serialize_contact_person(cp)
async def delete_contact_person(
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, person_id: str
) -> None:
"""Soft-delete a contact person."""
q = select(ContactPerson).where(
ContactPerson.id == uuid.UUID(person_id),
ContactPerson.contact_id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
)
result = await db.execute(q)
cp = result.scalar_one_or_none()
if not cp:
raise ValueError("Contact person not found")
from datetime import datetime, timezone
cp.deleted_at = datetime.now(timezone.utc)
await db.flush()
async def export_contacts_csv(
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None
) -> str:
"""Export contacts as CSV string."""
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if contact_type:
base = base.where(Contact.type == contact_type)
if search:
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
base = base.order_by(Contact.displayname)
result = await db.execute(base)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "type", "displayname", "name", "firstname", "surname", "code",
"email_1", "email_2", "phone_1", "phone_2", "website",
"mailing_city", "mailing_postalcode", "mailing_country",
"vat_code", "tags",
])
for c in contacts:
writer.writerow([
str(c.id), c.type, c.displayname, c.name or "", c.firstname or "", c.surname or "",
c.code or "", c.email_1 or "", c.email_2 or "", c.phone_1 or "", c.phone_2 or "",
c.website or "", c.mailing_city or "", c.mailing_postalcode or "",
c.mailing_country or "", c.vat_code or "", c.tags or "",
])
return output.getvalue()
+3 -3
View File
@@ -11,10 +11,10 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit from app.core.audit import log_audit
from app.models.company import Company from app.models.contact import Contact, ContactPerson as Company
from app.models.contact import Contact from app.models.contact import Contact, ContactPerson
from app.services.company_service import _company_to_dict from app.services.company_service import _company_to_dict
from app.services.contact_service import _contact_to_dict from app.services.contact_service import _serialize_contact as _contact_to_dict
# Expected CSV columns for each entity type # Expected CSV columns for each entity type
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"] COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
+142
View File
@@ -0,0 +1,142 @@
-- Migration 0021: Unified contacts model
DROP TABLE IF EXISTS company_contacts CASCADE;
DROP TABLE IF EXISTS contacts CASCADE;
DROP TABLE IF EXISTS companies CASCADE;
-- Create contacts table (without contactperson FKs first)
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'company',
displayname VARCHAR(255) NOT NULL DEFAULT '',
name VARCHAR(255),
firstname VARCHAR(100),
surname VARCHAR(100),
surfix VARCHAR(50),
ext_name_line VARCHAR(255),
gender VARCHAR(20),
code VARCHAR(100),
accounting_code VARCHAR(100),
vendor_accounting_code VARCHAR(100),
mailing_street VARCHAR(255),
mailing_number VARCHAR(20),
mailing_unit_number VARCHAR(50),
mailing_district VARCHAR(100),
mailing_extra_address_line VARCHAR(255),
mailing_postalcode VARCHAR(20),
mailing_city VARCHAR(100),
mailing_state VARCHAR(100),
mailing_country VARCHAR(2),
visit_street VARCHAR(255),
visit_number VARCHAR(20),
visit_unit_number VARCHAR(50),
visit_district VARCHAR(100),
visit_extra_address_line VARCHAR(255),
visit_postalcode VARCHAR(20),
visit_city VARCHAR(100),
visit_state VARCHAR(100),
invoice_street VARCHAR(255),
invoice_number VARCHAR(20),
invoice_unit_number VARCHAR(50),
invoice_district VARCHAR(100),
invoice_extra_address_line VARCHAR(255),
invoice_postalcode VARCHAR(20),
invoice_city VARCHAR(100),
invoice_state VARCHAR(100),
invoice_country VARCHAR(2),
country VARCHAR(2),
phone_1 VARCHAR(50),
phone_2 VARCHAR(50),
email_1 VARCHAR(255),
email_2 VARCHAR(255),
website VARCHAR(500),
vat_code VARCHAR(50),
fiscal_code VARCHAR(50),
commerce_code VARCHAR(100),
purchase_number VARCHAR(100),
bic VARCHAR(50),
bank_account VARCHAR(50),
discount_crew FLOAT NOT NULL DEFAULT 0,
discount_transport FLOAT NOT NULL DEFAULT 0,
discount_rental FLOAT NOT NULL DEFAULT 0,
discount_sale FLOAT NOT NULL DEFAULT 0,
discount_subrent FLOAT NOT NULL DEFAULT 0,
discount_total FLOAT NOT NULL DEFAULT 0,
latitude FLOAT,
longitude FLOAT,
projectnote TEXT,
projectnote_title VARCHAR(255),
contact_warning TEXT,
tags VARCHAR(500),
image TEXT,
custom JSON DEFAULT '{}'::json,
search_tsv TSVECTOR GENERATED ALWAYS AS (
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, '')
)
) STORED,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX ix_contacts_tenant_deleted ON contacts (tenant_id, deleted_at);
CREATE INDEX ix_contacts_tenant_type ON contacts (tenant_id, type);
CREATE INDEX ix_contacts_tenant_name ON contacts (tenant_id, name);
CREATE INDEX ix_contacts_tenant_displayname ON contacts (tenant_id, displayname);
CREATE INDEX ix_contacts_email ON contacts (email_1);
CREATE INDEX ix_contacts_code ON contacts (code);
CREATE INDEX ix_contacts_search_vec ON contacts USING gin (search_tsv);
-- Create contactpersons table
CREATE TABLE contactpersons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
displayname VARCHAR(255) NOT NULL DEFAULT '',
firstname VARCHAR(100),
middle_name VARCHAR(100),
lastname VARCHAR(100),
function VARCHAR(255),
phone VARCHAR(50),
mobilephone VARCHAR(50),
email VARCHAR(255),
street VARCHAR(255),
number VARCHAR(20),
postalcode VARCHAR(20),
city VARCHAR(100),
state VARCHAR(100),
country VARCHAR(2),
tags VARCHAR(500),
custom JSON DEFAULT '{}'::json,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX ix_contactpersons_tenant_deleted ON contactpersons (tenant_id, deleted_at);
CREATE INDEX ix_contactpersons_contact ON contactpersons (contact_id);
CREATE INDEX ix_contactpersons_email ON contactpersons (email);
-- Add FK columns to contacts referencing contactpersons
ALTER TABLE contacts ADD COLUMN default_person_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL;
ALTER TABLE contacts ADD COLUMN admin_contactperson_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL;
-- Update alembic version
UPDATE alembic_version SET version_num = '0021_unified_contacts';