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>
63 lines
2.3 KiB
Python
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')
|