From cf75680583521c47c5411f47fa4e8784c7e47432 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 19 Jul 2026 21:12:49 +0200 Subject: [PATCH] Unified contacts model: Rentman-style type field (company/person), inline addresses, ContactPerson 1:N, all Rentman fields except rental-specific --- alembic/versions/0021_unified_contacts.py | 165 +++++ app/models/__init__.py | 4 +- app/models/company.py | 56 +- app/models/contact.py | 219 ++++++- app/plugins/builtins/ai_proactive/jobs.py | 2 +- app/plugins/builtins/ai_proactive/services.py | 16 +- app/routes/contacts.py | 241 +++----- app/schemas/contact.py | 300 ++++++++- app/services/address_service.py | 2 +- app/services/ai_copilot_service.py | 2 +- app/services/company_service.py | 552 ++++++----------- app/services/contact_service.py | 573 +++++++++++------- app/services/import_export_service.py | 6 +- migration_0021.sql | 142 +++++ 14 files changed, 1411 insertions(+), 869 deletions(-) create mode 100644 alembic/versions/0021_unified_contacts.py create mode 100644 migration_0021.sql diff --git a/alembic/versions/0021_unified_contacts.py b/alembic/versions/0021_unified_contacts.py new file mode 100644 index 0000000..c26ff3b --- /dev/null +++ b/alembic/versions/0021_unified_contacts.py @@ -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") diff --git a/app/models/__init__.py b/app/models/__init__.py index 168e23c..19635a6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,7 +6,7 @@ from app.models.attachment import Attachment from app.models.audit import AuditLog, DeletionLog from app.models.auth import ApiToken, PasswordResetToken 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.group import Group, UserGroup from app.models.notification import Notification, NotificationPreference, NotificationType @@ -37,7 +37,7 @@ __all__ = [ "ApiToken", "Company", "Contact", - "CompanyContact", + "ContactPerson", "Currency", "TaxRate", "Sequence", diff --git a/app/models/company.py b/app/models/company.py index 6ab599b..8eb83d5 100644 --- a/app/models/company.py +++ b/app/models/company.py @@ -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 typing import Any +from app.models.contact import Contact, ContactPerson -from sqlalchemy import Computed, DateTime, ForeignKey, Index, String, Text -from sqlalchemy.dialects.postgresql import TSVECTOR -from sqlalchemy.dialects.postgresql import UUID as PGUUID -from sqlalchemy.orm import Mapped, mapped_column +# Backward-compat: Company is now just a Contact with type='company' +Company = Contact -from app.core.db import Base, TenantMixin - - -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 - ) +__all__ = ["Company", "ContactPerson"] diff --git a/app/models/contact.py b/app/models/contact.py index ca99761..8895438 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -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 import uuid +from typing import Any from sqlalchemy import ( - Boolean, - ForeignKey, + Computed, + ForeignKey, Index, String, Text, - UniqueConstraint, + Float, + JSON, ) +from sqlalchemy.dialects.postgresql import TSVECTOR 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 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" __table_args__ = ( Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"), - Index("ix_contacts_tenant_name", "tenant_id", "last_name", "first_name"), - Index("ix_contacts_email", "email"), + Index("ix_contacts_tenant_type", "tenant_id", "type"), + 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( 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) - email: Mapped[str | None] = mapped_column(String(255), nullable=True) - phone: Mapped[str | None] = mapped_column(String(30), nullable=True) - mobile: Mapped[str | None] = mapped_column(String(30), nullable=True) - position: Mapped[str | None] = mapped_column(String(100), nullable=True) - department: Mapped[str | None] = mapped_column(String(100), nullable=True) - linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True) - notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + # ── Identity & Type ── + type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person' + displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="") + name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name + firstname: Mapped[str | None] = mapped_column(String(100), nullable=True) + surname: Mapped[str | None] = mapped_column(String(100), nullable=True) + surfix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.) + 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( 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 ) + # ── 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__ = ( - UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"), - Index("ix_cc_company", "company_id"), - Index("ix_cc_contact", "contact_id"), + Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"), + Index("ix_contactpersons_contact", "contact_id"), + Index("ix_contactpersons_email", "email"), ) id: Mapped[uuid.UUID] = mapped_column( 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( 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 diff --git a/app/plugins/builtins/ai_proactive/jobs.py b/app/plugins/builtins/ai_proactive/jobs.py index 4fd7137..7203d5e 100644 --- a/app/plugins/builtins/ai_proactive/jobs.py +++ b/app/plugins/builtins/ai_proactive/jobs.py @@ -128,7 +128,7 @@ async def deep_analysis( extended_context["contact"] = _serialize_row(contact) if contact else None elif entity_type == "company": - from app.models.company import Company, CompanyContact + from app.models.contact import Contact as Company comp_result = await db.execute( select(Company) diff --git a/app/plugins/builtins/ai_proactive/services.py b/app/plugins/builtins/ai_proactive/services.py index 88a03a5..fd1d7b1 100644 --- a/app/plugins/builtins/ai_proactive/services.py +++ b/app/plugins/builtins/ai_proactive/services.py @@ -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.notifications import create_notification from app.models.audit import AuditLog -from app.models.company import Company -from app.models.contact import CompanyContact, Contact +from app.models.contact import Contact as Company +from app.models.contact import Contact, ContactPerson from app.plugins.builtins.ai_proactive.models import ( ContextLog, ProactiveSettings, @@ -177,9 +177,9 @@ async def gather_context( # Company via company_contacts cc_result = await db.execute( - select(CompanyContact) - .where(CompanyContact.contact_id == entity_id) - .where(CompanyContact.tenant_id == tenant_id) + select(ContactPerson) + .where(ContactPerson.contact_id == entity_id) + .where(ContactPerson.tenant_id == tenant_id) .limit(5) ) companies: list[dict[str, Any]] = [] @@ -279,9 +279,9 @@ async def gather_context( # Contacts via company_contacts cc_result = await db.execute( - select(CompanyContact) - .where(CompanyContact.company_id == entity_id) - .where(CompanyContact.tenant_id == tenant_id) + select(ContactPerson) + .where(ContactPerson.contact_id == entity_id) + .where(ContactPerson.tenant_id == tenant_id) ) contacts: list[dict[str, Any]] = [] for cc in cc_result.scalars().all(): diff --git a/app/routes/contacts.py b/app/routes/contacts.py index c897dad..706bf69 100644 --- a/app/routes/contacts.py +++ b/app/routes/contacts.py @@ -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 @@ -8,13 +8,16 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi.responses import StreamingResponse -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission -from app.models.contact import Contact -from app.schemas.contact import ContactCreate, ContactUpdate +from app.schemas.contact import ( + ContactCreate, + ContactUpdate, + ContactPersonCreate, + ContactPersonUpdate, +) from app.services import contact_service router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) @@ -25,120 +28,35 @@ async def list_contacts( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), 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)$"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): - """List contacts with pagination and optional search. - - page_size is capped at 100 — values >100 return 422. - """ + """List contacts with pagination, FTS search, type filter, sorting.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - result = await contact_service.list_contacts( - db, - tenant_id, - page=page, - page_size=page_size, - search=search, - sort_by=sort_by, - sort_order=sort_order, + return await contact_service.list_contacts( + db, tenant_id, + page=page, page_size=page_size, search=search, + contact_type=type, sort_by=sort_by, sort_order=sort_order, resolved_perms=current_user, ) - return result @router.get("/export") async def export_contacts( format: str = Query("csv", pattern="^(csv)$"), + type: str | None = Query(None, pattern="^(company|person)$"), search: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): - """Stream contacts as CSV (not buffered — uses StreamingResponse). - - For large exports (>1000 records), an ARQ background job should be started - with a notification on completion. This endpoint streams directly for - immediate download. - """ + """Stream contacts as CSV.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - from app.core.db import get_session_factory - - 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 - + csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search) return StreamingResponse( - generate_csv(), + io.StringIO(csv_data), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=contacts.csv"}, ) @@ -150,12 +68,10 @@ async def create_contact( db: AsyncSession = Depends(get_db), 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"]) user_id = uuid.UUID(current_user["user_id"]) - - - data = body.model_dump() + data = body.model_dump(exclude_none=True) 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), 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"]) try: - cid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException( - 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 + return await contact_service.get_contact(db, tenant_id, contact_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) @router.put("/{contact_id}") @@ -190,47 +99,89 @@ async def update_contact( """Update a contact.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - - + data = body.model_dump(exclude_none=True) try: - cid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException( - 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 + return await contact_service.update_contact(db, tenant_id, user_id, contact_id, data) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) -@router.delete("/{contact_id}") +@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_contact( contact_id: str, - gdpr: bool = Query(False), + hard: bool = Query(False, description="GDPR hard-delete"), db: AsyncSession = Depends(get_db), 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"]) user_id = uuid.UUID(current_user["user_id"]) - - + data = body.model_dump(exclude_none=True) try: - cid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException( - 400, detail={"detail": "Invalid contact_id", "code": "invalid_id"} - ) from None + return await contact_service.create_contact_person(db, tenant_id, user_id, contact_id, data) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) - 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: - raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) +@router.put("/{contact_id}/persons/{person_id}") +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)) diff --git a/app/schemas/contact.py b/app/schemas/contact.py index 5f13118..85b22e9 100644 --- a/app/schemas/contact.py +++ b/app/schemas/contact.py @@ -1,52 +1,291 @@ -"""Contact schemas — create, update, read, list.""" +"""Unified contact schemas — create, update, read, list, contactpersons.""" from __future__ import annotations from pydantic import BaseModel, Field -class ContactCreate(BaseModel): - first_name: str = Field(..., min_length=1, max_length=100) - last_name: str = Field(..., min_length=1, max_length=100) +# ── ContactPerson ── + +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) - phone: str | None = Field(None, max_length=30) - mobile: str | None = Field(None, max_length=30) - position: str | None = Field(None, max_length=100) - department: str | None = Field(None, max_length=100) - linkedin_url: str | None = Field(None, max_length=500) - notes: str | None = None - company_ids: list[str] | None = None + 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 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): - first_name: str | None = Field(None, min_length=1, max_length=100) - last_name: str | None = Field(None, min_length=1, max_length=100) - email: str | None = Field(None, max_length=255) - phone: str | None = Field(None, max_length=30) - mobile: str | None = Field(None, max_length=30) - position: str | None = Field(None, max_length=100) - department: str | None = Field(None, max_length=100) - linkedin_url: str | None = Field(None, max_length=500) - notes: str | None = None + type: str | None = Field(None, 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_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): id: str - first_name: str - last_name: str - email: str | None = None - phone: str | None = None - mobile: str | None = None - position: str | None = None - department: str | None = None - linkedin_url: str | None = None - notes: str | None = None + type: str + displayname: str + name: str | None = None + firstname: str | None = None + surname: str | None = None + surfix: str | None = None + ext_name_line: str | None = None + gender: 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 updated_at: str | None = None class ContactDetailResponse(ContactResponse): - companies: list[dict] = Field(default_factory=list) + contact_persons: list[ContactPersonResponse] = Field(default_factory=list) class ContactListResponse(BaseModel): @@ -56,6 +295,7 @@ class ContactListResponse(BaseModel): page_size: int +# Keep old names for compat class CompanyLinkRequest(BaseModel): role_at_company: str | None = Field(None, max_length=100) is_primary: bool = False diff --git a/app/services/address_service.py b/app/services/address_service.py index 23fbd2e..2c4bc44 100644 --- a/app/services/address_service.py +++ b/app/services/address_service.py @@ -191,7 +191,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int: """Migrate existing address fields from companies/contacts to Address records. 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 count = 0 diff --git a/app/services/ai_copilot_service.py b/app/services/ai_copilot_service.py index dbd8163..970c337 100644 --- a/app/services/ai_copilot_service.py +++ b/app/services/ai_copilot_service.py @@ -13,7 +13,7 @@ from app.ai.llm_client import get_llm_client from app.core.audit import log_audit from app.core.auth import check_permission 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.workflow import Workflow diff --git a/app/services/company_service.py b/app/services/company_service.py index 3b315a2..9657386 100644 --- a/app/services/company_service.py +++ b/app/services/company_service.py @@ -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 -import csv -import io import uuid -from datetime import UTC, datetime 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.orm import selectinload -from app.core.audit import log_audit -from app.models.company import Company -from app.models.contact import CompanyContact, Contact +from app.models.contact import Contact, ContactPerson -def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]: - """Serialize a Company ORM object to dict.""" - data: dict[str, Any] = { +def _company_to_dict(c: Contact) -> dict: + """Serialize a company (Contact with type=company) to dict.""" + return { "id": str(c.id), - "name": c.name, - "account_number": c.account_number, - "industry": c.industry, - "phone": c.phone, - "email": c.email, + "name": c.name or "", + "account_number": c.code or "", + "industry": (c.custom or {}).get("industry") if c.custom else None, + "phone": c.phone_1, + "email": c.email_1, "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, "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( @@ -51,411 +42,232 @@ async def list_companies( sort_by: str = "name", sort_order: str = "asc", resolved_perms: dict | None = None, -) -> dict[str, Any]: - """List companies with pagination, FTS search, industry filter, and sorting.""" - page = max(1, page) - page_size = max(1, min(100, page_size)) - - base = select(Company).where( - Company.tenant_id == tenant_id, - Company.deleted_at.is_(None), +) -> dict: + """List companies (contacts with type='company').""" + base = select(Contact).where( + Contact.tenant_id == tenant_id, + Contact.type == "company", + Contact.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), - ) - ) - - 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)) + base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))) count_q = select(func.count()).select_from(base.subquery()) - total_result = await db.execute(count_q) - total = total_result.scalar_one() + total = (await db.execute(count_q)).scalar() or 0 + + 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 - paginated = base.offset(offset).limit(page_size) - result = await db.execute(paginated) + base = base.offset(offset).limit(page_size) + + result = await db.execute(base) 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 { - "items": items, + "items": [_company_to_dict(c) for c in companies], "total": total, "page": page, "page_size": page_size, } -async def get_company_detail( - db: AsyncSession, - tenant_id: uuid.UUID, - company_id: uuid.UUID, - resolved_perms: dict | None = None, -) -> dict[str, Any] | None: - """Get a single company with its contacts array.""" - q = select(Company).where( - Company.id == company_id, - Company.tenant_id == tenant_id, - Company.deleted_at.is_(None), - ) - result = await db.execute(q) - company = result.scalar_one_or_none() - if company is None: - return None +async def create_company( + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict +) -> dict: + """Create a company (contact with type='company').""" + # Map old fields to new + contact_data = { + "type": "company", + "name": data.get("name", ""), + "code": data.get("account_number"), + "phone_1": data.get("phone"), + "email_1": data.get("email"), + "website": data.get("website"), + "displayname": data.get("name", ""), + } + # Store industry/description in custom + 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) - contacts_q = ( - select(Contact, CompanyContact) - .join(CompanyContact, CompanyContact.contact_id == Contact.id) + contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data) + db.add(contact) + await db.flush() + 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( - CompanyContact.company_id == company_id, - CompanyContact.tenant_id == tenant_id, + Contact.id == uuid.UUID(company_id), + Contact.tenant_id == tenant_id, + Contact.type == "company", Contact.deleted_at.is_(None), ) ) - contacts_result = await db.execute(contacts_q) - contacts_list = [] - for contact, link in contacts_result.all(): - contacts_list.append( - { - "id": str(contact.id), - "first_name": contact.first_name, - "last_name": contact.last_name, - "email": contact.email, - "phone": contact.phone, - "position": contact.position, - "role_at_company": link.role_at_company, - "is_primary": link.is_primary, - } - ) - data["contacts"] = contacts_list - if resolved_perms is not None: - data = _filter_company_fields(data, resolved_perms) + result = await db.execute(q) + contact = result.scalar_one_or_none() + if not contact: + raise ValueError("Company not found") + + data = _company_to_dict(contact) + data["contacts"] = [ + { + "id": str(cp.id), + "first_name": cp.firstname or "", + "last_name": cp.lastname or "", + "email": cp.email, + "phone": cp.phone, + "position": cp.function, + } + for cp in (contact.contact_persons or []) + if cp.deleted_at is None + ] 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( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - company_id: uuid.UUID, - data: dict[str, Any], -) -> dict[str, Any] | None: - """Update a company (partial update) and audit-log changes.""" - q = select(Company).where( - Company.id == company_id, - Company.tenant_id == tenant_id, - Company.deleted_at.is_(None), + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict +) -> dict: + """Update a company.""" + q = select(Contact).where( + Contact.id == uuid.UUID(company_id), + Contact.tenant_id == tenant_id, + Contact.type == "company", + Contact.deleted_at.is_(None), ) result = await db.execute(q) - company = result.scalar_one_or_none() - if company is None: - return None + contact = result.scalar_one_or_none() + if not contact: + raise ValueError("Company not found") - changes: dict[str, Any] = {} - all_fields = ( - "name", "account_number", "industry", "phone", "email", - "website", "description", - ) - for field in all_fields: - if field in data and data[field] is not None: - old_val = getattr(company, field) - changes[field] = {"old": old_val, "new": data[field]} - setattr(company, field, data[field]) - company.updated_by = user_id + if "name" in data: + contact.name = data["name"] + contact.displayname = data["name"] + if "account_number" in data: + contact.code = data["account_number"] + if "phone" in data: + contact.phone_1 = data["phone"] + if "email" in data: + contact.email_1 = data["email"] + if "website" in data: + 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.refresh(company) - await log_audit(db, tenant_id, user_id, "update", "company", company_id, changes=changes) - return _company_to_dict(company) + return _company_to_dict(contact) async def soft_delete_company( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - company_id: uuid.UUID, - cascade: bool = False, + db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True ) -> bool: - """Soft-delete a company. If cascade=True, also soft-delete linked CompanyContact rows.""" - q = select(Company).where( - Company.id == company_id, - Company.tenant_id == tenant_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, + """Soft-delete a company.""" + from datetime import datetime, timezone + q = select(Contact).where( + Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id, + Contact.type == "company", 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) - link = result.scalar_one_or_none() - if link is None: + contact = result.scalar_one_or_none() + if not contact: return False - - await db.delete(link) + contact.deleted_at = datetime.now(timezone.utc) 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 -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( - db: AsyncSession, - tenant_id: uuid.UUID, - industry: str | None = None, - search: str | None = None, + db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None ) -> bytes: - """Export companies as XLSX bytes.""" + """Export companies as XLSX.""" from openpyxl import Workbook - base = select(Company).where( - Company.tenant_id == tenant_id, - Company.deleted_at.is_(None), + base = select(Contact).where( + Contact.tenant_id == tenant_id, + Contact.type == "company", + Contact.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) + base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))) + base = base.order_by(Contact.name) + result = await db.execute(base) companies = result.scalars().all() wb = Workbook() ws = wb.active ws.title = "Companies" - headers = [ - "id", "name", "account_number", "industry", "phone", "email", "website", "description", - ] - ws.append(headers) + ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"]) for c in companies: - ws.append( - [str(c.id), c.name, c.account_number or "", c.industry or "", - c.phone or "", c.email or "", c.website or "", c.description or ""] - ) + custom = c.custom or {} + ws.append([ + 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() wb.save(buf) return buf.getvalue() -async def get_company_emails( - db: AsyncSession, - tenant_id: uuid.UUID, - company_id: uuid.UUID, -) -> list[dict[str, Any]]: - """Get emails for a company (mail plugin inactive — returns empty array).""" - q = select(Company).where( - Company.id == company_id, - Company.tenant_id == tenant_id, - Company.deleted_at.is_(None), +async def link_contact( + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str +) -> dict: + """Link a contact person to a company (create ContactPerson).""" + # In new model, contact_id is used to create a ContactPerson under the company contact + # This is a compat shim — the old API expected a separate Contact entity + # Now we just create a ContactPerson linked to the company + cp = ContactPerson( + tenant_id=tenant_id, + 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) - if result.scalar_one_or_none() is None: - return [] - return [] + cp = result.scalar_one_or_none() + if not cp: + return False + cp.deleted_at = datetime.now(timezone.utc) + await db.flush() + return True diff --git a/app/services/contact_service.py b/app/services/contact_service.py index 298f358..5811dd3 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -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 +import csv +import io import uuid -from datetime import UTC, datetime 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.orm import selectinload -from app.core.audit import log_audit, log_deletion -from app.models.company import Company -from app.models.contact import CompanyContact, Contact +from app.models.contact import Contact, ContactPerson -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.""" - data: dict[str, Any] = { + return { "id": str(c.id), - "first_name": c.first_name, - "last_name": c.last_name, - "email": c.email, - "phone": c.phone, - "mobile": c.mobile, - "position": c.position, - "department": c.department, - "linkedin_url": c.linkedin_url, - "notes": c.notes, + "type": c.type, + "displayname": c.displayname, + "name": c.name, + "firstname": c.firstname, + "surname": c.surname, + "surfix": c.surfix, + "ext_name_line": c.ext_name_line, + "gender": c.gender, + "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, "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 -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( db: AsyncSession, tenant_id: uuid.UUID, page: int = 1, page_size: int = 20, search: str | None = None, - sort_by: str = "last_name", + contact_type: str | None = None, + sort_by: str = "displayname", sort_order: str = "asc", resolved_perms: dict | None = None, -) -> dict[str, Any]: - """List contacts with pagination and optional search.""" - page = max(1, page) - page_size = max(1, min(100, page_size)) - +) -> dict: + """List contacts with pagination, FTS search, type filter, sorting.""" 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: - pattern = f"%{search}%" base = base.where( - (Contact.first_name.ilike(pattern)) - | (Contact.last_name.ilike(pattern)) - | (Contact.email.ilike(pattern)) + Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)) ) - sort_col = getattr(Contact, sort_by, Contact.last_name) - if sort_order == "desc": - base = base.order_by(desc(sort_col)) - else: - base = base.order_by(asc(sort_col)) - + # Count count_q = select(func.count()).select_from(base.subquery()) - total_result = await db.execute(count_q) - total = total_result.scalar_one() + total = (await db.execute(count_q)).scalar() or 0 + # 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 - paginated = base.offset(offset).limit(page_size) - result = await db.execute(paginated) + base = base.offset(offset).limit(page_size) + + result = await db.execute(base) 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 { - "items": items, + "items": [_serialize_contact(c) for c in contacts], "total": total, "page": page, "page_size": page_size, } -async def get_contact_detail( - db: AsyncSession, - tenant_id: uuid.UUID, - contact_id: uuid.UUID, - resolved_perms: dict | None = None, -) -> dict[str, Any] | None: - """Get a single contact with its linked companies array.""" - q = select(Contact).where( - Contact.id == contact_id, - Contact.tenant_id == tenant_id, - Contact.deleted_at.is_(None), +async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict: + """Get a single contact with contact_persons.""" + q = ( + select(Contact) + .options(selectinload(Contact.contact_persons)) + .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 contact is None: - return None - - 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 + if not contact: + raise ValueError("Contact not found") + return _serialize_contact_detail(contact) async def create_contact( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - data: dict[str, Any], -) -> dict[str, Any]: - """Create a new contact, optionally linking to companies via company_ids.""" + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict +) -> dict: + """Create a new contact.""" + data["displayname"] = _compute_displayname(data) + contact_persons_data = data.pop("contact_persons", None) + contact = Contact( 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, updated_by=user_id, + **{k: v for k, v in data.items() if hasattr(Contact, k)}, ) db.add(contact) await db.flush() - company_ids = data.get("company_ids") - linked_companies = [] - if company_ids: - for cid_str in company_ids: - try: - cid = uuid.UUID(cid_str) if isinstance(cid_str, str) else cid_str - except (ValueError, TypeError): - 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( + # Create inline contact persons + if contact_persons_data: + for cp_data in contact_persons_data: + cp_data["displayname"] = " ".join( + p for p in [cp_data.get("firstname"), cp_data.get("lastname")] if p + ).strip() + cp = ContactPerson( tenant_id=tenant_id, - company_id=cid, 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) - linked_companies.append(str(cid)) + db.add(cp) await db.flush() - await db.refresh(contact) - await log_audit( - db, - tenant_id, - user_id, - "create", - "contact", - contact.id, - changes={ - "first_name": data["first_name"], - "last_name": data["last_name"], - "linked_companies": linked_companies, - }, - ) - return _contact_to_dict(contact) + # Load with contact_persons + q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id) + result = await db.execute(q) + contact = result.scalar_one() + return _serialize_contact_detail(contact) async def update_contact( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - contact_id: uuid.UUID, - data: dict[str, Any], -) -> dict[str, Any] | None: - """Update a contact (partial update) and audit-log changes.""" - 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 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, + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict +) -> dict: + """Update a contact.""" + q = ( + select(Contact) + .options(selectinload(Contact.contact_persons)) + .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") + # 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.flush() - await log_deletion( - db, - tenant_id, - user_id, - "contact", - contact_id, - snapshot, + +# ── ContactPerson CRUD ── + +async def list_contact_persons( + db: AsyncSession, tenant_id: uuid.UUID, contact_id: str +) -> list[dict]: + """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() diff --git a/app/services/import_export_service.py b/app/services/import_export_service.py index 8a4260f..bf066b6 100644 --- a/app/services/import_export_service.py +++ b/app/services/import_export_service.py @@ -11,10 +11,10 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit -from app.models.company import Company -from app.models.contact import Contact +from app.models.contact import Contact, ContactPerson as Company +from app.models.contact import Contact, ContactPerson 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 COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"] diff --git a/migration_0021.sql b/migration_0021.sql new file mode 100644 index 0000000..98555d7 --- /dev/null +++ b/migration_0021.sql @@ -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';