Skip to content

Data Model: Item Catalog

Module: item_catalog Last Updated: 2026-05-10 Generated From: analysis.md


Tables

All tables are prefixed with ic_ to namespace them within the modular monolith. All include tenant_id for Row Level Security (RLS).


ic_item_group

Stores organizational groups for items. Created before items so items can reference it.

Column Type Nullable Default Description
id UUID No gen_random_uuid() Primary key
tenant_id UUID No Owning tenant; RLS anchor
name VARCHAR(100) No Display name; unique within (tenant_id, item_type) case-insensitively
item_type VARCHAR(20) No Immutable; matches ItemType enum
created_by UUID No User who created the group
created_at TIMESTAMPTZ No NOW()
updated_by UUID Yes NULL
updated_at TIMESTAMPTZ Yes NULL

Constraints:

CONSTRAINT uq_ic_item_group_name_type UNIQUE (tenant_id, item_type, lower(name))
CONSTRAINT chk_ic_item_group_item_type CHECK (
    item_type IN ('ingredient','product','intermediate','packaging','consumable','service','resale')
)

ic_item

The canonical item registry — the core table of this module.

Column Type Nullable Default Description
id UUID No gen_random_uuid() Primary key
tenant_id UUID No Owning tenant; RLS anchor
code VARCHAR(50) No Unique item code within tenant namespace; case-insensitive
name VARCHAR(200) No Display name
item_type VARCHAR(20) No Immutable; matches ItemType enum
storage_unit VARCHAR(10) No Immutable; matches StorageUnit enum
status VARCHAR(10) No 'active' active or inactive
is_system BOOLEAN No false GrinSystem-provided items
description VARCHAR(500) Yes NULL Free-text description
group_id UUID Yes NULL FK → ic_item_group.id; nullable (0..1 group)
shelf_life_days INTEGER Yes NULL Product and intermediate only; positive integer
ean VARCHAR(20) Yes NULL GS1 barcode; unique per tenant when set
created_by UUID No
created_at TIMESTAMPTZ No NOW()
updated_by UUID Yes NULL
updated_at TIMESTAMPTZ Yes NULL

Constraints:

CONSTRAINT uq_ic_item_code UNIQUE (tenant_id, lower(code))

CONSTRAINT uq_ic_item_ean UNIQUE (tenant_id, ean)
    -- Partial: applied only where ean IS NOT NULL

CONSTRAINT chk_ic_item_status CHECK (status IN ('active', 'inactive'))

CONSTRAINT chk_ic_item_item_type CHECK (
    item_type IN ('ingredient','product','intermediate','packaging','consumable','service','resale')
)

CONSTRAINT chk_ic_item_storage_unit CHECK (
    storage_unit IN ('kg','g','l','ml','piece')
)

CONSTRAINT chk_ic_item_shelf_life_positive CHECK (
    shelf_life_days IS NULL OR shelf_life_days > 0
)

CONSTRAINT fk_ic_item_group FOREIGN KEY (group_id)
    REFERENCES ic_item_group(id)
    ON DELETE RESTRICT   -- prevents orphaned group references; group must be empty to delete

ic_item_tag

Tags stored in a child table to allow indexed filtering and avoid array column complexity.

Alternative: tags can be stored as a text[] column with GIN index on ic_item. The child-table approach is preferred for query flexibility (tag-level filtering without array operators). Choose one at implementation time.

Column Type Nullable Default Description
id UUID No gen_random_uuid() Primary key
item_id UUID No FK → ic_item.id ON DELETE CASCADE
tenant_id UUID No Denormalized for RLS
tag VARCHAR(50) No Lowercased; deduplicated at write time

Constraints:

CONSTRAINT uq_ic_item_tag UNIQUE (item_id, lower(tag))
CONSTRAINT fk_ic_item_tag_item FOREIGN KEY (item_id)
    REFERENCES ic_item(id) ON DELETE CASCADE

Full-Text Search Column (on ic_item)

