Tags
Project88's global tagging system — color-coded tags that work across records, calendar events, conversations, agents, and contacts.
Tags are Project88's universal labeling system. A single tag can be applied to almost anything — a record, a calendar event, a conversation, an agent, a contact — via a polymorphic join table. The same tag picker appears everywhere.
The data model
Two tables (migration 036_tags.sql):
tags
| Column | Notes |
|---|---|
id | UUID |
org_id | The owning org |
workspace_id | Nullable — NULL means org-wide; set means scoped to ws |
name | Display name (case-insensitively unique within scope) |
color | Hex color for the pill |
display_order | Integer, used by the settings drag-reorder UI |
created_by | The user who created the tag |
tag_assignments
A polymorphic join row per assignment:
| Column | Notes |
|---|---|
tag_id | FK to tags |
entity_type | user_table_row, calendar_event, conversation, agent, person, … |
entity_id | UUID of the tagged entity |
assigned_by | The user who applied the tag |
assigned_at | Timestamp — drives display order (newest first) |
Unique on (tag_id, entity_type, entity_id).
Scope
A tag can be org-wide (visible in every workspace) or
workspace-scoped (visible only in its own workspace). The
workspace_id column distinguishes them:
workspace_id IS NULL→ org-wideworkspace_id = <ws>→ workspace-scoped
The settings UI shows a scope badge on each tag, and you can change scope when editing. Org admins / owners control org-scoped tags; any member can manage workspace-scoped tags.
Where tags show up
- Records — in the Record Detail Sheet header, always editable, via
<TagPicker entityType="user_table_row" />. - Calendar events — in the Event Detail Sheet, via the same picker
with
entityType="calendar_event". Event color in the calendar is derived from the most recently assigned tag. - Conversations and Agents — tag pickers in their detail views.
- People — the People table's
tagstext array overlaps with this system (see Data tables). - Filters & pipelines — the pipeline stage widget filters by tag; filter trees support tag conditions.
Two-way record ↔ event sync
When a record is linked to a calendar event (via calendar_event_links),
tags sync both ways automatically. Logic lives in
src/store/tags.jsx in resolveSyncTargets():
- Tagging a record fans the tag out to every linked calendar event.
- Tagging an event fans the tag out to (a) the linked record and (b) every other event linked to that record.
SYNCED_ENTITY_TYPES = { 'user_table_row', 'calendar_event' }. Other
entity types (conversation, agent, person) don't fan out — they're
self-contained.
One-way record → email inheritance
Emails are tagged independently, but the inbox physically copies a
linked record's tags onto an email the first time you open it. The copy
runs through the same inheritTagsBetweenEntities action the calendar
uses on event-record links — idempotent (skip-existing on the target),
so reopening only catches up new tags added to the source record.
Rows land in tag_assignments with entity_type = 'email_message' and
the same TagPicker used by the calendar lets you add or remove tags
directly on the email afterwards.
The copy is one-way: tagging an email does not fan out to the source record. The semantic owner of the tag is the record; the email gets a snapshot. See Inbox → Tag inheritance for the link rule that drives the sender → record match.
Settings: managing tags
⌘K → Settings → Tags opens TagsTab.jsx:
- Inline editor — name input, color swatch, scope select (Org / Workspace).
- Drag-and-drop reorder writes to
display_order. - Delete cascades to all
tag_assignments.
A useful pattern: define a small set of org-wide tags for cross-cutting concepts (priority, status, lifecycle stage), then add workspace-scoped tags for project- or client-specific work.
Newest-tag-wins for event color
For calendar event coloring the most recently assigned tag wins.
This is intentional — moving an event from "lead" to "customer" by
adding a customer tag should change the color even if the older lead
tag is still attached.
The mechanic is: tag_assignments.assigned_at is preserved on
re-assignment (not silently no-op'd), and the API returns assignments
ordered by assigned_at DESC. The calendar's useEventTagColor hook
reads the last item in the array, so the newest is what renders.