Files
maskanx_crm_backend/alembic/versions/c2e5f8a41b76_add_custom_properties.py
T
AFFAANhandClaude Opus 5 9d6aa64ad6 feat(properties): add custom fields without a migration
Modelled on HubSpot's custom properties: the definition lives in a table,
the values live in each record's existing JSON attributes. Adding a field
is an INSERT, so it can happen mid-conversation through MCP and the next
record can use it immediately.

The cost of that is that the database enforces nothing about the values,
so data_type is enforced in application code and every write path has to
come through it — otherwise the type on a definition is decoration.
Values are coerced rather than merely checked, because callers are agents
and HTTP clients and "42" is a number expressed loosely; what is rejected
is genuinely ambiguous, like "quite large" for a number.

A value whose property has no definition is rejected rather than stored.
A typo sitting in the database looks exactly like data, and a registry
whose set of fields is not actually the set of fields is worse than none.
Workspaces with nothing defined keep the old free-form behaviour, so this
does not break attributes already in use.

name and data_type are not updatable: renaming orphans every value stored
under the old key, and retyping leaves values that no longer satisfy the
type. Deleting a definition leaves existing values alone rather than
rewriting every record, so undoing a mistaken delete is just recreating
the property.

Two ways in, sharing one validator: a logged-in person, and an integration
key for MaskanX. An agent gets no shortcut around the rules a person is
held to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:07:50 +05:30

63 lines
2.3 KiB
Python

"""add crm_custom_properties
Fields an operator (or an agent, through MCP) adds without a migration.
The definition lives here; the values live in each record's existing
`attributes` JSON. That split is the whole point: adding a field is an
INSERT, so it can happen mid-conversation and be usable immediately.
Revision ID: c2e5f8a41b76
Revises: b1c4d7e29a03
Create Date: 2026-08-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c2e5f8a41b76'
down_revision: Union[str, None] = 'b1c4d7e29a03'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crm_custom_properties',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('object_type', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('label', sa.String(length=160), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data_type', sa.String(length=24), nullable=False),
sa.Column('options', sa.JSON(), nullable=False),
sa.Column('is_required', sa.Boolean(), nullable=False),
sa.Column('created_by', sa.String(length=120), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
# `name` is the key written into each record's attributes, so it has
# to be unique per object type — two definitions sharing a key would
# fight over the same stored value.
sa.UniqueConstraint(
'tenant_id', 'object_type', 'name',
name='uq_crm_custom_properties_object_name',
),
)
op.create_index(
'ix_crm_custom_properties_tenant_object',
'crm_custom_properties',
['tenant_id', 'object_type'],
)
def downgrade() -> None:
op.drop_index(
'ix_crm_custom_properties_tenant_object',
table_name='crm_custom_properties',
)
op.drop_table('crm_custom_properties')