Project Management Tables¶
Projects (projects)¶
Purpose: Core project/production management
Use Case Example: Create a main project "Summer Blockbuster 2024" with sub-projects for "VFX Unit" and "Second Unit", each tracking their own budgets and teams.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| organisation_id | UUID | Reference to organisation that owns this project |
| entity_id | UUID | Reference to the entity that owns/operates this project (for accounting projects, this represents the business or individual) - References entities(id) |
| project_code | TEXT | Unique project code (auto-generated by trigger from title) |
| url_key | TEXT | Unique 6-character alphanumeric key used in URLs instead of UUID |
| parent_project_id | UUID | Reference to parent project (for sub-projects) |
| title | TEXT | Project title |
| created_by_user_id | UUID | User who created the project |
| updated_by_user_id | UUID | User who last updated |
| country_code | TEXT | ISO country code where the production company is headquartered (required, determines currency and tax scheme) - References countries(code) |
| currency_code | TEXT | Currency for financial transactions (auto-populated from country via trigger) - References currencies(code) |
| exchange_rate_mode | exchange_rate_mode | Project-wide foreign-currency conversion mode (live | stamped), NOT NULL DEFAULT stamped. live = always the currently-active project rate (corrections self-heal); stamped = the immutable rate version each transaction was processed under (transactions.exchange_rate_id), freezing already-processed figures. Read by resolve_transaction_exchange_rate; drives every converted amount in v_transactions / budget views. |
| fixed_exchange_rates_json | JSONB | Fixed exchange rates if applicable |
| tax_scheme_id | UUID | Tax scheme for the project (auto-populated from country via trigger) - References tax_schemes(id) |
| poster_attachment_id | UUID | Reference to the project poster image |
| logo_attachment_id | UUID | Reference to the project logo image |
| start_date | DATE | Date when the project started or will start |
| is_active | BOOLEAN | Whether the project is active or archived |
| is_tax_registered | BOOLEAN | Whether the production company or individual is registered for tax (GST/VAT). Default is false (not registered) |
| metadata | JSONB | Additional project metadata. Keys: emails (inbound-parsing addresses, e.g. {"emails": {"generic": "project.title_urlkey@docs.farmcove.co.uk"}}); transaction (per-transaction settings object — see note below) |
| current_budget_id | UUID | The budget currently being tracked (baseline budget). Set via UI when user selects active budget |
| current_schedule_id | UUID | The schedule currently being tracked (baseline schedule). Initially set on project creation, can be updated via UI |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Indexes:
idx_projects_country_codeoncountry_codefor efficient country-based lookupsidx_projects_entity_idonentity_idfor efficient entity-based lookups
metadata.transaction — per-transaction-type project settings map, keyed EXCLUSIVELY by the six direction-aware transaction type keys (Expense, Invoice_inbound, Invoice_outbound, Payroll Invoice, Reimbursement, Unknown — see getTransactionTypeKey in utils/transaction.ts). Each entry is { entities_created_as_sensitive: boolean, line_item_processing?: 'detailed' | 'summary' }. The form always persists the full six-key map (a missing key would read as "not sensitive"), replacing metadata.transaction wholesale while preserving root metadata siblings (e.g. emails). The shape invariant is pinned by packages/database/supabase/tests/13_project_metadata_transaction_shape.sql.
metadata.transaction.<key>.entities_created_as_sensitive (boolean, default off) — when on for a type, an entity and project relationship auto-created while processing a transaction of that type are marked sensitive (auto_applied = true), so their transactions stay hidden until a producer/accountant reviews them. Because the transaction's type is only known after AI type discovery, the mark lands at that step, not at record creation. See SENSITIVE_DATA_SYSTEM.md § Auto-applied marks.
metadata.transaction.<key>.line_item_processing ('detailed' | 'summary', default detailed) — how the processing pipeline shapes a source document's extracted lines onto a transaction of that type. detailed keeps every extracted line as its own item; summary consolidates them into one item per distinct tax treatment before the matching steps run. An absent map, an absent key and an absent value are all equivalent to detailed (resolveLineItemProcessingMode in utils/transaction.ts), so existing projects need no backfill. The value is read at step time by the transaction_line_item_summarisation pipeline step, which stamps the mode it applied on transactions.line_item_mode — changing the setting does not retroactively rewrite existing transactions (the targeted Transaction Regenerate Line Items template does that). See PROCESSING_ARCHITECTURE.md § Line-item processing mode.
Per-type processing fallbacks are NOT part of this map — they live in their own table, project_transaction_fallbacks below.
Project Transaction Fallbacks (project_transaction_fallbacks)¶
Purpose: Per-project, per-transaction-type fallback defaults the processing pipeline applies when a line item cannot be matched to a chart of account or a budget item.
Use Case Example: A project routes every unmatched expense line to the "6000 — General Expenses" account rather than leaving it unassigned, so the accountant reviews one issue instead of re-coding each line.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| project_id | UUID | Project the fallbacks apply to (FK → projects, CASCADE) |
| transaction_type | transaction_type | Transaction type the fallbacks apply to |
| direction | transaction_direction (nullable) | Invoice direction the fallbacks apply to. NOT NULL exactly when transaction_type is Invoice |
| coa_reference_id | UUID (nullable) | Chart-of-accounts reference used when a line cannot be matched (FK → integration_reference_data, ON DELETE SET NULL). NULL means no COA fallback configured |
| budget_item_id | UUID (nullable) | Budget item used when a line cannot be matched (FK → budget_items, ON DELETE SET NULL). NULL means no budget fallback configured |
| created_by_user_id | UUID (nullable) | User who created the row (DEFAULT auth.uid()) |
| updated_by_user_id | UUID (nullable) | User who last updated the row (DEFAULT auth.uid()) |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
"Enabled" is simply "the column is NOT NULL" — there is no separate flag. The COA fallback is only usable when a connected accounting integration has synced a chart of accounts; the budget fallback only when the project has an active budget (current_budget_id).
Constraints:
project_transaction_fallbacks_direction_matches_typeCHECK ((transaction_type = 'Invoice') = (direction IS NOT NULL)) — a fallback row is scoped to exactly one product-level transaction kind, andInvoiceis the only type that splits by direction. Without it a single kind would be addressable by two rows and the resolver's lookup would be ambiguous. A cross-column relational invariant, not a value domain (both columns are already enums).project_transaction_fallbacks_unique_typeUNIQUE NULLS NOT DISTINCT(project_id, transaction_type, direction)— theNULLS NOT DISTINCTclause is load-bearing: every non-Invoice row carriesdirection IS NULL, and under the defaultNULLS DISTINCTthose NULLs never collide, so a project could accumulate unlimited duplicate rows per non-Invoice type.
Tenancy is trigger-enforced, not FK-enforced. A plain FK proves the target row exists, not that it belongs to this project, so the project_transaction_fallbacks_assert_ownership BEFORE INSERT/UPDATE trigger walks each budget item (budget_items → budget_headers → budgets) and each COA reference (integration_reference_data → project_integrations) back to its owning project and rejects a mismatch. Without it a project could point its fallbacks at another tenant's budget item and the pipeline would allocate to it.
Why a table rather than JSONB. These settings previously lived in projects.metadata.transaction.<key>.fallbacks as bare UUIDs with no referential integrity: a deleted budget item or COA reference left a dangling id that the read path silently ignored. The ON DELETE SET NULL FKs mean a deleted fallback target now nulls the column out — the setting visibly disappears instead of appearing configured but doing nothing. The migration backfilled only values whose legacy enabled flag was true and whose target still existed and belonged to the project, reporting every dropped value, then stripped the fallbacks key from every metadata.transaction entry.
Row Level Security: SELECT is ordinary project membership (v_user_accessible_projects); INSERT/UPDATE/DELETE require project:edit. Writes go through the atomic save_project_transaction_settings RPC, which treats the supplied set as the complete desired state.
Applying a fallback does not suppress the issue. When one is applied, the unmatched-linking processing issue is still raised, carrying fallbackApplied/fallbackName in its context_data so the issue card shows a "default fallback applied" legend.
Organisations (organisations)¶
Purpose: Represents organisations that own projects. Each user gets a "Personal" organisation by default.
Use Case Example: When a user signs up, they automatically get a "Personal" organisation. Companies can create separate organisations for team collaboration.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| name | TEXT | Organisation name |
| organisation_code | TEXT | Unique code generated from organisation name for internal use |
| url_key | TEXT | Unique 6-character alphanumeric key used in URLs instead of UUID |
| logo_attachment_id | UUID | Reference to attachment for organisation logo |
| is_active | BOOLEAN | Whether the organisation is active or archived |
| created_by_user_id | UUID | User who created the organisation |
| updated_by_user_id | UUID | User who last updated the organisation |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
| is_default | BOOLEAN | Whether this is the default organisation for new users |
User Accesses (user_accesses)¶
Purpose: Unified permission system that grants roles or permissions to users at organisation or project scope.
Use Case Example: Grant Jane the "Line Producer" role for "Summer Blockbuster 2024" project, or grant Bob "organisation:edit" permission for the entire production company.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| user_id | UUID | Reference to user being granted access |
| grant_type | TEXT | Type of grant: 'role' or 'permission' |
| grant_id | UUID | ID of permission_role or permission being granted |
| scope_type | TEXT | Scope of grant: 'organisation' or 'project' |
| scope_id | UUID | ID of organisation or project |
| project_relationship_id | UUID | Reference to project_relationship (for project scope) |
| status | TEXT | Status: 'Active', 'Invited', or 'Revoked' |
| granted_by_user_id | UUID | User who granted this access |
| granted_at | TIMESTAMPTZ | When this access was last granted (initial or re-grant after revoke) |
| revoked_by_user_id | UUID | User who performed the most recent revoke (nullable) |
| revoked_at | TIMESTAMPTZ | Timestamp of the most recent revoke (preserved across re-grants) |
| expires_at | TIMESTAMPTZ | Optional expiration timestamp |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Unique constraint on (user_id, grant_type, grant_id, scope_type, scope_id, project_relationship_id)
- Supports both role-based and direct permission grants
- Can grant access at organisation or project level
- Links to project_relationship for project-level grants
granted_at+granted_by_user_idcapture the latest grant event;revoked_at+revoked_by_user_idcapture the latest revoke event. Comparegranted_atvsrevoked_atto determine current state history (a futureaudit_eventstable will hold the full log)
Indexes:
idx_user_accesses_user_scopeon(user_id, scope_type, scope_id)— composite index backing the permission-resolution primitives (caller_accessible_project_ids/caller_has_permissionviav_user_permissions) and the sensitive-data clearance functions (get_user_clearance/get_user_clearance_for_user), the clearance functions andcaller_has_permissionprobe the full(user_id, scope_type, scope_id)triple;caller_accessible_project_idsseeks on the leadinguser_idand reads the scope columns from the index.
Note on revoked_at / role is_active: the permission-resolution path (v_user_permissions) filters on status but checks neither revoked_at nor the role's is_active (it never joins permission_roles), whereas the clearance functions require revoked_at IS NULL and permission_roles.is_active = true. A grant left status = 'Active' with a non-null revoked_at, or one referencing a deactivated role, still confers permissions but not clearance — known divergences flagged for a follow-up ticket (see PERMISSIONS_SYSTEM.md § Access primitives & semantic contracts).
Role Definitions (role_definitions)¶
Purpose: Defines standard production industry roles with department classification and role characteristics
Use Case Example: Create roles like "Director", "Producer", "Cinematographer" that can be assigned to entities on projects.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| title | TEXT | Role title (e.g., Director) |
| description | TEXT | Detailed description of the role and its responsibilities |
| department_id | UUID | Reference to departments table (nullable) |
| is_hod | BOOLEAN | Whether this is a Head of Department role |
| is_chain_of_title | BOOLEAN | Whether this role affects chain of title |
| is_on_screen | BOOLEAN | Whether this is an on-screen role (e.g., Cast) |
| is_not_common | BOOLEAN | Whether this role is uncommon in standard productions |
| can_be_vendor | BOOLEAN | Whether this role can be assigned to Organisation entities |
| can_be_person | BOOLEAN | Whether this role can be assigned to Person entities |
| is_active | BOOLEAN | Whether role is currently used |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Department classification links roles to production departments (Above/Below the Line)
- Boolean flags identify special role characteristics (HOD, chain of title, on-screen, uncommon)
- Project types array indicates which project types the role applies to
- Supports both individual persons and vendor organisations
- Includes 305 standard production roles and 64 business service roles
Project Relationships (project_relationships)¶
Purpose: Links entities to projects with specific production roles
Use Case Example:
- Add Steven Spielberg as "Director" (Person entity)
- Add a production company as "Producer" (Organisation entity)
- Add catering service as "Supplier" (Organisation entity)
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| project_id | UUID | Reference to project |
| entity_id | UUID | Reference to entity (required) |
| role_definition_id | UUID | Standard role from role_definitions table |
| role_title_override | TEXT | Custom role title if different |
| is_active | BOOLEAN | Record lifecycle on the project. true = currently participating, false = deactivated. NOT NULL, default true |
| access_status | ENUM | App access lifecycle, using the shared access_status enum (Active/Invited/Revoked). NULL means no invite has been sent (e.g. crew member added without granting app access) |
| expense_reimbursement_code | TEXT | Code used by production to match submitted receipts/expenses to this entity (nullable) |
| deal_terms | TEXT | Free-text agreed engagement terms (rates, conditions, other negotiated terms) for this relationship. Plain, non-sensitive, unmasked — visible to anyone who can view the relationship. Nullable; empty/whitespace-only input is normalized to NULL by the app |
| metadata | JSONB | Relationship-specific data (contains identity_document object with document details captured at acceptance time). Default: '{}'::JSONB |
| invited_by_user_id | UUID | User who sent the invitation to join the project |
| invited_at | TIMESTAMPTZ | Timestamp when the invitation was sent |
| accepted_at | TIMESTAMPTZ | Timestamp when the invitation was accepted. Immutable once set |
| cancelled_by_user_id | UUID | User who cancelled a pending invitation (never-accepted path). Distinct from revoke |
| cancelled_at | TIMESTAMPTZ | Timestamp when a pending invitation was cancelled |
| revoked_by_user_id | UUID | User who revoked access on an accepted relationship. Cleared when access is granted again |
| revoked_at | TIMESTAMPTZ | Timestamp of the most recent revoke on an accepted relationship. Cleared when access is granted again |
| invite_failed_at | TIMESTAMPTZ | Set when the Sana identity-onboarding flow crashed; presence bypasses the 1-hour resend throttle. Cleared on successful resend |
| employment_type | ENUM | How this crew member is engaged. public.employment_type: employee, self_employed, loan_out, other. Nullable — set after identity/starter-form flow |
| loan_out_via_project_relationship_id | UUID | For loan_out employment type: the Business entity relationship on this same project that invoices on behalf of this person. Must reference a Business entity in the same project. Enforced by trg_validate_loan_out_relationship BEFORE INSERT/UPDATE trigger |
| payment_details | JSONB | Snapshot of the entity payment details at the time the relationship was finalised (same shape as entities.payment_details). Backfilled from the legacy metadata.bank blob and now stored as a first-class column with its own GIN index (idx_project_relationships_payment_details). Raw reads are revoked for authenticated — this column is EXCLUDED from the authenticated SELECT column grant, so the ONLY authenticated read path is the masking view v_project_relationships (via the DEFINER helper private.project_relationship_masked_fields), which enforces the field mask and subject self-visibility. INSERT/UPDATE/DELETE are unchanged, so it stays WRITABLE via the base table. See SENSITIVE_DATA_SYSTEM.md |
| requires_identity_document | BOOLEAN | Whether the project requires an identity document from this crew member. Default false |
| identity_document_waived_by_user_id | UUID | User who waived the identity document requirement (nullable) |
| identity_document_waived_at | TIMESTAMPTZ | When the identity document requirement was waived (nullable) |
| created_by_user_id | UUID | User who created the relationship |
| updated_by_user_id | UUID | User who last updated the relationship |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- entity_id references the entities table (can be Person or Organisation type)
- Supports all types of project participants through unified entity model
- Role definitions determine applicable entity types
- metadata.identity_document stores a snapshot of identity details at the time of acceptance (entity always has latest data, but each relationship preserves the state when accepted). The actual file linkage now lives in
project_relationship_attachments(withattachment_type = 'identity_document'); the legacyidentity_attachment_idcolumn was dropped in migration20260403000000and existing rows were backfilled cancelled_*is reserved for cancelling a pending invitation (never accepted);revoked_*is reserved for revoking access on an accepted relationship. The two paths are distinct so the UI and analytics can tell them apart- invite_failed_at is stamped by Sana form-handler catch blocks when the identity-onboarding flow crashes. The inviter's "Resend Invite" button reads this column to bypass the 1-hour throttle; a successful resend clears it back to NULL
- The relationship is modelled on two independent axes:
is_active— record lifecycle on the project:true(currently participating) vsfalse(deactivated)access_status— app-access lifecycle:Invited(sent, awaiting acceptance) →Active(accepted, access granted) →Revoked(access revoked after acceptance, or invite cancelled). NULL means no invite was ever sent