To support diacritic-insensitive full-text search across name, code, description, group name, and tags, a generated tsvector column is maintained:

ALTER TABLE ic_item ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        to_tsvector('simple',
            unaccent(coalesce(name, '')) || ' ' ||
            unaccent(coalesce(code, '')) || ' ' ||
            unaccent(coalesce(description, ''))
        )
    ) STORED;

Tag content and group name are included in search either by joining ic_item_tag and ic_item_group at query time (via ILIKE + unaccent), or by denormalizing them into the search_vector via a trigger. Decision deferred to implementation. The unaccent PostgreSQL extension must be enabled.


Indexes

Index Name Table Columns Type Purpose
ix_ic_item_tenant_status ic_item (tenant_id, status) BTREE Status filter in ListItems
ix_ic_item_tenant_type ic_item (tenant_id, item_type) BTREE item_type multi-select filter
ix_ic_item_group_id ic_item group_id BTREE FK lookup and group filter
ix_ic_item_search_vector ic_item search_vector GIN Full-text search
ix_ic_item_tenant_ean ic_item (tenant_id, ean) BTREE EAN uniqueness check and Warehouse scan
ix_ic_item_tag_item_id ic_item_tag item_id BTREE Tag lookup by item
ix_ic_item_group_tenant_type ic_item_group (tenant_id, item_type) BTREE Group list filtered by type

Row Level Security

All tables use tenant_id for RLS. Policy pattern (applied identically to all three tables):

ALTER TABLE ic_item ENABLE ROW LEVEL SECURITY;

CREATE POLICY ic_item_tenant_isolation ON ic_item
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

ALTER TABLE ic_item_group ENABLE ROW LEVEL SECURITY;

CREATE POLICY ic_item_group_tenant_isolation ON ic_item_group
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

ALTER TABLE ic_item_tag ENABLE ROW LEVEL SECURITY;

CREATE POLICY ic_item_tag_tenant_isolation ON ic_item_tag
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

System items and system groups have tenant_id = NULL or a reserved system tenant UUID — see provisioning design (outside scope of this doc).


Transaction Boundaries

Use Case Tables Written Transaction Scope Notes
UC-001: CreateItem ic_item, ic_item_tag Single transaction Item + tags written atomically
UC-002: EditItem ic_item, ic_item_tag Single transaction Tags: delete-all + re-insert within same transaction
UC-003: DeactivateItem ic_item Single transaction Status change only
UC-004: ReactivateItem ic_item Single transaction Status change only
UC-005: AssignItemToGroup ic_item Single transaction group_id update only
UC-006: RemoveItemFromGroup ic_item Single transaction group_id → NULL
UC-007: ImportItems ic_item, ic_item_tag Single transaction (all rows) All-or-nothing; all rows in one DB transaction
UC-008: CreateItemGroup ic_item_group Single transaction
UC-009: EditItemGroup ic_item_group Single transaction
UC-010: DeleteItemGroup ic_item_group Single transaction FK ON DELETE RESTRICT prevents accidental cascade

FK Relationships Diagram

ic_item_group
    id ─────────────────────────────┐
    tenant_id                       │ (group_id FK)
    name                            │
    item_type                       │
    is_system                       │
ic_item ────────────── ic_item_tag
    id ──────────────────────── item_id (FK, ON DELETE CASCADE)
    tenant_id                   tenant_id
    code                        tag
    name
    item_type
    storage_unit
    status
    group_id (FK → ic_item_group.id, NULLABLE)
    shelf_life_days
    ean

Alembic Migration Notes

  • Enable unaccent extension before creating the search_vector generated column.
  • The partial unique index on ean requires CREATE UNIQUE INDEX ... WHERE ean IS NOT NULL syntax (not expressible as a table constraint in some versions — use op.create_index in Alembic).
  • The lower(code) and lower(name) unique constraints require expression indexes, which Alembic creates via op.create_index('...', 'ic_item', [text('lower(code)')], unique=True).