Tickets
What is a ticket in EdControls?
A ticket in EdControls is a single unit of work that needs attention on a project — an issue, a snag, a punch-list item, a deficiency, or any other actionable observation. Tickets are the operational currency of the platform: they are the things that get created, assigned, worked on, completed, and reported against.
A ticket carries a title and body, an author, a person responsible
for resolving it, a status, a due date, and (optionally) a position
on a project map (x / y pixel coordinates and / or a latitude /
longitude geo coordinate). Tickets can also carry tags, progress
labels (a finer-grained sub-status), and custom fields — an
EdControls feature that lets each project define its own structured
metadata on top of the built-in ticket schema.
A ticket lives on a single project. Many tickets are created
inside an audit as a follow-up to a question's answer; that
linkage is captured in the audit_answers_tickets
bridge (documented under the Audits domain) and exposed here as the
"tickets created from audit findings" cross-table query.
Domain ER diagram
The audit_answers_tickets bridge is documented in the
Audits domain; it links a ticket back
to the audit answer that created it (when applicable). This subgraph
just shows how tickets connect outward.
Tables in this domain
tickets— one row per ticket.tickets_roles— RACI assignments per ticket (responsible,consulted,informed).tickets_tags— free-text tags applied to a ticket.tickets_progress_labels— sub-status labels for finer workflow tracking.tickets_custom_fields— per-ticket custom-field values.tickets_custom_field_options— selectable options for choice-type custom fields.tickets_custom_field_selections— which option(s) a ticket selected for a choice field.
Status and progress labels
The built-in tickets.status column has three closed values:
created— the ticket exists but has no responsible person.started— a responsible person is assigned (tickets.responsibleis non-empty) and work is in progress.completed— the ticket has been finished.dateCompletedis set.
Beyond status, projects can use progress labels to track
finer-grained workflow states ("waiting on parts", "ready for QA",
etc.) without changing the closed status enum. Progress labels are
free-text strings stored one row per (ticket, label) in
tickets_progress_labels.
A ticket can carry zero, one, or multiple labels.
Custom fields
EdControls projects can extend the ticket schema with custom
fields, defined per project. Each custom field has a field_type
drawn from a closed set:
freetext— a free-text string.numeric— a number, stored as a string.date— an ISO date string.singleSelectMultipleChoice— pick one of N predefined options.multiSelectMultipleChoice— pick zero or more of N options.
Three tables back this:
tickets_custom_fields— one row per (ticket, field). For simple types,field_valuecarries the value directly. For choice types,field_valuecarries a JSON-stringified array of selected option IDs.tickets_custom_field_options— for choice fields only, the option catalog as it existed for that ticket (text, type, optional image).tickets_custom_field_selections— for choice fields only, one row per (ticket, field, selected option) — the normalized form of the same selection thatfield_valuecarries as a JSON array.
The three custom-field tables are auto-created on older datasets via
the backfill path in backend/services/sql.py
(migrate_tickets_custom_fields_tables). If you encounter a dataset
where the custom-field tables exist but are empty, the dataset has
been migrated but no ticket has yet been re-synced with custom-field
data. See the leaf pages for details.
Lifecycle
- A
ticketsrow is upserted when a ticket document is observed in source data: the converter doesDELETE FROM tickets WHERE id = <id>followed by anINSERT. - A ticket can be archived (
tickets.archivedis set to aDATETIME). Archived tickets remain in the table; downstream joins (e.g. fromaudit_answers_tickets) continue to resolve. - All five satellite tables (
tickets_roles,tickets_tags,tickets_progress_labels,tickets_custom_fields,tickets_custom_field_options,tickets_custom_field_selections) are rebuilt per ticket document: on each observation of a ticket, every existing satellite row for that ticket is deleted and the current set is re-inserted. Reading any satellite during an in-flight reindex of one ticket may briefly return zero rows for that ticket. - A reindex with
type=ticket(or a full reindex) drops and rebuilds all seven tables in this domain. There is no per-satellite reindex target.
Cross-table queries
The following queries combine tickets with its satellites or with
neighbouring domains. Per-table queries live on each leaf page.
Ticket burndown by status
SELECT
status,
COUNT(*) AS tickets,
SUM(CASE WHEN archived IS NULL THEN 1 ELSE 0 END) AS active,
AVG(created_completed) / 86400 AS avg_days_to_complete
FROM tickets
WHERE projectid = 'your_project_id'
GROUP BY status
ORDER BY tickets DESC;
Tickets with their tags and progress labels
SELECT
t.id,
t.title,
t.status,
GROUP_CONCAT(DISTINCT tt.tag SEPARATOR ', ') AS tags,
GROUP_CONCAT(DISTINCT tpl.progress_label SEPARATOR ', ') AS progress_labels
FROM tickets t
LEFT JOIN tickets_tags tt ON tt.ticketid = t.id
LEFT JOIN tickets_progress_labels tpl ON tpl.ticketid = t.id
WHERE t.projectid = 'your_project_id'
AND t.archived IS NULL
GROUP BY t.id, t.title, t.status
ORDER BY t.lastmodifieddate DESC;
Tickets with custom-field values
SELECT
t.id AS ticket_id,
t.title,
cf.field_label,
cf.field_type,
cf.field_value
FROM tickets t
JOIN tickets_custom_fields cf ON cf.ticketid = t.id
WHERE t.projectid = 'your_project_id'
AND t.archived IS NULL
ORDER BY t.id, cf.field_label;
Tickets with their selected choice options resolved to text
SELECT
t.id AS ticket_id,
t.title,
cf.field_label,
GROUP_CONCAT(co.option_text SEPARATOR ', ') AS selected_text
FROM tickets t
JOIN tickets_custom_fields cf ON cf.ticketid = t.id
JOIN tickets_custom_field_selections cs ON cs.ticketid = t.id AND cs.field_id = cf.field_id
JOIN tickets_custom_field_options co ON co.ticketid = t.id
AND co.field_id = cf.field_id
AND co.option_id = cs.selected_option_id
WHERE cf.field_type IN ('singleSelectMultipleChoice', 'multiSelectMultipleChoice')
AND t.projectid = 'your_project_id'
GROUP BY t.id, t.title, cf.field_label;
Tickets by progress label
SELECT
tpl.progress_label,
COUNT(DISTINCT t.id) AS tickets
FROM tickets t
JOIN tickets_progress_labels tpl ON tpl.ticketid = t.id
WHERE t.projectid = 'your_project_id'
AND t.archived IS NULL
GROUP BY tpl.progress_label
ORDER BY tickets DESC;
Tickets created from audit findings
audit_answers_tickets (documented in the
Audits domain) links a ticket back to
the audit answer that produced it.
SELECT
a.id AS audit_id,
a.name AS audit_name,
t.id AS ticket_id,
t.title AS ticket_title,
t.status
FROM audit_answers_tickets aat
JOIN audits a ON a.id = aat.audit
JOIN tickets t ON t.id = aat.ticket
WHERE t.projectid = 'your_project_id'
ORDER BY a.creationdate DESC, t.creationdate DESC;
User workload across active tickets
SELECT
t.responsible AS owner,
COUNT(*) AS assigned_tickets,
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) AS completed,
AVG(CASE WHEN t.status = 'completed' THEN t.created_completed END) / 86400
AS avg_days_to_complete
FROM tickets t
WHERE t.projectid = 'your_project_id'
AND t.archived IS NULL
AND t.responsible IS NOT NULL
GROUP BY t.responsible
ORDER BY assigned_tickets DESC;