Data model
The 19 entities and 3 join tables behind TALA, the dual user-or-organization ownership chain, every enum, and the four migrations that build them.
Schema is migration-driven. Four migrations exist, and TypeORM’s synchronize must be turned off explicitly on every shared database — it is not off by default outside production. See the warning below.
The ownership chain
User ─┬─> Collection ──< Asset ──< AssetVersion
│ └──< collection_assets >── Asset (many-to-many)
│
└─> Organization ──< OrganizationMember ──> User
├──< OrganizationInvitation
├──< Project ──< Task ──< TaskComment
│ └──< task_assignees >── OrganizationMember
│ └──< task_comment_assets >── Asset
├──< Collection (org-scoped)
└──< Asset (org-scoped, also project-linked)
An asset can reach a user three different ways — owner_user_id, created_by_user_id, and through its collection — and reach an organization two ways, directly or through its project. Each column is individually nullable, and which one is authoritative depends on the scope the caller asked for.
Ownership is exclusive
Nullable per column does not mean free-form. Both owner-bearing tables carry a CHECK constraint requiring exactly one of the owner pair:
| Table | Constraint | Rule |
|---|---|---|
assets |
CHK_assets_exactly_one_owner |
num_nonnulls("owner_user_id", "organization_id") = 1 |
collections |
CHK_collections_exactly_one_owner |
num_nonnulls("user_id", "organization_id") = 1 |
So an asset is personal or organizational, never both and never orphaned. created_by_user_id, collection_id and project_id are unconstrained and orthogonal — an org-owned asset still records who uploaded it.
Tables
| Table | Key | Notable columns | Soft delete |
|---|---|---|---|
users |
char(10) | role, account_status, provider, provider_id, image_url, plan_id; password is select: false |
no |
organizations |
char(10) | name, unique slug, owner_id |
yes |
organization_members |
char(10) | role, status; unique (org, user); index (org, status) |
no |
organization_invitations |
char(10) | email, role, unique token_hash, expires_at, accepted_at, revoked_at |
no |
collections |
char(10) | title, description, user_id + organization_id — exactly one non-null |
yes |
assets |
varchar | asset_type, asset_name, short + long URL, current_version, collection_id, created_by_user_id, project_id; owner_user_id + organization_id — exactly one non-null |
yes |
asset_versions |
varchar | version_number, s3_key, change_note; unique (asset, version_number) |
no |
asset_logs |
char(10) | message, activity_type; user FK is onDelete: RESTRICT |
yes |
asset_favorites |
char(14) | per-user favourite join | no |
collection_favorites |
char(14) | per-user favourite join | no |
projects |
char(12) | status, priority, start_date, due_date, position; index (org, status, position) |
yes |
tasks |
char(12) | status, priority, start_at, due_at, position; indexes (project, status, position) and (start_at, due_at) |
yes |
task_comments |
char(12) | body, author_user_id, attached assets |
yes |
activity_logs |
varchar | action, endpoint, status_code, resource_type, resource_id, ip_address, user_agent, jsonb metadata, duration_ms |
no |
refresh_tokens |
jti char(36) |
sha256 token_hash, expires_at, revoked_at |
no |
oauth_exchange_codes |
code_hash |
sha256 at rest, ~60s expiry, single use | no |
email_verification |
email |
token |
no |
password_reset |
email |
token char(64) |
no |
plans |
varchar | unique name, minimum_seat, maximum_seat |
no |
Join tables carry no entity of their own: collection_assets, task_assignees, task_comment_assets.
All timestamps use TypeORM’s @CreateDateColumn / @UpdateDateColumn / @DeleteDateColumn. Soft delete is deleted_at.
Enums
Every enum and its values
| Enum | Values | Defined in |
|---|---|---|
Role |
CONTENT_CREATOR, ADMIN |
auth/auth.types.ts |
AccountStatus |
VERIFIED, PENDING, SUSPENDED |
auth/auth.types.ts |
AuthProvider |
LOCAL, GOOGLE, GITHUB |
auth/auth.types.ts |
OrgRole |
OWNER, ADMIN, MEMBER |
organization/organization.types.ts |
MembershipStatus |
ACTIVE, INVITED, SUSPENDED |
organization/organization.types.ts |
WorkStatus |
QUEUED, IN_PROGRESS, COMPLETED |
project/project.types.ts |
WorkPriority |
LOW, MEDIUM, HIGH |
project/project.types.ts |
ActivityType |
created, updated, deleted, accessed, shared, version_added, version_restored |
assets/activity-type.ts |
LogAction |
namespaced strings — auth.login, collection.created, asset.uploaded, … |
activity-logs/log-action.types.ts |
WorkStatus and WorkPriority are shared by projects and tasks through named Postgres types (work_status_enum, work_priority_enum), so a value added to one applies to both.
Role is the platform role and gates /api/timeline and /api/seeder. OrgRole is the tenant role and gates membership mutations. They are unrelated — a platform ADMIN is not automatically an org OWNER.
Migrations
| Migration | What it does |
|---|---|
InitialSchema |
Base tables |
SeedPlans |
Inserts the plan rows — no /api/seeder call needed |
LockDownPublicSchema |
Enables RLS and revokes anon / authenticated grants and default privileges |
CoreMvpCollaboration |
Organizations, invitations, projects, tasks, comments, favourites, dual ownership |
npm run migration:generate -- src/migrations/MigrationName
npm run migration:run
npm run migration:revert
