-
+
{#if form.http_auth_enabled}
-
Credential
+
{t('settings.adminOperations.actionCapabilities.credential')}
{#if form.applies_to_all_workspaces}
- Global capabilities can only reference global credentials.
+ {t('settings.adminOperations.actionCapabilities.globalCredentialHelp')}
{:else}
- Workspace-scoped capabilities can reference globals or credentials in their workspace allowlist.
+ {t('settings.adminOperations.actionCapabilities.workspaceCredentialHelp')}
{/if}
diff --git a/internal/database/database.go b/internal/database/database.go
index a011776e1..96d1b7629 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -302,8 +302,8 @@ func (db *DB) initializeDefaultData() error {
for _, cat := range defaultStatusCategories {
var result sql.Result
result, err = tx.Exec(
- "INSERT INTO status_categories (name, color, description, is_default, is_completed) VALUES (?, ?, ?, ?, ?)",
- cat.name, cat.color, cat.description, cat.isDefault, cat.isCompleted,
+ "INSERT INTO status_categories (builtin_key, name, color, description, is_default, is_completed) VALUES (?, ?, ?, ?, ?, ?)",
+ cat.builtinKey, cat.name, cat.color, cat.description, cat.isDefault, cat.isCompleted,
)
if err != nil {
return fmt.Errorf("failed to create status category %s: %w", cat.name, err)
@@ -318,8 +318,8 @@ func (db *DB) initializeDefaultData() error {
categoryID := categoryIDs[status.category]
var result sql.Result
result, err = tx.Exec(
- "INSERT INTO statuses (name, description, category_id, is_default) VALUES (?, ?, ?, ?)",
- status.name, status.description, categoryID, status.isDefault,
+ "INSERT INTO statuses (builtin_key, name, description, category_id, is_default) VALUES (?, ?, ?, ?, ?)",
+ status.builtinKey, status.name, status.description, categoryID, status.isDefault,
)
if err != nil {
return fmt.Errorf("failed to create status %s: %w", status.name, err)
@@ -330,8 +330,8 @@ func (db *DB) initializeDefaultData() error {
// 3. Create default workflow
result, err := tx.Exec(
- "INSERT INTO workflows (name, description, is_default) VALUES (?, ?, ?)",
- "Default Workflow", "Basic workflow for getting work done", true,
+ "INSERT INTO workflows (builtin_key, name, description, is_default) VALUES (?, ?, ?, ?)",
+ "default", "Default Workflow", "Basic workflow for getting work done", true,
)
if err != nil {
return fmt.Errorf("failed to create default workflow: %w", err)
@@ -358,8 +358,8 @@ func (db *DB) initializeDefaultData() error {
// 5. Create default screen with basic fields
result, err = tx.Exec(
- "INSERT INTO screens (name, description) VALUES (?, ?)",
- "Default Screen", "Default screen with essential work item fields",
+ "INSERT INTO screens (builtin_key, name, description) VALUES (?, ?, ?)",
+ "default", "Default Screen", "Default screen with essential work item fields",
)
if err != nil {
return fmt.Errorf("failed to create default screen: %w", err)
@@ -379,8 +379,8 @@ func (db *DB) initializeDefaultData() error {
// 7. Create default configuration set
configResult, err := tx.Exec(
- "INSERT INTO configuration_sets (name, description, workflow_id, is_default) VALUES (?, ?, ?, ?)",
- "Default Configuration", "Default configuration set with basic workflow and screen", workflowID, true,
+ "INSERT INTO configuration_sets (builtin_key, name, description, workflow_id, is_default) VALUES (?, ?, ?, ?, ?)",
+ "default", "Default Configuration", "Default configuration set with basic workflow and screen", workflowID, true,
)
if err != nil {
return fmt.Errorf("failed to create default configuration set: %w", err)
@@ -401,8 +401,8 @@ func (db *DB) initializeDefaultData() error {
// 9. Create default link types
for _, linkType := range defaultLinkTypes {
_, err = tx.Exec(
- "INSERT INTO link_types (name, description, forward_label, reverse_label, color, is_system, allowed_entity_types) VALUES (?, ?, ?, ?, ?, ?, ?)",
- linkType.name, linkType.description, linkType.forwardLabel, linkType.reverseLabel, linkType.color, linkType.isSystem, linkType.allowedEntityTypes,
+ "INSERT INTO link_types (builtin_key, name, description, forward_label, reverse_label, color, is_system, allowed_entity_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ linkType.builtinKey, linkType.name, linkType.description, linkType.forwardLabel, linkType.reverseLabel, linkType.color, linkType.isSystem, linkType.allowedEntityTypes,
)
if err != nil {
return fmt.Errorf("failed to create link type %s: %w", linkType.name, err)
@@ -423,8 +423,8 @@ func (db *DB) initializeDefaultData() error {
// 9. Create default hierarchy levels
for _, hl := range defaultHierarchyLevels {
_, err = tx.Exec(
- "INSERT INTO hierarchy_levels (level, name, description) VALUES (?, ?, ?)",
- hl.level, hl.name, hl.description,
+ "INSERT INTO hierarchy_levels (builtin_key, level, name, description) VALUES (?, ?, ?, ?)",
+ hl.builtinKey, hl.level, hl.name, hl.description,
)
if err != nil {
return fmt.Errorf("failed to create hierarchy level %s: %w", hl.name, err)
@@ -434,8 +434,8 @@ func (db *DB) initializeDefaultData() error {
// 10. Create default item types with icons and colors
for _, itemType := range defaultItemTypes {
_, err = tx.Exec(
- "INSERT INTO item_types (configuration_set_id, name, description, icon, color, hierarchy_level, sort_order, is_default) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
- configSetID, itemType.name, itemType.description, itemType.icon, itemType.color, itemType.hierarchyLevel, itemType.sortOrder, true,
+ "INSERT INTO item_types (builtin_key, configuration_set_id, name, description, icon, color, hierarchy_level, sort_order, is_default) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ itemType.builtinKey, configSetID, itemType.name, itemType.description, itemType.icon, itemType.color, itemType.hierarchyLevel, itemType.sortOrder, true,
)
if err != nil {
return fmt.Errorf("failed to create default item type %s: %w", itemType.name, err)
@@ -470,8 +470,8 @@ func (db *DB) initializeDefaultData() error {
// 12. Create default themes with dual light/dark nav colors
for _, theme := range defaultThemes {
_, err = tx.Exec(
- "INSERT INTO themes (name, description, is_default, is_active, nav_background_color_light, nav_text_color_light, nav_background_color_dark, nav_text_color_dark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
- theme.name, theme.description, theme.isDefault, theme.isActive, theme.navBackgroundColorLight, theme.navTextColorLight, theme.navBackgroundColorDark, theme.navTextColorDark,
+ "INSERT INTO themes (builtin_key, name, description, is_default, is_active, nav_background_color_light, nav_text_color_light, nav_background_color_dark, nav_text_color_dark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ theme.builtinKey, theme.name, theme.description, theme.isDefault, theme.isActive, theme.navBackgroundColorLight, theme.navTextColorLight, theme.navBackgroundColorDark, theme.navTextColorDark,
)
if err != nil {
return fmt.Errorf("failed to create theme %s: %w", theme.name, err)
@@ -521,8 +521,8 @@ func (db *DB) initializeDefaultData() error {
// the server bootstrap after Initialize completes — keeps the database
// layer free of email-domain imports.)
notificationSettingResult, err := tx.Exec(
- "INSERT INTO notification_settings (name, description, is_active, created_by) VALUES (?, ?, ?, ?)",
- "Default Notifications", "Standard notification rules for work item updates", true, nil,
+ "INSERT INTO notification_settings (builtin_key, name, description, is_active, created_by) VALUES (?, ?, ?, ?, ?)",
+ "default", "Default Notifications", "Standard notification rules for work item updates", true, nil,
)
if err != nil {
return fmt.Errorf("failed to create default notification setting: %w", err)
diff --git a/internal/database/default_data_definitions.go b/internal/database/default_data_definitions.go
index 556be8e1a..002592840 100644
--- a/internal/database/default_data_definitions.go
+++ b/internal/database/default_data_definitions.go
@@ -1,6 +1,7 @@
package database
type defaultStatusCategory struct {
+ builtinKey string
name string
color string
description string
@@ -9,12 +10,13 @@ type defaultStatusCategory struct {
}
var defaultStatusCategories = []defaultStatusCategory{
- {"To Do", "#d1d5db", "Work that hasn't been started", false, false},
- {"In Progress", "#3b82f6", "Work that is actively being done", true, false},
- {"Done", "#22c55e", "Work that has been completed", false, true},
+ {"to_do", "To Do", "#d1d5db", "Work that hasn't been started", false, false},
+ {"in_progress", "In Progress", "#3b82f6", "Work that is actively being done", true, false},
+ {"done", "Done", "#22c55e", "Work that has been completed", false, true},
}
type defaultStatus struct {
+ builtinKey string
name string
description string
category string
@@ -22,9 +24,9 @@ type defaultStatus struct {
}
var defaultStatuses = []defaultStatus{
- {"Open", "New work item, not yet started", "To Do", true},
- {"In Progress", "Currently being worked on", "In Progress", false},
- {"Done", "Work has been completed", "Done", false},
+ {"open", "Open", "New work item, not yet started", "To Do", true},
+ {"in_progress", "In Progress", "Currently being worked on", "In Progress", false},
+ {"done", "Done", "Work has been completed", "Done", false},
}
type defaultTransition struct {
@@ -64,6 +66,7 @@ var defaultScreenFields = []defaultScreenField{
var defaultScreenContexts = []string{"create", "edit", "view"}
type defaultLinkType struct {
+ builtinKey string
name string
description string
forwardLabel string
@@ -74,14 +77,14 @@ type defaultLinkType struct {
}
var defaultLinkTypes = []defaultLinkType{
- {"Tests", "Test case tests work item", "tests", "tested by", "#10b981", true, strPtr(`["item","test_case"]`)},
- {"Implements", "Work item implements another work item", "implements", "implemented by", "#3b82f6", true, nil},
- {"Depends On", "Work item depends on another work item", "depends on", "blocks", "#f59e0b", true, nil},
- {"Relates To", "General bidirectional relationship", "relates to", "relates to", "#6b7280", true, nil},
- {"Links To", "General directional link", "links to", "linked from", "#64748b", true, nil},
- {"Duplicates", "Work item is a duplicate of another", "duplicates", "duplicated by", "#ef4444", true, nil},
- {"Child Of", "Alternative hierarchy relationship", "child of", "parent of", "#8b5cf6", true, nil},
- {"Page", "Work item references a knowledge page", "references page", "referenced by", "#0ea5e9", true, strPtr(`["item","page"]`)},
+ {"tests", "Tests", "Test case tests work item", "tests", "tested by", "#10b981", true, strPtr(`["item","test_case"]`)},
+ {"implements", "Implements", "Work item implements another work item", "implements", "implemented by", "#3b82f6", true, nil},
+ {"depends_on", "Depends On", "Work item depends on another work item", "depends on", "blocks", "#f59e0b", true, nil},
+ {"relates_to", "Relates To", "General bidirectional relationship", "relates to", "relates to", "#6b7280", true, nil},
+ {"links_to", "Links To", "General directional link", "links to", "linked from", "#64748b", true, nil},
+ {"duplicates", "Duplicates", "Work item is a duplicate of another", "duplicates", "duplicated by", "#ef4444", true, nil},
+ {"child_of", "Child Of", "Alternative hierarchy relationship", "child of", "parent of", "#8b5cf6", true, nil},
+ {"page", "Page", "Work item references a knowledge page", "references page", "referenced by", "#0ea5e9", true, strPtr(`["item","page"]`)},
}
type defaultSystemSetting struct {
@@ -107,20 +110,22 @@ var defaultSystemSettings = []defaultSystemSetting{
}
type defaultHierarchyLevel struct {
+ builtinKey string
level int
name string
description string
}
var defaultHierarchyLevels = []defaultHierarchyLevel{
- {0, "Initiative", "High-level strategic work spanning multiple epics"},
- {1, "Epic", "Large work item that can be broken down into stories"},
- {2, "Story", "User story or feature that delivers value"},
- {3, "Task", "Individual work item or technical task"},
- {4, "Activity", "Discrete activity within a task"},
+ {"initiative", 0, "Initiative", "High-level strategic work spanning multiple epics"},
+ {"epic", 1, "Epic", "Large work item that can be broken down into stories"},
+ {"story", 2, "Story", "User story or feature that delivers value"},
+ {"task", 3, "Task", "Individual work item or technical task"},
+ {"activity", 4, "Activity", "Discrete activity within a task"},
}
type defaultItemType struct {
+ builtinKey string
name string
description string
icon string
@@ -130,12 +135,12 @@ type defaultItemType struct {
}
var defaultItemTypes = []defaultItemType{
- {"Initiative", "Strategic initiative spanning multiple teams", "Target", "#7c3aed", 0, 1},
- {"Epic", "Large feature or capability", "Zap", "#2563eb", 1, 1},
- {"Story", "User story delivering value to end users", "BookOpen", "#059669", 2, 1},
- {"Task", "Development or operational task", "CheckSquare", "#dc2626", 3, 1},
- {"Bug", "Software defect that needs fixing", "Bug", "#ea580c", 3, 2},
- {"Sub-task", "Small work item below any regular hierarchy level", "Minus", "#6b7280", -1, 1},
+ {"initiative", "Initiative", "Strategic initiative spanning multiple teams", "Target", "#7c3aed", 0, 1},
+ {"epic", "Epic", "Large feature or capability", "Zap", "#2563eb", 1, 1},
+ {"story", "Story", "User story delivering value to end users", "BookOpen", "#059669", 2, 1},
+ {"task", "Task", "Development or operational task", "CheckSquare", "#dc2626", 3, 1},
+ {"bug", "Bug", "Software defect that needs fixing", "Bug", "#ea580c", 3, 2},
+ {"subtask", "Sub-task", "Small work item below any regular hierarchy level", "Minus", "#6b7280", -1, 1},
}
var defaultItemTypeBindings = []string{"Epic", "Story", "Task", "Bug", "Sub-task"}
@@ -151,6 +156,7 @@ const defaultNotificationChannelConfig = `{
}`
type defaultTheme struct {
+ builtinKey string
name string
description string
isDefault bool
@@ -162,9 +168,9 @@ type defaultTheme struct {
}
var defaultThemes = []defaultTheme{
- {"Default", "Clean theme with standard navigation colors", true, true, "#ffffff", "#374151", "#1f2937", "#f3f4f6"},
- {"Ocean", "Professional blue-tinted navigation theme", false, false, "#f0f9ff", "#0c4a6e", "#0c4a6e", "#e0f2fe"},
- {"Forest", "Nature-inspired green navigation theme", false, false, "#f0fdf4", "#14532d", "#14532d", "#dcfce7"},
+ {"default", "Default", "Clean theme with standard navigation colors", true, true, "#ffffff", "#374151", "#1f2937", "#f3f4f6"},
+ {"ocean", "Ocean", "Professional blue-tinted navigation theme", false, false, "#f0f9ff", "#0c4a6e", "#0c4a6e", "#e0f2fe"},
+ {"forest", "Forest", "Nature-inspired green navigation theme", false, false, "#f0fdf4", "#14532d", "#14532d", "#dcfce7"},
}
type defaultNotificationEventRule struct {
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 43a61b51f..4da63bf29 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -314,6 +314,170 @@ var Catalog = []Migration{
ON scm_connection_health(consecutive_failures, last_failure_at);
`,
},
+ {
+ Version: "20260829_builtin_translation_keys",
+ Name: "Add immutable keys for localized built-in records",
+ CheckSQLite: `SELECT CASE WHEN
+ (SELECT COUNT(*) FROM pragma_table_info('configuration_sets') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('workflows') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('screens') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('notification_settings') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('item_types') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('hierarchy_levels') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('priorities') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('status_categories') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('statuses') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('workspace_roles') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('link_types') WHERE name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM pragma_table_info('themes') WHERE name='builtin_key') = 1
+ THEN 1 ELSE 0 END`,
+ CheckPostgres: `SELECT CASE WHEN
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='configuration_sets' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='workflows' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='screens' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='notification_settings' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='item_types' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='hierarchy_levels' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='priorities' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='status_categories' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='statuses' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='workspace_roles' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='link_types' AND column_name='builtin_key') = 1 AND
+ (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='themes' AND column_name='builtin_key') = 1
+ THEN 1 ELSE 0 END`,
+ SQLite: `
+ ALTER TABLE configuration_sets ADD COLUMN builtin_key TEXT;
+ ALTER TABLE workflows ADD COLUMN builtin_key TEXT;
+ ALTER TABLE screens ADD COLUMN builtin_key TEXT;
+ ALTER TABLE notification_settings ADD COLUMN builtin_key TEXT;
+ ALTER TABLE item_types ADD COLUMN builtin_key TEXT;
+ ALTER TABLE hierarchy_levels ADD COLUMN builtin_key TEXT;
+ ALTER TABLE priorities ADD COLUMN builtin_key TEXT;
+ ALTER TABLE status_categories ADD COLUMN builtin_key TEXT;
+ ALTER TABLE statuses ADD COLUMN builtin_key TEXT;
+ ALTER TABLE workspace_roles ADD COLUMN builtin_key TEXT;
+ ALTER TABLE link_types ADD COLUMN builtin_key TEXT;
+ ALTER TABLE themes ADD COLUMN builtin_key TEXT;
+
+ UPDATE configuration_sets SET builtin_key='default' WHERE name='Default Configuration' AND is_default=true;
+ UPDATE workflows SET builtin_key='default' WHERE name='Default Workflow' AND is_default=true;
+ UPDATE screens SET builtin_key='default' WHERE name='Default Screen';
+ UPDATE notification_settings SET builtin_key='default' WHERE name='Default Notifications';
+ UPDATE item_types SET builtin_key=CASE name
+ WHEN 'Initiative' THEN 'initiative' WHEN 'Epic' THEN 'epic' WHEN 'Story' THEN 'story'
+ WHEN 'Task' THEN 'task' WHEN 'Bug' THEN 'bug' WHEN 'Sub-task' THEN 'subtask' END
+ WHERE is_default=true AND name IN ('Initiative','Epic','Story','Task','Bug','Sub-task');
+ UPDATE hierarchy_levels SET builtin_key=CASE level
+ WHEN 0 THEN 'initiative' WHEN 1 THEN 'epic' WHEN 2 THEN 'story'
+ WHEN 3 THEN 'task' WHEN 4 THEN 'activity' END WHERE level BETWEEN 0 AND 4;
+ UPDATE priorities SET builtin_key=LOWER(name) WHERE name IN ('Critical','High','Medium','Low');
+ UPDATE status_categories SET builtin_key=CASE name
+ WHEN 'To Do' THEN 'to_do' WHEN 'In Progress' THEN 'in_progress' WHEN 'Done' THEN 'done' END
+ WHERE name IN ('To Do','In Progress','Done');
+ UPDATE statuses SET builtin_key=CASE name
+ WHEN 'Open' THEN 'open' WHEN 'In Progress' THEN 'in_progress' WHEN 'Done' THEN 'done' END
+ WHERE name IN ('Open','In Progress','Done');
+ UPDATE workspace_roles SET builtin_key=LOWER(name)
+ WHERE is_system=true AND name IN ('Viewer','Editor','Administrator','Tester');
+ UPDATE link_types SET builtin_key=CASE name
+ WHEN 'Tests' THEN 'tests' WHEN 'Implements' THEN 'implements'
+ WHEN 'Depends On' THEN 'depends_on' WHEN 'Relates To' THEN 'relates_to'
+ WHEN 'Links To' THEN 'links_to' WHEN 'Duplicates' THEN 'duplicates'
+ WHEN 'Child Of' THEN 'child_of' WHEN 'Page' THEN 'page' END
+ WHERE is_system=true AND name IN ('Tests','Implements','Depends On','Relates To','Links To','Duplicates','Child Of','Page');
+ UPDATE themes SET builtin_key='default'
+ WHERE name='Default' AND description='Clean theme with standard navigation colors'
+ AND nav_background_color_light='#ffffff' AND nav_text_color_light='#374151'
+ AND nav_background_color_dark='#1f2937' AND nav_text_color_dark='#f3f4f6';
+ UPDATE themes SET builtin_key='ocean'
+ WHERE name='Ocean' AND description='Professional blue-tinted navigation theme'
+ AND nav_background_color_light='#f0f9ff' AND nav_text_color_light='#0c4a6e'
+ AND nav_background_color_dark='#0c4a6e' AND nav_text_color_dark='#e0f2fe';
+ UPDATE themes SET builtin_key='forest'
+ WHERE name='Forest' AND description='Nature-inspired green navigation theme'
+ AND nav_background_color_light='#f0fdf4' AND nav_text_color_light='#14532d'
+ AND nav_background_color_dark='#14532d' AND nav_text_color_dark='#dcfce7';
+
+ CREATE UNIQUE INDEX uq_configuration_sets_builtin_key ON configuration_sets(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_workflows_builtin_key ON workflows(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_screens_builtin_key ON screens(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_notification_settings_builtin_key ON notification_settings(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_item_types_builtin_key ON item_types(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_hierarchy_levels_builtin_key ON hierarchy_levels(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_priorities_builtin_key ON priorities(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_status_categories_builtin_key ON status_categories(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_statuses_builtin_key ON statuses(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_workspace_roles_builtin_key ON workspace_roles(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_link_types_builtin_key ON link_types(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_themes_builtin_key ON themes(builtin_key) WHERE builtin_key IS NOT NULL;
+ `,
+ Postgres: `
+ ALTER TABLE configuration_sets ADD COLUMN builtin_key TEXT;
+ ALTER TABLE workflows ADD COLUMN builtin_key TEXT;
+ ALTER TABLE screens ADD COLUMN builtin_key TEXT;
+ ALTER TABLE notification_settings ADD COLUMN builtin_key TEXT;
+ ALTER TABLE item_types ADD COLUMN builtin_key TEXT;
+ ALTER TABLE hierarchy_levels ADD COLUMN builtin_key TEXT;
+ ALTER TABLE priorities ADD COLUMN builtin_key TEXT;
+ ALTER TABLE status_categories ADD COLUMN builtin_key TEXT;
+ ALTER TABLE statuses ADD COLUMN builtin_key TEXT;
+ ALTER TABLE workspace_roles ADD COLUMN builtin_key TEXT;
+ ALTER TABLE link_types ADD COLUMN builtin_key TEXT;
+ ALTER TABLE themes ADD COLUMN builtin_key TEXT;
+
+ UPDATE configuration_sets SET builtin_key='default' WHERE name='Default Configuration' AND is_default=true;
+ UPDATE workflows SET builtin_key='default' WHERE name='Default Workflow' AND is_default=true;
+ UPDATE screens SET builtin_key='default' WHERE name='Default Screen';
+ UPDATE notification_settings SET builtin_key='default' WHERE name='Default Notifications';
+ UPDATE item_types SET builtin_key=CASE name
+ WHEN 'Initiative' THEN 'initiative' WHEN 'Epic' THEN 'epic' WHEN 'Story' THEN 'story'
+ WHEN 'Task' THEN 'task' WHEN 'Bug' THEN 'bug' WHEN 'Sub-task' THEN 'subtask' END
+ WHERE is_default=true AND name IN ('Initiative','Epic','Story','Task','Bug','Sub-task');
+ UPDATE hierarchy_levels SET builtin_key=CASE level
+ WHEN 0 THEN 'initiative' WHEN 1 THEN 'epic' WHEN 2 THEN 'story'
+ WHEN 3 THEN 'task' WHEN 4 THEN 'activity' END WHERE level BETWEEN 0 AND 4;
+ UPDATE priorities SET builtin_key=LOWER(name) WHERE name IN ('Critical','High','Medium','Low');
+ UPDATE status_categories SET builtin_key=CASE name
+ WHEN 'To Do' THEN 'to_do' WHEN 'In Progress' THEN 'in_progress' WHEN 'Done' THEN 'done' END
+ WHERE name IN ('To Do','In Progress','Done');
+ UPDATE statuses SET builtin_key=CASE name
+ WHEN 'Open' THEN 'open' WHEN 'In Progress' THEN 'in_progress' WHEN 'Done' THEN 'done' END
+ WHERE name IN ('Open','In Progress','Done');
+ UPDATE workspace_roles SET builtin_key=LOWER(name)
+ WHERE is_system=true AND name IN ('Viewer','Editor','Administrator','Tester');
+ UPDATE link_types SET builtin_key=CASE name
+ WHEN 'Tests' THEN 'tests' WHEN 'Implements' THEN 'implements'
+ WHEN 'Depends On' THEN 'depends_on' WHEN 'Relates To' THEN 'relates_to'
+ WHEN 'Links To' THEN 'links_to' WHEN 'Duplicates' THEN 'duplicates'
+ WHEN 'Child Of' THEN 'child_of' WHEN 'Page' THEN 'page' END
+ WHERE is_system=true AND name IN ('Tests','Implements','Depends On','Relates To','Links To','Duplicates','Child Of','Page');
+ UPDATE themes SET builtin_key='default'
+ WHERE name='Default' AND description='Clean theme with standard navigation colors'
+ AND nav_background_color_light='#ffffff' AND nav_text_color_light='#374151'
+ AND nav_background_color_dark='#1f2937' AND nav_text_color_dark='#f3f4f6';
+ UPDATE themes SET builtin_key='ocean'
+ WHERE name='Ocean' AND description='Professional blue-tinted navigation theme'
+ AND nav_background_color_light='#f0f9ff' AND nav_text_color_light='#0c4a6e'
+ AND nav_background_color_dark='#0c4a6e' AND nav_text_color_dark='#e0f2fe';
+ UPDATE themes SET builtin_key='forest'
+ WHERE name='Forest' AND description='Nature-inspired green navigation theme'
+ AND nav_background_color_light='#f0fdf4' AND nav_text_color_light='#14532d'
+ AND nav_background_color_dark='#14532d' AND nav_text_color_dark='#dcfce7';
+
+ CREATE UNIQUE INDEX uq_configuration_sets_builtin_key ON configuration_sets(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_workflows_builtin_key ON workflows(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_screens_builtin_key ON screens(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_notification_settings_builtin_key ON notification_settings(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_item_types_builtin_key ON item_types(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_hierarchy_levels_builtin_key ON hierarchy_levels(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_priorities_builtin_key ON priorities(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_status_categories_builtin_key ON status_categories(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_statuses_builtin_key ON statuses(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_workspace_roles_builtin_key ON workspace_roles(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_link_types_builtin_key ON link_types(builtin_key) WHERE builtin_key IS NOT NULL;
+ CREATE UNIQUE INDEX uq_themes_builtin_key ON themes(builtin_key) WHERE builtin_key IS NOT NULL;
+ `,
+ },
}
func (m Migration) checksum(driver string) string {
diff --git a/internal/database/postgres.go b/internal/database/postgres.go
index bda1539f5..4f7039248 100644
--- a/internal/database/postgres.go
+++ b/internal/database/postgres.go
@@ -575,8 +575,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
for _, cat := range defaultStatusCategories {
var id int64
err = tx.QueryRow(
- "INSERT INTO status_categories (name, color, description, is_default, is_completed) VALUES ($1, $2, $3, $4, $5) RETURNING id",
- cat.name, cat.color, cat.description, cat.isDefault, cat.isCompleted,
+ "INSERT INTO status_categories (builtin_key, name, color, description, is_default, is_completed) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
+ cat.builtinKey, cat.name, cat.color, cat.description, cat.isDefault, cat.isCompleted,
).Scan(&id)
if err != nil {
return fmt.Errorf("failed to create status category %s: %w", cat.name, err)
@@ -590,8 +590,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
categoryID := categoryIDs[status.category]
var id int64
err = tx.QueryRow(
- "INSERT INTO statuses (name, description, category_id, is_default) VALUES ($1, $2, $3, $4) RETURNING id",
- status.name, status.description, categoryID, status.isDefault,
+ "INSERT INTO statuses (builtin_key, name, description, category_id, is_default) VALUES ($1, $2, $3, $4, $5) RETURNING id",
+ status.builtinKey, status.name, status.description, categoryID, status.isDefault,
).Scan(&id)
if err != nil {
return fmt.Errorf("failed to create status %s: %w", status.name, err)
@@ -602,8 +602,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 3. Create default workflow
var workflowID int64
err = tx.QueryRow(
- "INSERT INTO workflows (name, description, is_default) VALUES ($1, $2, $3) RETURNING id",
- "Default Workflow", "Basic workflow for getting work done", true,
+ "INSERT INTO workflows (builtin_key, name, description, is_default) VALUES ($1, $2, $3, $4) RETURNING id",
+ "default", "Default Workflow", "Basic workflow for getting work done", true,
).Scan(&workflowID)
if err != nil {
return fmt.Errorf("failed to create default workflow: %w", err)
@@ -630,8 +630,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 5. Create default screen with basic fields
var screenID int64
err = tx.QueryRow(
- "INSERT INTO screens (name, description) VALUES ($1, $2) RETURNING id",
- "Default Screen", "Default screen with essential work item fields",
+ "INSERT INTO screens (builtin_key, name, description) VALUES ($1, $2, $3) RETURNING id",
+ "default", "Default Screen", "Default screen with essential work item fields",
).Scan(&screenID)
if err != nil {
return fmt.Errorf("failed to create default screen: %w", err)
@@ -651,8 +651,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 7. Create default configuration set
var configSetID int64
err = tx.QueryRow(
- "INSERT INTO configuration_sets (name, description, workflow_id, is_default) VALUES ($1, $2, $3, $4) RETURNING id",
- "Default Configuration", "Default configuration set with basic workflow and screen", workflowID, true,
+ "INSERT INTO configuration_sets (builtin_key, name, description, workflow_id, is_default) VALUES ($1, $2, $3, $4, $5) RETURNING id",
+ "default", "Default Configuration", "Default configuration set with basic workflow and screen", workflowID, true,
).Scan(&configSetID)
if err != nil {
return fmt.Errorf("failed to create default configuration set: %w", err)
@@ -672,8 +672,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 9. Create default link types
for _, linkType := range defaultLinkTypes {
_, err = tx.Exec(
- "INSERT INTO link_types (name, description, forward_label, reverse_label, color, is_system, allowed_entity_types) VALUES ($1, $2, $3, $4, $5, $6, $7)",
- linkType.name, linkType.description, linkType.forwardLabel, linkType.reverseLabel, linkType.color, linkType.isSystem, linkType.allowedEntityTypes,
+ "INSERT INTO link_types (builtin_key, name, description, forward_label, reverse_label, color, is_system, allowed_entity_types) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+ linkType.builtinKey, linkType.name, linkType.description, linkType.forwardLabel, linkType.reverseLabel, linkType.color, linkType.isSystem, linkType.allowedEntityTypes,
)
if err != nil {
return fmt.Errorf("failed to create link type %s: %w", linkType.name, err)
@@ -694,8 +694,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 12. Create default hierarchy levels
for _, hl := range defaultHierarchyLevels {
_, err = tx.Exec(
- "INSERT INTO hierarchy_levels (level, name, description) VALUES ($1, $2, $3)",
- hl.level, hl.name, hl.description,
+ "INSERT INTO hierarchy_levels (builtin_key, level, name, description) VALUES ($1, $2, $3, $4)",
+ hl.builtinKey, hl.level, hl.name, hl.description,
)
if err != nil {
return fmt.Errorf("failed to create hierarchy level %s: %w", hl.name, err)
@@ -705,8 +705,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 13. Create default item types with icons and colors
for _, itemType := range defaultItemTypes {
_, err = tx.Exec(
- "INSERT INTO item_types (configuration_set_id, name, description, icon, color, hierarchy_level, sort_order, is_default) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
- configSetID, itemType.name, itemType.description, itemType.icon, itemType.color, itemType.hierarchyLevel, itemType.sortOrder, true,
+ "INSERT INTO item_types (builtin_key, configuration_set_id, name, description, icon, color, hierarchy_level, sort_order, is_default) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
+ itemType.builtinKey, configSetID, itemType.name, itemType.description, itemType.icon, itemType.color, itemType.hierarchyLevel, itemType.sortOrder, true,
)
if err != nil {
return fmt.Errorf("failed to create default item type %s: %w", itemType.name, err)
@@ -741,8 +741,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// 14. Create default themes with dual light/dark nav colors
for _, theme := range defaultThemes {
_, err = tx.Exec(
- "INSERT INTO themes (name, description, is_default, is_active, nav_background_color_light, nav_text_color_light, nav_background_color_dark, nav_text_color_dark) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
- theme.name, theme.description, theme.isDefault, theme.isActive, theme.navBackgroundColorLight, theme.navTextColorLight, theme.navBackgroundColorDark, theme.navTextColorDark,
+ "INSERT INTO themes (builtin_key, name, description, is_default, is_active, nav_background_color_light, nav_text_color_light, nav_background_color_dark, nav_text_color_dark) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
+ theme.builtinKey, theme.name, theme.description, theme.isDefault, theme.isActive, theme.navBackgroundColorLight, theme.navTextColorLight, theme.navBackgroundColorDark, theme.navTextColorDark,
)
if err != nil {
return fmt.Errorf("failed to create theme %s: %w", theme.name, err)
@@ -769,8 +769,8 @@ func (p *PostgresDB) initializePostgresDefaultData() error {
// layer free of email-domain imports.)
var notificationSettingID int64
err = tx.QueryRow(
- "INSERT INTO notification_settings (name, description, is_active, created_by) VALUES ($1, $2, $3, $4) RETURNING id",
- "Default Notifications", "Standard notification rules for work item updates", true, nil,
+ "INSERT INTO notification_settings (builtin_key, name, description, is_active, created_by) VALUES ($1, $2, $3, $4, $5) RETURNING id",
+ "default", "Default Notifications", "Standard notification rules for work item updates", true, nil,
).Scan(¬ificationSettingID)
if err != nil {
return fmt.Errorf("failed to create default notification setting: %w", err)
diff --git a/internal/database/schema/base_tables_postgres.sql b/internal/database/schema/base_tables_postgres.sql
index 02e2d6f76..32bebe0c0 100644
--- a/internal/database/schema/base_tables_postgres.sql
+++ b/internal/database/schema/base_tables_postgres.sql
@@ -188,6 +188,7 @@ CREATE TABLE IF NOT EXISTS custom_field_indexes (
-- From config_workflows_postgres.sql
CREATE TABLE IF NOT EXISTS workflows (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -197,6 +198,7 @@ CREATE TABLE IF NOT EXISTS workflows (
CREATE TABLE IF NOT EXISTS screens (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
@@ -206,6 +208,7 @@ CREATE TABLE IF NOT EXISTS screens (
CREATE TABLE IF NOT EXISTS priorities (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -218,6 +221,7 @@ CREATE TABLE IF NOT EXISTS priorities (
CREATE TABLE IF NOT EXISTS item_types (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
configuration_set_id INTEGER, -- Made nullable for many-to-many relationship
name TEXT NOT NULL UNIQUE, -- Changed to global unique (not per config set)
description TEXT,
@@ -232,6 +236,7 @@ CREATE TABLE IF NOT EXISTS item_types (
CREATE TABLE IF NOT EXISTS hierarchy_levels (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
level INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
@@ -241,6 +246,7 @@ CREATE TABLE IF NOT EXISTS hierarchy_levels (
CREATE TABLE IF NOT EXISTS status_categories (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL, -- Hex color code (e.g., "#3b82f6")
description TEXT,
@@ -294,6 +300,7 @@ CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(categ
CREATE TABLE IF NOT EXISTS themes (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -306,6 +313,8 @@ CREATE TABLE IF NOT EXISTS themes (
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_themes_builtin_key ON themes(builtin_key) WHERE builtin_key IS NOT NULL;
+
CREATE TABLE IF NOT EXISTS board_configurations (
id SERIAL PRIMARY KEY,
workspace_id INTEGER,
@@ -333,6 +342,7 @@ CREATE TABLE IF NOT EXISTS attachment_settings (
CREATE TABLE IF NOT EXISTS link_types (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
forward_label TEXT NOT NULL,
@@ -345,6 +355,8 @@ CREATE TABLE IF NOT EXISTS link_types (
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_link_types_builtin_key ON link_types(builtin_key) WHERE builtin_key IS NOT NULL;
+
-- Email templates: per-sender HTML/text/subject Go templates editable from the
-- admin UI. `name` is the lookup key consumed by senders (e.g. "magic_link",
-- "notification_batch"). The legacy template_type/event_type/template_body/
diff --git a/internal/database/schema/config_workflows.sql b/internal/database/schema/config_workflows.sql
index 1640d5585..dddf4efb1 100644
--- a/internal/database/schema/config_workflows.sql
+++ b/internal/database/schema/config_workflows.sql
@@ -1,5 +1,6 @@
CREATE TABLE IF NOT EXISTS configuration_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -14,6 +15,7 @@ CREATE TABLE IF NOT EXISTS configuration_sets (
CREATE TABLE IF NOT EXISTS item_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
configuration_set_id INTEGER, -- Made nullable for many-to-many relationship
name TEXT NOT NULL UNIQUE, -- Changed to global unique (not per config set)
description TEXT,
@@ -59,6 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_config_set_item_types_view_screen_id ON configura
CREATE TABLE IF NOT EXISTS priorities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -85,6 +88,7 @@ CREATE INDEX IF NOT EXISTS idx_config_set_priorities_priority_id ON configuratio
CREATE TABLE IF NOT EXISTS hierarchy_levels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
level INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
@@ -94,6 +98,7 @@ CREATE TABLE IF NOT EXISTS hierarchy_levels (
CREATE TABLE IF NOT EXISTS screens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -131,6 +136,7 @@ CREATE INDEX IF NOT EXISTS idx_screen_system_fields_screen_id ON screen_system_f
-- Workflow System Tables
CREATE TABLE IF NOT EXISTS status_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL, -- Hex color code (e.g., "#3b82f6")
description TEXT,
@@ -142,6 +148,7 @@ CREATE TABLE IF NOT EXISTS status_categories (
CREATE TABLE IF NOT EXISTS statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
category_id INTEGER NOT NULL,
@@ -153,6 +160,7 @@ CREATE TABLE IF NOT EXISTS statuses (
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -181,6 +189,14 @@ CREATE INDEX IF NOT EXISTS idx_statuses_category_id ON statuses(category_id);
CREATE INDEX IF NOT EXISTS idx_workflow_transitions_workflow_id ON workflow_transitions(workflow_id);
CREATE INDEX IF NOT EXISTS idx_workflow_transitions_from_status ON workflow_transitions(from_status_id);
CREATE INDEX IF NOT EXISTS idx_workflow_transitions_to_status ON workflow_transitions(to_status_id);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_configuration_sets_builtin_key ON configuration_sets(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_item_types_builtin_key ON item_types(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_priorities_builtin_key ON priorities(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_hierarchy_levels_builtin_key ON hierarchy_levels(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_screens_builtin_key ON screens(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_status_categories_builtin_key ON status_categories(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_statuses_builtin_key ON statuses(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_workflows_builtin_key ON workflows(builtin_key) WHERE builtin_key IS NOT NULL;
-- Configuration Set Screen Assignments
CREATE TABLE IF NOT EXISTS configuration_set_screens (
diff --git a/internal/database/schema/config_workflows_postgres.sql b/internal/database/schema/config_workflows_postgres.sql
index c54ac45e4..78c9ba8b8 100644
--- a/internal/database/schema/config_workflows_postgres.sql
+++ b/internal/database/schema/config_workflows_postgres.sql
@@ -10,6 +10,7 @@
CREATE TABLE IF NOT EXISTS statuses (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
category_id INTEGER NOT NULL,
@@ -21,6 +22,7 @@ CREATE TABLE IF NOT EXISTS statuses (
CREATE TABLE IF NOT EXISTS configuration_sets (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -135,6 +137,14 @@ CREATE TABLE IF NOT EXISTS workflow_transitions (
-- Configuration set indexes
CREATE INDEX IF NOT EXISTS idx_configuration_sets_workflow_id ON configuration_sets(workflow_id);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_configuration_sets_builtin_key ON configuration_sets(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_item_types_builtin_key ON item_types(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_priorities_builtin_key ON priorities(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_hierarchy_levels_builtin_key ON hierarchy_levels(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_screens_builtin_key ON screens(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_status_categories_builtin_key ON status_categories(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_statuses_builtin_key ON statuses(builtin_key) WHERE builtin_key IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_workflows_builtin_key ON workflows(builtin_key) WHERE builtin_key IS NOT NULL;
-- Item type indexes (moved to base_tables_postgres.sql)
CREATE INDEX IF NOT EXISTS idx_item_types_configuration_set_id ON item_types(configuration_set_id);
diff --git a/internal/database/schema/content.sql b/internal/database/schema/content.sql
index 91d359bf5..15c98736e 100644
--- a/internal/database/schema/content.sql
+++ b/internal/database/schema/content.sql
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_item_diagrams_created_by ON item_diagrams(created
-- Link management tables
CREATE TABLE IF NOT EXISTS link_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
forward_label TEXT NOT NULL,
@@ -86,6 +87,8 @@ CREATE TABLE IF NOT EXISTS link_types (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_link_types_builtin_key ON link_types(builtin_key) WHERE builtin_key IS NOT NULL;
+
CREATE TABLE IF NOT EXISTS item_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_type_id INTEGER NOT NULL,
diff --git a/internal/database/schema/content_postgres.sql b/internal/database/schema/content_postgres.sql
index 31f749d6a..9a0c57d71 100644
--- a/internal/database/schema/content_postgres.sql
+++ b/internal/database/schema/content_postgres.sql
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_item_diagrams_created_by ON item_diagrams(created
-- Link management tables
CREATE TABLE IF NOT EXISTS link_types (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
forward_label TEXT NOT NULL,
@@ -86,6 +87,8 @@ CREATE TABLE IF NOT EXISTS link_types (
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_link_types_builtin_key ON link_types(builtin_key) WHERE builtin_key IS NOT NULL;
+
CREATE TABLE IF NOT EXISTS item_links (
id SERIAL PRIMARY KEY,
link_type_id INTEGER NOT NULL,
diff --git a/internal/database/schema/default_data.sql b/internal/database/schema/default_data.sql
index 5603b3060..32abeae53 100644
--- a/internal/database/schema/default_data.sql
+++ b/internal/database/schema/default_data.sql
@@ -2,11 +2,11 @@
-- This file contains INSERT statements for default priorities
-- Default priorities
-INSERT INTO priorities (name, description, icon, color, sort_order, is_default)
+INSERT INTO priorities (builtin_key, name, description, icon, color, sort_order, is_default)
VALUES
- ('Critical', 'Urgent items requiring immediate attention', 'AlertCircle', '#dc2626', 1, FALSE),
- ('High', 'High priority items', 'ArrowUp', '#ea580c', 2, FALSE),
- ('Medium', 'Normal priority items', 'Minus', '#ca8a04', 3, TRUE),
- ('Low', 'Low priority items', 'ArrowDown', '#16a34a', 4, FALSE);
+ ('critical', 'Critical', 'Urgent items requiring immediate attention', 'AlertCircle', '#dc2626', 1, FALSE),
+ ('high', 'High', 'High priority items', 'ArrowUp', '#ea580c', 2, FALSE),
+ ('medium', 'Medium', 'Normal priority items', 'Minus', '#ca8a04', 3, TRUE),
+ ('low', 'Low', 'Low priority items', 'ArrowDown', '#16a34a', 4, FALSE);
-- migration: 0000_baseline
diff --git a/internal/database/schema/default_data_postgres.sql b/internal/database/schema/default_data_postgres.sql
index f91eeb1a9..ec01c043d 100644
--- a/internal/database/schema/default_data_postgres.sql
+++ b/internal/database/schema/default_data_postgres.sql
@@ -2,9 +2,9 @@
-- This file contains INSERT statements for default priorities
-- Default priorities
-INSERT INTO priorities (name, description, icon, color, sort_order, is_default)
+INSERT INTO priorities (builtin_key, name, description, icon, color, sort_order, is_default)
VALUES
- ('Critical', 'Urgent items requiring immediate attention', 'AlertCircle', '#dc2626', 1, false),
- ('High', 'High priority items', 'ArrowUp', '#ea580c', 2, false),
- ('Medium', 'Normal priority items', 'Minus', '#ca8a04', 3, true),
- ('Low', 'Low priority items', 'ArrowDown', '#16a34a', 4, false);
+ ('critical', 'Critical', 'Urgent items requiring immediate attention', 'AlertCircle', '#dc2626', 1, false),
+ ('high', 'High', 'High priority items', 'ArrowUp', '#ea580c', 2, false),
+ ('medium', 'Medium', 'Normal priority items', 'Minus', '#ca8a04', 3, true),
+ ('low', 'Low', 'Low priority items', 'ArrowDown', '#16a34a', 4, false);
diff --git a/internal/database/schema/notifications.sql b/internal/database/schema/notifications.sql
index 8e71a5a8a..8965fbe53 100644
--- a/internal/database/schema/notifications.sql
+++ b/internal/database/schema/notifications.sql
@@ -62,6 +62,7 @@ CREATE INDEX IF NOT EXISTS idx_notification_templates_active ON notification_tem
-- Notification settings system for configuration sets
CREATE TABLE IF NOT EXISTS notification_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT true,
@@ -73,6 +74,7 @@ CREATE TABLE IF NOT EXISTS notification_settings (
CREATE INDEX IF NOT EXISTS idx_notification_settings_active ON notification_settings(is_active);
CREATE INDEX IF NOT EXISTS idx_notification_settings_created_by ON notification_settings(created_by);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_notification_settings_builtin_key ON notification_settings(builtin_key) WHERE builtin_key IS NOT NULL;
-- Notification event rules for each setting
CREATE TABLE IF NOT EXISTS notification_event_rules (
diff --git a/internal/database/schema/notifications_postgres.sql b/internal/database/schema/notifications_postgres.sql
index b29620e42..fdf405c3a 100644
--- a/internal/database/schema/notifications_postgres.sql
+++ b/internal/database/schema/notifications_postgres.sql
@@ -45,6 +45,7 @@ CREATE INDEX IF NOT EXISTS idx_notifications_workspace_id ON notifications(works
-- Notification settings system for configuration sets
CREATE TABLE IF NOT EXISTS notification_settings (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT true,
@@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS notification_settings (
CREATE INDEX IF NOT EXISTS idx_notification_settings_active ON notification_settings(is_active);
CREATE INDEX IF NOT EXISTS idx_notification_settings_created_by ON notification_settings(created_by);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_notification_settings_builtin_key ON notification_settings(builtin_key) WHERE builtin_key IS NOT NULL;
-- Notification event rules for each setting
CREATE TABLE IF NOT EXISTS notification_event_rules (
diff --git a/internal/database/schema/permissions.sql b/internal/database/schema/permissions.sql
index 3e34f6a89..3649b179b 100644
--- a/internal/database/schema/permissions.sql
+++ b/internal/database/schema/permissions.sql
@@ -33,6 +33,7 @@ CREATE INDEX IF NOT EXISTS idx_user_global_permissions_permission_id ON user_glo
-- Workspace roles tables
CREATE TABLE IF NOT EXISTS workspace_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_system BOOLEAN DEFAULT FALSE,
@@ -45,6 +46,7 @@ CREATE TABLE IF NOT EXISTS workspace_roles (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_workspace_roles_builtin_key ON workspace_roles(builtin_key) WHERE builtin_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS user_workspace_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -213,12 +215,12 @@ INSERT OR IGNORE INTO permissions (permission_key, permission_name, description,
('public_board.manage', 'Manage Public Boards', 'Can make collections public and configure public board sharing', 'global', 0);
-- Default workspace roles
-INSERT OR IGNORE INTO workspace_roles (name, description, is_system, display_order)
+INSERT OR IGNORE INTO workspace_roles (builtin_key, name, description, is_system, display_order)
VALUES
- ('Viewer', 'Can view workspace content and participate in discussions', 1, 1),
- ('Editor', 'Can create and edit work items within the workspace', 1, 2),
- ('Administrator', 'Full workspace administration including permissions', 1, 3),
- ('Tester', 'Can view items, execute tests, manage test cases, and create defects', 1, 4);
+ ('viewer', 'Viewer', 'Can view workspace content and participate in discussions', 1, 1),
+ ('editor', 'Editor', 'Can create and edit work items within the workspace', 1, 2),
+ ('administrator', 'Administrator', 'Full workspace administration including permissions', 1, 3),
+ ('tester', 'Tester', 'Can view items, execute tests, manage test cases, and create defects', 1, 4);
-- Default role permission mappings
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
diff --git a/internal/database/schema/permissions_postgres.sql b/internal/database/schema/permissions_postgres.sql
index 85c3132f5..eb7586a77 100644
--- a/internal/database/schema/permissions_postgres.sql
+++ b/internal/database/schema/permissions_postgres.sql
@@ -33,6 +33,7 @@ CREATE INDEX IF NOT EXISTS idx_user_global_permissions_permission_id ON user_glo
-- Workspace roles tables
CREATE TABLE IF NOT EXISTS workspace_roles (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_system BOOLEAN DEFAULT false,
@@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS workspace_roles (
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_workspace_roles_builtin_key ON workspace_roles(builtin_key) WHERE builtin_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS user_workspace_roles (
id SERIAL PRIMARY KEY,
@@ -212,12 +214,12 @@ INSERT INTO permissions (permission_key, permission_name, description, scope, is
ON CONFLICT (permission_key) DO NOTHING;
-- Default workspace roles
-INSERT INTO workspace_roles (name, description, is_system, display_order)
+INSERT INTO workspace_roles (builtin_key, name, description, is_system, display_order)
VALUES
- ('Viewer', 'Can view workspace content and participate in discussions', true, 1),
- ('Editor', 'Can create and edit work items within the workspace', true, 2),
- ('Administrator', 'Full workspace administration including permissions', true, 3),
- ('Tester', 'Can view items, execute tests, manage test cases, and create defects', true, 4)
+ ('viewer', 'Viewer', 'Can view workspace content and participate in discussions', true, 1),
+ ('editor', 'Editor', 'Can create and edit work items within the workspace', true, 2),
+ ('administrator', 'Administrator', 'Full workspace administration including permissions', true, 3),
+ ('tester', 'Tester', 'Can view items, execute tests, manage test cases, and create defects', true, 4)
ON CONFLICT (name) DO NOTHING;
-- Default role permission mappings
diff --git a/internal/database/schema/system.sql b/internal/database/schema/system.sql
index fdda854cb..aa92f6303 100644
--- a/internal/database/schema/system.sql
+++ b/internal/database/schema/system.sql
@@ -237,6 +237,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_user_id ON active_timers(use
-- Themes table
CREATE TABLE IF NOT EXISTS themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -249,6 +250,8 @@ CREATE TABLE IF NOT EXISTS themes (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_themes_builtin_key ON themes(builtin_key) WHERE builtin_key IS NOT NULL;
+
-- Board configuration tables
CREATE TABLE IF NOT EXISTS board_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/internal/database/schema/system_postgres.sql b/internal/database/schema/system_postgres.sql
index 87fee1336..eaee4377d 100644
--- a/internal/database/schema/system_postgres.sql
+++ b/internal/database/schema/system_postgres.sql
@@ -256,6 +256,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_user_id ON active_timers(use
-- Themes table
CREATE TABLE IF NOT EXISTS themes (
id SERIAL PRIMARY KEY,
+ builtin_key TEXT,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
@@ -268,6 +269,8 @@ CREATE TABLE IF NOT EXISTS themes (
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_themes_builtin_key ON themes(builtin_key) WHERE builtin_key IS NOT NULL;
+
-- Board configuration tables
CREATE TABLE IF NOT EXISTS board_configurations (
id SERIAL PRIMARY KEY,
diff --git a/internal/handlers/homepage.go b/internal/handlers/homepage.go
index e8ff4fb4b..c6399c116 100644
--- a/internal/handlers/homepage.go
+++ b/internal/handlers/homepage.go
@@ -71,9 +71,11 @@ type ItemActivity struct {
WorkspaceItemNumber int `json:"workspace_item_number"`
Title string `json:"title"`
Status string `json:"status"`
+ StatusBuiltinKey string `json:"status_builtin_key,omitempty"`
StatusColor *string `json:"status_color,omitempty"`
PriorityID *int `json:"priority_id,omitempty"`
PriorityName *string `json:"priority_name,omitempty"`
+ PriorityBuiltinKey string `json:"priority_builtin_key,omitempty"`
PriorityColor *string `json:"priority_color,omitempty"`
LastActivity string `json:"last_activity"`
ActivityCount int `json:"activity_count"`
@@ -349,7 +351,9 @@ func (h *HomepageHandler) getItemActivitiesBatch(ctx context.Context, activities
WorkspaceItemNumber: item.WorkspaceItemNumber,
Title: item.Title,
Status: item.StatusName,
+ StatusBuiltinKey: item.StatusBuiltinKey,
PriorityID: item.PriorityID,
+ PriorityBuiltinKey: item.PriorityBuiltinKey,
WorkspaceKey: item.WorkspaceKey,
}
if itemActivity.Status == "" {
diff --git a/internal/handlers/items_status.go b/internal/handlers/items_status.go
index fce678a4c..f5839418f 100644
--- a/internal/handlers/items_status.go
+++ b/internal/handlers/items_status.go
@@ -150,6 +150,7 @@ func (h *ItemHandler) loadAvailableStatusTransitions(ctx context.Context, userID
twids = append(twids, services.TransitionWithID{
TransitionID: rt.TransitionID,
StatusID: rt.StatusID,
+ BuiltinKey: rt.BuiltinKey,
StatusName: rt.StatusName,
CategoryColor: color,
})
@@ -175,6 +176,7 @@ func (h *ItemHandler) loadAvailableStatusTransitions(ctx context.Context, userID
rawTransitions = append(rawTransitions, services.StatusTransitionOption{
TransitionID: f.TransitionID,
StatusID: f.StatusID,
+ BuiltinKey: f.BuiltinKey,
StatusName: f.StatusName,
CategoryColor: categoryColor,
})
@@ -273,9 +275,10 @@ func (h *ItemHandler) GetWorkspaceTransitionMatrix(w http.ResponseWriter, r *htt
func transitionOptionResponse(option services.StatusTransitionOption) map[string]any {
transition := map[string]any{
- "id": option.StatusID,
- "name": option.StatusName,
- "value": strings.ToLower(strings.ReplaceAll(option.StatusName, " ", "_")),
+ "id": option.StatusID,
+ "builtin_key": option.BuiltinKey,
+ "name": option.StatusName,
+ "value": strings.ToLower(strings.ReplaceAll(option.StatusName, " ", "_")),
}
if option.CategoryColor != nil {
transition["category_color"] = *option.CategoryColor
diff --git a/internal/handlers/link_types.go b/internal/handlers/link_types.go
index bd38fad59..f4cbe7676 100644
--- a/internal/handlers/link_types.go
+++ b/internal/handlers/link_types.go
@@ -83,6 +83,7 @@ func (h *LinkTypeHandler) Create(w http.ResponseWriter, r *http.Request) {
}
lt.ID = id
+ lt.BuiltinKey = ""
lt.IsSystem = false
lt.Active = true
lt.CreatedAt = now
@@ -124,15 +125,19 @@ func (h *LinkTypeHandler) Update(w http.ResponseWriter, r *http.Request) {
return
}
- lt.ID = id
- lt.UpdatedAt = now
+ updated, err := h.repo.GetByID(id)
+ if err != nil {
+ respondInternalError(w, r, err)
+ return
+ }
+ updated.UpdatedAt = now
currentUser := utils.GetCurrentUser(r)
if currentUser != nil {
- h.auditor.Log(r, currentUser, logger.ActionLinkTypeUpdate, logger.ResourceLinkType, &id, lt.Name)
+ h.auditor.Log(r, currentUser, logger.ActionLinkTypeUpdate, logger.ResourceLinkType, &id, updated.Name)
}
- respondJSONOK(w, lt)
+ respondJSONOK(w, updated)
}
func (h *LinkTypeHandler) Delete(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/handlers/priorities.go b/internal/handlers/priorities.go
index c8e9fb8bb..76d17adb9 100644
--- a/internal/handlers/priorities.go
+++ b/internal/handlers/priorities.go
@@ -27,7 +27,7 @@ func NewPriorityHandler(db database.Database) *PriorityHandler {
func (h *PriorityHandler) GetAll(w http.ResponseWriter, r *http.Request) {
// Base query for priorities
query := `
- SELECT p.id, p.name, p.description, p.is_default,
+ SELECT p.id, COALESCE(p.builtin_key, ''), p.name, p.description, p.is_default,
p.icon, p.color, p.sort_order, p.created_at, p.updated_at
FROM priorities p`
@@ -54,7 +54,7 @@ func (h *PriorityHandler) GetAll(w http.ResponseWriter, r *http.Request) {
var priorities []models.Priority
for rows.Next() {
var p models.Priority
- err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.IsDefault,
+ err := rows.Scan(&p.ID, &p.BuiltinKey, &p.Name, &p.Description, &p.IsDefault,
&p.Icon, &p.Color, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
respondInternalError(w, r, err)
@@ -63,7 +63,7 @@ func (h *PriorityHandler) GetAll(w http.ResponseWriter, r *http.Request) {
// Load configuration set associations from junction table
configSetQuery := `
- SELECT cs.id, cs.name
+ SELECT cs.id, cs.name, COALESCE(cs.builtin_key, '')
FROM configuration_set_priorities csp
JOIN configuration_sets cs ON csp.configuration_set_id = cs.id
WHERE csp.priority_id = ?
@@ -77,16 +77,18 @@ func (h *PriorityHandler) GetAll(w http.ResponseWriter, r *http.Request) {
var configSetIDs []int
var configSetNames []string
+ var configSetBuiltinKeys []string
for configSetRows.Next() {
var configSetID int
- var configSetName string
- if err := configSetRows.Scan(&configSetID, &configSetName); err != nil {
+ var configSetName, configSetBuiltinKey string
+ if err := configSetRows.Scan(&configSetID, &configSetName, &configSetBuiltinKey); err != nil {
_ = configSetRows.Close()
respondInternalError(w, r, err)
return
}
configSetIDs = append(configSetIDs, configSetID)
configSetNames = append(configSetNames, configSetName)
+ configSetBuiltinKeys = append(configSetBuiltinKeys, configSetBuiltinKey)
}
if err := configSetRows.Err(); err != nil {
_ = configSetRows.Close()
@@ -97,6 +99,7 @@ func (h *PriorityHandler) GetAll(w http.ResponseWriter, r *http.Request) {
p.ConfigurationSetIDs = configSetIDs
p.ConfigurationSetNames = configSetNames
+ p.ConfigurationSetBuiltinKeys = configSetBuiltinKeys
priorities = append(priorities, p)
}
@@ -120,11 +123,11 @@ func (h *PriorityHandler) Get(w http.ResponseWriter, r *http.Request) {
var p models.Priority
err := h.db.QueryRow(`
- SELECT id, name, description, is_default,
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default,
icon, color, sort_order, created_at, updated_at
FROM priorities
WHERE id = ?
- `, id).Scan(&p.ID, &p.Name, &p.Description, &p.IsDefault,
+ `, id).Scan(&p.ID, &p.BuiltinKey, &p.Name, &p.Description, &p.IsDefault,
&p.Icon, &p.Color, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
@@ -138,7 +141,7 @@ func (h *PriorityHandler) Get(w http.ResponseWriter, r *http.Request) {
// Load configuration set associations from junction table
configSetQuery := `
- SELECT cs.id, cs.name
+ SELECT cs.id, cs.name, COALESCE(cs.builtin_key, '')
FROM configuration_set_priorities csp
JOIN configuration_sets cs ON csp.configuration_set_id = cs.id
WHERE csp.priority_id = ?
@@ -153,15 +156,17 @@ func (h *PriorityHandler) Get(w http.ResponseWriter, r *http.Request) {
var configSetIDs []int
var configSetNames []string
+ var configSetBuiltinKeys []string
for configSetRows.Next() {
var configSetID int
- var configSetName string
- if err := configSetRows.Scan(&configSetID, &configSetName); err != nil {
+ var configSetName, configSetBuiltinKey string
+ if err := configSetRows.Scan(&configSetID, &configSetName, &configSetBuiltinKey); err != nil {
respondInternalError(w, r, err)
return
}
configSetIDs = append(configSetIDs, configSetID)
configSetNames = append(configSetNames, configSetName)
+ configSetBuiltinKeys = append(configSetBuiltinKeys, configSetBuiltinKey)
}
if err := configSetRows.Err(); err != nil {
respondInternalError(w, r, err)
@@ -170,6 +175,7 @@ func (h *PriorityHandler) Get(w http.ResponseWriter, r *http.Request) {
p.ConfigurationSetIDs = configSetIDs
p.ConfigurationSetNames = configSetNames
+ p.ConfigurationSetBuiltinKeys = configSetBuiltinKeys
respondJSONOK(w, p)
}
@@ -234,11 +240,11 @@ func (h *PriorityHandler) validateConfigurationSets(w http.ResponseWriter, r *ht
// loadPriorityWithConfigSets loads a priority by ID and attaches configuration set info.
func (h *PriorityHandler) loadPriorityWithConfigSets(p *models.Priority, id int, configSetIDs []int) error {
err := h.db.QueryRow(`
- SELECT id, name, description, is_default,
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default,
icon, color, sort_order, created_at, updated_at
FROM priorities
WHERE id = ?
- `, id).Scan(&p.ID, &p.Name, &p.Description, &p.IsDefault,
+ `, id).Scan(&p.ID, &p.BuiltinKey, &p.Name, &p.Description, &p.IsDefault,
&p.Icon, &p.Color, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return err
@@ -247,13 +253,19 @@ func (h *PriorityHandler) loadPriorityWithConfigSets(p *models.Priority, id int,
p.ConfigurationSetIDs = configSetIDs
var configSetNames []string
+ var configSetBuiltinKeys []string
for _, csID := range configSetIDs {
- var csName string
- if err := h.db.QueryRow("SELECT name FROM configuration_sets WHERE id = ?", csID).Scan(&csName); err == nil {
+ var csName, csBuiltinKey string
+ if err := h.db.QueryRow(
+ "SELECT name, COALESCE(builtin_key, '') FROM configuration_sets WHERE id = ?",
+ csID,
+ ).Scan(&csName, &csBuiltinKey); err == nil {
configSetNames = append(configSetNames, csName)
+ configSetBuiltinKeys = append(configSetBuiltinKeys, csBuiltinKey)
}
}
p.ConfigurationSetNames = configSetNames
+ p.ConfigurationSetBuiltinKeys = configSetBuiltinKeys
return nil
}
diff --git a/internal/handlers/screens.go b/internal/handlers/screens.go
index a968613a7..9a0dd5f60 100644
--- a/internal/handlers/screens.go
+++ b/internal/handlers/screens.go
@@ -35,7 +35,7 @@ func NewScreenHandler(db database.Database) *ScreenHandler {
}
func (h *ScreenHandler) GetAll(w http.ResponseWriter, r *http.Request) {
- query := `SELECT id, name, description, created_at, updated_at FROM screens ORDER BY name`
+ query := `SELECT id, COALESCE(builtin_key, ''), name, description, created_at, updated_at FROM screens ORDER BY name`
rows, err := h.db.Query(query)
if err != nil {
@@ -46,7 +46,7 @@ func (h *ScreenHandler) GetAll(w http.ResponseWriter, r *http.Request) {
var screens []models.Screen
for rows.Next() {
var screen models.Screen
- err := rows.Scan(&screen.ID, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
+ err := rows.Scan(&screen.ID, &screen.BuiltinKey, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
if err != nil {
_ = rows.Close()
respondInternalError(w, r, err)
@@ -102,9 +102,9 @@ func (h *ScreenHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *ScreenHandler) loadScreen(id int) (*models.Screen, error) {
var screen models.Screen
err := h.db.QueryRow(`
- SELECT id, name, description, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, created_at, updated_at
FROM screens WHERE id = ?
- `, id).Scan(&screen.ID, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
+ `, id).Scan(&screen.ID, &screen.BuiltinKey, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrNotFound
@@ -174,9 +174,9 @@ func (h *ScreenHandler) Create(w http.ResponseWriter, r *http.Request) {
// Return the created screen
err = h.db.QueryRow(`
- SELECT id, name, description, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, created_at, updated_at
FROM screens WHERE id = ?
- `, id).Scan(&screen.ID, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
+ `, id).Scan(&screen.ID, &screen.BuiltinKey, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
if err != nil {
respondInternalError(w, r, err)
@@ -224,9 +224,9 @@ func (h *ScreenHandler) Update(w http.ResponseWriter, r *http.Request) {
// Return the updated screen
err = h.db.QueryRow(`
- SELECT id, name, description, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, created_at, updated_at
FROM screens WHERE id = ?
- `, id).Scan(&screen.ID, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
+ `, id).Scan(&screen.ID, &screen.BuiltinKey, &screen.Name, &screen.Description, &screen.CreatedAt, &screen.UpdatedAt)
if err != nil {
respondInternalError(w, r, err)
diff --git a/internal/handlers/workspace_bootstrap.go b/internal/handlers/workspace_bootstrap.go
index 117fbbc74..2febdc69d 100644
--- a/internal/handlers/workspace_bootstrap.go
+++ b/internal/handlers/workspace_bootstrap.go
@@ -275,7 +275,7 @@ func prioritiesToModels(results []services.PriorityResult) []models.Priority {
priorities := make([]models.Priority, 0, len(results))
for _, priority := range results {
priorities = append(priorities, models.Priority{
- ID: priority.ID, Name: priority.Name, Description: priority.Description,
+ ID: priority.ID, BuiltinKey: priority.BuiltinKey, Name: priority.Name, Description: priority.Description,
Icon: priority.Icon, Color: priority.Color, SortOrder: priority.SortOrder,
IsDefault: priority.IsDefault,
})
diff --git a/internal/handlers/workspace_roles.go b/internal/handlers/workspace_roles.go
index ec44ae958..afc70bfb5 100644
--- a/internal/handlers/workspace_roles.go
+++ b/internal/handlers/workspace_roles.go
@@ -269,6 +269,7 @@ func (h *WorkspaceRoleHandler) GetWorkspaceRoleAssignments(w http.ResponseWriter
type Role struct {
RoleID int `json:"role_id"`
+ RoleBuiltinKey string `json:"role_builtin_key,omitempty"`
RoleName string `json:"role_name"`
RoleDescription string `json:"role_description"`
AssignmentID int `json:"assignment_id"`
@@ -306,6 +307,7 @@ func (h *WorkspaceRoleHandler) GetWorkspaceRoleAssignments(w http.ResponseWriter
// Add role to user
user.Roles = append(user.Roles, Role{
RoleID: a.RoleID,
+ RoleBuiltinKey: a.RoleBuiltinKey,
RoleName: a.RoleName,
RoleDescription: a.RoleDescription,
AssignmentID: a.AssignmentID,
@@ -495,6 +497,7 @@ func (h *WorkspaceRoleHandler) GetWorkspaceGroupRoleAssignments(w http.ResponseW
type Role struct {
RoleID int `json:"role_id"`
+ RoleBuiltinKey string `json:"role_builtin_key,omitempty"`
RoleName string `json:"role_name"`
RoleDescription string `json:"role_description"`
AssignmentID int `json:"assignment_id"`
@@ -527,6 +530,7 @@ func (h *WorkspaceRoleHandler) GetWorkspaceGroupRoleAssignments(w http.ResponseW
group.Roles = append(group.Roles, Role{
RoleID: a.RoleID,
+ RoleBuiltinKey: a.RoleBuiltinKey,
RoleName: a.RoleName,
RoleDescription: a.RoleDescription,
AssignmentID: a.AssignmentID,
diff --git a/internal/handlers/workspaces_stats.go b/internal/handlers/workspaces_stats.go
index b3bbd43dd..5e8fa6cac 100644
--- a/internal/handlers/workspaces_stats.go
+++ b/internal/handlers/workspaces_stats.go
@@ -46,10 +46,11 @@ type ProjectStats struct {
// MilestoneStatusBreakdown represents the distribution of items per status category within a milestone
type MilestoneStatusBreakdown struct {
- CategoryName string `json:"category_name"`
- CategoryColor string `json:"category_color,omitempty"`
- ItemCount int `json:"item_count"`
- IsCompleted bool `json:"is_completed"`
+ CategoryName string `json:"category_name"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ ItemCount int `json:"item_count"`
+ IsCompleted bool `json:"is_completed"`
}
// MilestoneStatusProgress aggregates milestone progress for a workspace
@@ -221,10 +222,11 @@ func (h *WorkspaceHandler) GetStats(w http.ResponseWriter, r *http.Request) {
breakdowns := make([]MilestoneStatusBreakdown, len(rp.StatusBreakdown))
for j, rb := range rp.StatusBreakdown {
breakdowns[j] = MilestoneStatusBreakdown{
- CategoryName: rb.CategoryName,
- CategoryColor: rb.CategoryColor,
- ItemCount: rb.ItemCount,
- IsCompleted: rb.IsCompleted,
+ CategoryName: rb.CategoryName,
+ CategoryBuiltinKey: rb.CategoryBuiltinKey,
+ CategoryColor: rb.CategoryColor,
+ ItemCount: rb.ItemCount,
+ IsCompleted: rb.IsCompleted,
}
}
milestoneProgress[i] = MilestoneStatusProgress{
diff --git a/internal/models/configuration.go b/internal/models/configuration.go
index 33f6b3b74..4582579a9 100644
--- a/internal/models/configuration.go
+++ b/internal/models/configuration.go
@@ -15,6 +15,7 @@ const HierarchyLevelGenericSubtask = -1
// ConfigurationSet represents a configuration set for workspaces
type ConfigurationSet struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
WorkspaceID int `json:"workspace_id"` // Keep for backward compatibility
Name string `json:"name"`
Description string `json:"description"`
@@ -27,11 +28,13 @@ type ConfigurationSet struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields for API responses
- WorkspaceName string `json:"workspace_name,omitempty"`
- WorkflowName string `json:"workflow_name,omitempty"`
- NotificationSettingName string `json:"notification_setting_name,omitempty"`
- ConditionSetName string `json:"condition_set_name,omitempty"`
- ApprovalSetName string `json:"approval_set_name,omitempty"`
+ WorkspaceName string `json:"workspace_name,omitempty"`
+ WorkflowName string `json:"workflow_name,omitempty"`
+ WorkflowBuiltinKey string `json:"workflow_builtin_key,omitempty"`
+ NotificationSettingName string `json:"notification_setting_name,omitempty"`
+ NotificationSettingBuiltinKey string `json:"notification_setting_builtin_key,omitempty"`
+ ConditionSetName string `json:"condition_set_name,omitempty"`
+ ApprovalSetName string `json:"approval_set_name,omitempty"`
// Many-to-many workspace relationships
WorkspaceIDs []int `json:"workspace_ids,omitempty"`
Workspaces []string `json:"workspaces,omitempty"` // Workspace names for display
@@ -43,15 +46,19 @@ type ConfigurationSet struct {
PriorityIDs []int `json:"priority_ids,omitempty"` // IDs of associated priorities
PrioritiesDetailed []PriorityDisplay `json:"priorities_detailed,omitempty"` // Full priority data with icons and colors
// Screen assignments for different contexts
- CreateScreenID *int `json:"create_screen_id,omitempty"`
- EditScreenID *int `json:"edit_screen_id,omitempty"`
- ViewScreenID *int `json:"view_screen_id,omitempty"`
- CreateScreenName string `json:"create_screen_name,omitempty"`
- EditScreenName string `json:"edit_screen_name,omitempty"`
- ViewScreenName string `json:"view_screen_name,omitempty"`
+ CreateScreenID *int `json:"create_screen_id,omitempty"`
+ EditScreenID *int `json:"edit_screen_id,omitempty"`
+ ViewScreenID *int `json:"view_screen_id,omitempty"`
+ CreateScreenName string `json:"create_screen_name,omitempty"`
+ CreateScreenBuiltinKey string `json:"create_screen_builtin_key,omitempty"`
+ EditScreenName string `json:"edit_screen_name,omitempty"`
+ EditScreenBuiltinKey string `json:"edit_screen_builtin_key,omitempty"`
+ ViewScreenName string `json:"view_screen_name,omitempty"`
+ ViewScreenBuiltinKey string `json:"view_screen_builtin_key,omitempty"`
// Default item type for new items (when user has no localStorage preference)
- DefaultItemTypeID *int `json:"default_item_type_id,omitempty"`
- DefaultItemTypeName string `json:"default_item_type_name,omitempty"`
+ DefaultItemTypeID *int `json:"default_item_type_id,omitempty"`
+ DefaultItemTypeName string `json:"default_item_type_name,omitempty"`
+ DefaultItemTypeBuiltinKey string `json:"default_item_type_builtin_key,omitempty"`
}
// ConfigurationSetScreen represents a screen assignment for a configuration set
@@ -69,6 +76,7 @@ type ConfigurationSetScreen struct {
// Screen represents a field layout screen
type Screen struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
@@ -96,6 +104,7 @@ type ScreenField struct {
// ItemType represents a type of work item
type ItemType struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
ConfigurationSetID int `json:"configuration_set_id,omitempty"` // Deprecated: kept for backward compatibility
Name string `json:"name"`
Description string `json:"description"`
@@ -107,8 +116,9 @@ type ItemType struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Many-to-many configuration set relationships
- ConfigurationSetIDs []int `json:"configuration_set_ids,omitempty"` // IDs of associated configuration sets
- ConfigurationSetNames []string `json:"configuration_set_names,omitempty"` // Names for display
+ ConfigurationSetIDs []int `json:"configuration_set_ids,omitempty"` // IDs of associated configuration sets
+ ConfigurationSetNames []string `json:"configuration_set_names,omitempty"` // Names for display
+ ConfigurationSetBuiltinKeys []string `json:"configuration_set_builtin_keys,omitempty"` // Stable keys for built-in configuration sets
// Deprecated joined fields (kept for backward compatibility)
ConfigurationSetName string `json:"configuration_set_name,omitempty"`
WorkspaceName string `json:"workspace_name,omitempty"`
@@ -116,6 +126,7 @@ type ItemType struct {
// ItemTypeDisplay holds minimal item type data for displaying in configuration sets
type ItemTypeDisplay struct {
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Icon string `json:"icon"`
Color string `json:"color"`
@@ -124,14 +135,16 @@ type ItemTypeDisplay struct {
// ItemTypeConfig represents item type configuration with optional workflow and screen overrides
type ItemTypeConfig struct {
- ItemTypeID int `json:"item_type_id"`
- ItemTypeName string `json:"item_type_name"`
- ItemTypeIcon string `json:"item_type_icon"`
- ItemTypeColor string `json:"item_type_color"`
- HierarchyLevel int `json:"hierarchy_level"`
+ ItemTypeID int `json:"item_type_id"`
+ ItemTypeBuiltinKey string `json:"item_type_builtin_key,omitempty"`
+ ItemTypeName string `json:"item_type_name"`
+ ItemTypeIcon string `json:"item_type_icon"`
+ ItemTypeColor string `json:"item_type_color"`
+ HierarchyLevel int `json:"hierarchy_level"`
// Override workflow (NULL = use configuration set default)
- WorkflowID *int `json:"workflow_id,omitempty"`
- WorkflowName string `json:"workflow_name,omitempty"` // "Default" or workflow name
+ WorkflowID *int `json:"workflow_id,omitempty"`
+ WorkflowName string `json:"workflow_name,omitempty"` // "Default" or workflow name
+ WorkflowBuiltinKey string `json:"workflow_builtin_key,omitempty"`
// Override condition set (NULL = use configuration set default)
ConditionSetID *int `json:"condition_set_id,omitempty"`
ConditionSetName string `json:"condition_set_name,omitempty"`
@@ -139,17 +152,21 @@ type ItemTypeConfig struct {
ApprovalSetID *int `json:"approval_set_id,omitempty"`
ApprovalSetName string `json:"approval_set_name,omitempty"`
// Override screens (NULL = use configuration set defaults)
- CreateScreenID *int `json:"create_screen_id,omitempty"`
- CreateScreenName string `json:"create_screen_name,omitempty"`
- EditScreenID *int `json:"edit_screen_id,omitempty"`
- EditScreenName string `json:"edit_screen_name,omitempty"`
- ViewScreenID *int `json:"view_screen_id,omitempty"`
- ViewScreenName string `json:"view_screen_name,omitempty"`
+ CreateScreenID *int `json:"create_screen_id,omitempty"`
+ CreateScreenName string `json:"create_screen_name,omitempty"`
+ CreateScreenBuiltinKey string `json:"create_screen_builtin_key,omitempty"`
+ EditScreenID *int `json:"edit_screen_id,omitempty"`
+ EditScreenName string `json:"edit_screen_name,omitempty"`
+ EditScreenBuiltinKey string `json:"edit_screen_builtin_key,omitempty"`
+ ViewScreenID *int `json:"view_screen_id,omitempty"`
+ ViewScreenName string `json:"view_screen_name,omitempty"`
+ ViewScreenBuiltinKey string `json:"view_screen_builtin_key,omitempty"`
}
// Priority represents a priority level
type Priority struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
@@ -159,22 +176,25 @@ type Priority struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Many-to-many configuration set relationships
- ConfigurationSetIDs []int `json:"configuration_set_ids,omitempty"` // IDs of associated configuration sets
- ConfigurationSetNames []string `json:"configuration_set_names,omitempty"` // Names for display
+ ConfigurationSetIDs []int `json:"configuration_set_ids,omitempty"` // IDs of associated configuration sets
+ ConfigurationSetNames []string `json:"configuration_set_names,omitempty"` // Names for display
+ ConfigurationSetBuiltinKeys []string `json:"configuration_set_builtin_keys,omitempty"` // Stable keys for built-in configuration sets
}
// PriorityDisplay holds minimal priority data for displaying in configuration sets
type PriorityDisplay struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Icon string `json:"icon"`
- Color string `json:"color"`
- SortOrder int `json:"sort_order"`
+ ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ Color string `json:"color"`
+ SortOrder int `json:"sort_order"`
}
// HierarchyLevel represents a hierarchy level definition
type HierarchyLevel struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Level int `json:"level"` // 0, 1, 2, 3...
Name string `json:"name"` // e.g., "Initiative", "Epic", "Task", "Sub-task"
Description string `json:"description"`
@@ -185,6 +205,7 @@ type HierarchyLevel struct {
// StatusCategory represents a category for statuses
type StatusCategory struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Color string `json:"color"` // Hex color code (e.g., "#3b82f6")
Description string `json:"description"`
@@ -197,6 +218,7 @@ type StatusCategory struct {
// Status represents a workflow status
type Status struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CategoryID int `json:"category_id"`
@@ -204,14 +226,16 @@ type Status struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields for API responses
- CategoryName string `json:"category_name,omitempty"`
- CategoryColor string `json:"category_color,omitempty"`
- IsCompleted bool `json:"is_completed,omitempty"`
+ CategoryName string `json:"category_name,omitempty"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ IsCompleted bool `json:"is_completed,omitempty"`
}
// Workflow represents a workflow definition
type Workflow struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
diff --git a/internal/models/item.go b/internal/models/item.go
index 0fd9d2def..7797a56d9 100644
--- a/internal/models/item.go
+++ b/internal/models/item.go
@@ -62,12 +62,15 @@ type Item struct {
WorkspaceName string `json:"workspace_name,omitempty"`
WorkspaceKey string `json:"workspace_key,omitempty"`
ItemTypeName string `json:"item_type_name,omitempty"`
+ ItemTypeBuiltinKey string `json:"item_type_builtin_key,omitempty"`
PriorityName string `json:"priority_name,omitempty"`
+ PriorityBuiltinKey string `json:"priority_builtin_key,omitempty"`
PriorityIcon string `json:"priority_icon,omitempty"`
PriorityColor string `json:"priority_color,omitempty"`
ParentTitle string `json:"parent_title,omitempty"`
ParentWorkspaceItemNumber *int `json:"parent_workspace_item_number,omitempty"` // Parent's workspace-scoped number, for rendering the parent key
StatusName string `json:"status_name,omitempty"` // Name from statuses table (joined field)
+ StatusBuiltinKey string `json:"status_builtin_key,omitempty"` // Stable key for translating built-in statuses
StatusColor string `json:"status_color,omitempty"` // Color from the status category (joined field)
// StatusSince is when the item last entered its current status, derived from
// item_history (falls back to created_at when there is no recorded change).
@@ -264,6 +267,7 @@ type AttachmentSettingsRequest struct {
// LinkType represents a type of link between items
type LinkType struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
ForwardLabel string `json:"forward_label"`
diff --git a/internal/models/permissions.go b/internal/models/permissions.go
index 3d2926111..a94923cb5 100644
--- a/internal/models/permissions.go
+++ b/internal/models/permissions.go
@@ -190,6 +190,7 @@ type PermissionSetPermission struct {
// future toggle can flip it; v1 forces it false at create time.
type WorkspaceRole struct {
ID int `json:"id" db:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty" db:"builtin_key"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
IsSystem bool `json:"is_system" db:"is_system"`
diff --git a/internal/models/portal.go b/internal/models/portal.go
index 9ced3ee1a..c25971d83 100644
--- a/internal/models/portal.go
+++ b/internal/models/portal.go
@@ -538,6 +538,7 @@ type EmailTemplate struct {
// NotificationSetting represents a notification configuration that can be assigned to configuration sets
type NotificationSetting struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"` // e.g., "Development Team Notifications"
Description string `json:"description"` // e.g., "Standard notifications for development workspaces"
IsActive bool `json:"is_active"`
diff --git a/internal/models/user.go b/internal/models/user.go
index cfefed551..e2dd6faba 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -236,6 +236,7 @@ type TeamGroupMembershipResponse struct {
// Theme represents the application's visual theme settings
type Theme struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
diff --git a/internal/repository/configuration_set_repository.go b/internal/repository/configuration_set_repository.go
index acd6df6e6..2d17c8f67 100644
--- a/internal/repository/configuration_set_repository.go
+++ b/internal/repository/configuration_set_repository.go
@@ -118,7 +118,15 @@ const notificationSettingSubquery = `
WHERE csns2.configuration_set_id = cs.id
ORDER BY csns2.created_at DESC
LIMIT 1
- ) AS notification_setting_name`
+ ) AS notification_setting_name,
+ (
+ SELECT COALESCE(ns.builtin_key, '')
+ FROM configuration_set_notification_settings csns3
+ JOIN notification_settings ns ON ns.id = csns3.notification_setting_id
+ WHERE csns3.configuration_set_id = cs.id
+ ORDER BY csns3.created_at DESC
+ LIMIT 1
+ ) AS notification_setting_builtin_key`
// FindByID loads a configuration set by ID with all related data
func (r *ConfigurationSetRepository) FindByID(id int) (*models.ConfigurationSet, error) {
@@ -147,19 +155,21 @@ func (r *ConfigurationSetRepository) findByIDBasic(id int) (*models.Configuratio
var defaultItemTypeID sql.NullInt64
var notificationSettingID sql.NullInt64
var notificationSettingName sql.NullString
+ var notificationSettingBuiltinKey sql.NullString
var defaultItemTypeName sql.NullString
+ var defaultItemTypeBuiltinKey sql.NullString
var conditionSetID sql.NullInt64
var conditionSetName sql.NullString
var approvalSetID sql.NullInt64
var approvalSetName sql.NullString
query := fmt.Sprintf(`
- SELECT cs.id, cs.name, cs.description, cs.is_default, cs.differentiate_by_item_type, cs.workflow_id,
+ SELECT cs.id, COALESCE(cs.builtin_key, ''), cs.name, cs.description, cs.is_default, cs.differentiate_by_item_type, cs.workflow_id,
cs.default_item_type_id, cs.condition_set_id, cs.approval_set_id,
%s,
cs.created_at, cs.updated_at,
- wf.name as workflow_name,
- dit.name as default_item_type_name,
+ wf.name as workflow_name, COALESCE(wf.builtin_key, '') as workflow_builtin_key,
+ dit.name as default_item_type_name, COALESCE(dit.builtin_key, '') as default_item_type_builtin_key,
cset.name as condition_set_name,
aset.name as approval_set_name
FROM configuration_sets cs
@@ -171,11 +181,11 @@ func (r *ConfigurationSetRepository) findByIDBasic(id int) (*models.Configuratio
`, notificationSettingSubquery)
err := r.db.QueryRow(query, id).Scan(
- &cs.ID, &cs.Name, &cs.Description,
+ &cs.ID, &cs.BuiltinKey, &cs.Name, &cs.Description,
&cs.IsDefault, &cs.DifferentiateByItemType, &workflowID, &defaultItemTypeID,
&conditionSetID, &approvalSetID,
- ¬ificationSettingID, ¬ificationSettingName, &cs.CreatedAt, &cs.UpdatedAt,
- &workflowName, &defaultItemTypeName, &conditionSetName, &approvalSetName,
+ ¬ificationSettingID, ¬ificationSettingName, ¬ificationSettingBuiltinKey, &cs.CreatedAt, &cs.UpdatedAt,
+ &workflowName, &cs.WorkflowBuiltinKey, &defaultItemTypeName, &defaultItemTypeBuiltinKey, &conditionSetName, &approvalSetName,
)
if errors.Is(err, sql.ErrNoRows) {
@@ -187,7 +197,9 @@ func (r *ConfigurationSetRepository) findByIDBasic(id int) (*models.Configuratio
cs.WorkflowName = workflowName.String
cs.NotificationSettingName = notificationSettingName.String
+ cs.NotificationSettingBuiltinKey = notificationSettingBuiltinKey.String
cs.DefaultItemTypeName = defaultItemTypeName.String
+ cs.DefaultItemTypeBuiltinKey = defaultItemTypeBuiltinKey.String
cs.ConditionSetName = conditionSetName.String
cs.ApprovalSetName = approvalSetName.String
cs.WorkflowID = utils.NullInt64ToPtr(workflowID)
@@ -223,12 +235,12 @@ func (r *ConfigurationSetRepository) List(page, limit int, search string) ([]mod
// Build data query with pagination
offset := (page - 1) * limit
query := fmt.Sprintf(`
- SELECT cs.id, cs.name, cs.description, cs.is_default, cs.differentiate_by_item_type, cs.workflow_id,
+ SELECT cs.id, COALESCE(cs.builtin_key, ''), cs.name, cs.description, cs.is_default, cs.differentiate_by_item_type, cs.workflow_id,
cs.default_item_type_id, cs.condition_set_id, cs.approval_set_id,
%s,
cs.created_at, cs.updated_at,
- wf.name as workflow_name,
- dit.name as default_item_type_name,
+ wf.name as workflow_name, COALESCE(wf.builtin_key, '') as workflow_builtin_key,
+ dit.name as default_item_type_name, COALESCE(dit.builtin_key, '') as default_item_type_builtin_key,
cset.name as condition_set_name,
aset.name as approval_set_name
FROM configuration_sets cs
@@ -255,18 +267,20 @@ func (r *ConfigurationSetRepository) List(page, limit int, search string) ([]mod
var defaultItemTypeID sql.NullInt64
var notificationSettingID sql.NullInt64
var notificationSettingName sql.NullString
+ var notificationSettingBuiltinKey sql.NullString
var defaultItemTypeName sql.NullString
+ var defaultItemTypeBuiltinKey sql.NullString
var conditionSetID sql.NullInt64
var conditionSetName sql.NullString
var approvalSetID sql.NullInt64
var approvalSetName sql.NullString
err := rows.Scan(
- &cs.ID, &cs.Name, &cs.Description,
+ &cs.ID, &cs.BuiltinKey, &cs.Name, &cs.Description,
&cs.IsDefault, &cs.DifferentiateByItemType, &workflowID, &defaultItemTypeID,
&conditionSetID, &approvalSetID,
- ¬ificationSettingID, ¬ificationSettingName, &cs.CreatedAt, &cs.UpdatedAt,
- &workflowName, &defaultItemTypeName, &conditionSetName, &approvalSetName,
+ ¬ificationSettingID, ¬ificationSettingName, ¬ificationSettingBuiltinKey, &cs.CreatedAt, &cs.UpdatedAt,
+ &workflowName, &cs.WorkflowBuiltinKey, &defaultItemTypeName, &defaultItemTypeBuiltinKey, &conditionSetName, &approvalSetName,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan configuration set: %w", err)
@@ -274,7 +288,9 @@ func (r *ConfigurationSetRepository) List(page, limit int, search string) ([]mod
cs.WorkflowName = workflowName.String
cs.NotificationSettingName = notificationSettingName.String
+ cs.NotificationSettingBuiltinKey = notificationSettingBuiltinKey.String
cs.DefaultItemTypeName = defaultItemTypeName.String
+ cs.DefaultItemTypeBuiltinKey = defaultItemTypeBuiltinKey.String
cs.ConditionSetName = conditionSetName.String
cs.ApprovalSetName = approvalSetName.String
cs.WorkflowID = utils.NullInt64ToPtr(workflowID)
@@ -368,6 +384,7 @@ func (r *ConfigurationSetRepository) loadRelations(cs *models.ConfigurationSet)
for _, config := range itemTypeConfigs {
itemTypeNames = append(itemTypeNames, config.ItemTypeName)
itemTypesDetailed = append(itemTypesDetailed, models.ItemTypeDisplay{
+ BuiltinKey: config.ItemTypeBuiltinKey,
Name: config.ItemTypeName,
Icon: config.ItemTypeIcon,
Color: config.ItemTypeColor,
@@ -424,7 +441,7 @@ func (r *ConfigurationSetRepository) loadWorkspaces(configSetID int) (ids []int,
// loadScreens loads screen assignments for a configuration set
func (r *ConfigurationSetRepository) loadScreens(cs *models.ConfigurationSet) error {
query := `
- SELECT css.context, css.screen_id, s.name
+ SELECT css.context, css.screen_id, s.name, COALESCE(s.builtin_key, '')
FROM configuration_set_screens css
JOIN screens s ON css.screen_id = s.id
WHERE css.configuration_set_id = ?`
@@ -439,7 +456,8 @@ func (r *ConfigurationSetRepository) loadScreens(cs *models.ConfigurationSet) er
var screenContext string
var screenID int
var screenName string
- if err := rows.Scan(&screenContext, &screenID, &screenName); err != nil {
+ var screenBuiltinKey string
+ if err := rows.Scan(&screenContext, &screenID, &screenName, &screenBuiltinKey); err != nil {
return fmt.Errorf("failed to scan screen: %w", err)
}
@@ -447,12 +465,15 @@ func (r *ConfigurationSetRepository) loadScreens(cs *models.ConfigurationSet) er
case "create":
cs.CreateScreenID = &screenID
cs.CreateScreenName = screenName
+ cs.CreateScreenBuiltinKey = screenBuiltinKey
case "edit":
cs.EditScreenID = &screenID
cs.EditScreenName = screenName
+ cs.EditScreenBuiltinKey = screenBuiltinKey
case "view":
cs.ViewScreenID = &screenID
cs.ViewScreenName = screenName
+ cs.ViewScreenBuiltinKey = screenBuiltinKey
}
}
if err := rows.Err(); err != nil {
@@ -466,11 +487,11 @@ func (r *ConfigurationSetRepository) loadScreens(cs *models.ConfigurationSet) er
func (r *ConfigurationSetRepository) loadItemTypeConfigs(configSetID int) ([]models.ItemTypeConfig, error) {
query := `
SELECT
- it.id, it.name, it.icon, it.color, it.hierarchy_level,
- csit.workflow_id, wf.name as workflow_name,
- csit.create_screen_id, cs_create.name as create_screen_name,
- csit.edit_screen_id, cs_edit.name as edit_screen_name,
- csit.view_screen_id, cs_view.name as view_screen_name,
+ it.id, COALESCE(it.builtin_key, ''), it.name, it.icon, it.color, it.hierarchy_level,
+ csit.workflow_id, wf.name as workflow_name, COALESCE(wf.builtin_key, ''),
+ csit.create_screen_id, cs_create.name as create_screen_name, COALESCE(cs_create.builtin_key, ''),
+ csit.edit_screen_id, cs_edit.name as edit_screen_name, COALESCE(cs_edit.builtin_key, ''),
+ csit.view_screen_id, cs_view.name as view_screen_name, COALESCE(cs_view.builtin_key, ''),
csit.condition_set_id, cset.name as condition_set_name,
csit.approval_set_id, aset.name as approval_set_name
FROM configuration_set_item_types csit
@@ -507,11 +528,11 @@ func (r *ConfigurationSetRepository) loadItemTypeConfigs(configSetID int) ([]mod
var approvalSetName sql.NullString
if err := rows.Scan(
- &config.ItemTypeID, &config.ItemTypeName, &config.ItemTypeIcon, &config.ItemTypeColor, &config.HierarchyLevel,
- &workflowID, &workflowName,
- &createScreenID, &createScreenName,
- &editScreenID, &editScreenName,
- &viewScreenID, &viewScreenName,
+ &config.ItemTypeID, &config.ItemTypeBuiltinKey, &config.ItemTypeName, &config.ItemTypeIcon, &config.ItemTypeColor, &config.HierarchyLevel,
+ &workflowID, &workflowName, &config.WorkflowBuiltinKey,
+ &createScreenID, &createScreenName, &config.CreateScreenBuiltinKey,
+ &editScreenID, &editScreenName, &config.EditScreenBuiltinKey,
+ &viewScreenID, &viewScreenName, &config.ViewScreenBuiltinKey,
&conditionSetID, &conditionSetName,
&approvalSetID, &approvalSetName,
); err != nil {
@@ -559,7 +580,7 @@ func (r *ConfigurationSetRepository) loadItemTypeConfigs(configSetID int) ([]mod
// loadPriorities loads priority assignments for a configuration set
func (r *ConfigurationSetRepository) loadPriorities(configSetID int) ([]int, []models.PriorityDisplay, error) {
query := `
- SELECT p.id, p.name, p.icon, p.color, p.sort_order
+ SELECT p.id, COALESCE(p.builtin_key, ''), p.name, p.icon, p.color, p.sort_order
FROM configuration_set_priorities csp
JOIN priorities p ON csp.priority_id = p.id
WHERE csp.configuration_set_id = ?
@@ -575,7 +596,7 @@ func (r *ConfigurationSetRepository) loadPriorities(configSetID int) ([]int, []m
var priorities []models.PriorityDisplay
for rows.Next() {
var priority models.PriorityDisplay
- if err := rows.Scan(&priority.ID, &priority.Name, &priority.Icon, &priority.Color, &priority.SortOrder); err != nil {
+ if err := rows.Scan(&priority.ID, &priority.BuiltinKey, &priority.Name, &priority.Icon, &priority.Color, &priority.SortOrder); err != nil {
return nil, nil, fmt.Errorf("failed to scan priority: %w", err)
}
priorityIDs = append(priorityIDs, priority.ID)
@@ -1163,7 +1184,7 @@ func (r *ConfigurationSetRepository) UnassignNotification(configSetID, assignmen
func (r *ConfigurationSetRepository) ListAvailableNotificationSettings(configSetID int) ([]models.NotificationSetting, error) {
rows, err := r.db.Query(`
SELECT
- ns.id, ns.name, ns.description, ns.is_active, ns.created_by, ns.created_at, ns.updated_at,
+ ns.id, COALESCE(ns.builtin_key, ''), ns.name, ns.description, ns.is_active, ns.created_by, ns.created_at, ns.updated_at,
u.first_name || ' ' || u.last_name as created_by_name
FROM notification_settings ns
LEFT JOIN users u ON ns.created_by = u.id
@@ -1186,7 +1207,7 @@ func (r *ConfigurationSetRepository) ListAvailableNotificationSettings(configSet
var createdBy *int
var createdByName *string
if err := rows.Scan(
- &s.ID, &s.Name, &s.Description, &s.IsActive, &createdBy, &s.CreatedAt, &s.UpdatedAt,
+ &s.ID, &s.BuiltinKey, &s.Name, &s.Description, &s.IsActive, &createdBy, &s.CreatedAt, &s.UpdatedAt,
&createdByName,
); err != nil {
return nil, err
diff --git a/internal/repository/item_list.go b/internal/repository/item_list.go
index 48969e04f..742aed478 100644
--- a/internal/repository/item_list.go
+++ b/internal/repository/item_list.go
@@ -260,11 +260,12 @@ func (r *ItemRepository) FindAllWithDetailsPageContext(ctx context.Context, para
i.id, i.workspace_id, i.workspace_item_number, i.item_type_id, i.title, %s, i.status_id, i.priority_id, i.due_date, i.start_date, i.end_date, i.is_task,
i.iteration_id, i.project_id, i.inherit_project, i.time_project_id, i.assignee_id, i.creator_id, i.custom_field_values, i.calendar_data, i.parent_id,
i.story_points, i.estimate_minutes, i.frac_index, i.created_at, i.updated_at, i.last_active_at,
- w.name as workspace_name, w.key as workspace_key, it.name as item_type_name,
+ w.name as workspace_name, w.key as workspace_key, it.name as item_type_name, COALESCE(it.builtin_key, '') as item_type_builtin_key,
p.title as parent_title, p.workspace_item_number as parent_workspace_item_number, iter.name as iteration_name, COALESCE(CAST(iter.end_date AS TEXT), '') as iteration_end_date, proj.name as project_name, tp.name as time_project_name,
assignee.first_name || ' ' || assignee.last_name as assignee_name, assignee.email as assignee_email, assignee.avatar_url as assignee_avatar,
creator.first_name || ' ' || creator.last_name as creator_name, creator.email as creator_email,
- st.name as status_name, sc.color as status_color, pri.name as priority_name, pri.icon as priority_icon, pri.color as priority_color,
+ st.name as status_name, COALESCE(st.builtin_key, '') as status_builtin_key, sc.color as status_color,
+ pri.name as priority_name, COALESCE(pri.builtin_key, '') as priority_builtin_key, pri.icon as priority_icon, pri.color as priority_color,
COALESCE(%s, i.created_at) as status_since
`, descriptionExpr, cql.CurrentStatusTransitionAtExpr(""))
@@ -855,9 +856,9 @@ func (r *ItemRepository) scanItemList(rows *sql.Rows) ([]models.Item, error) {
var itemTypeID, parentID, parentWorkspaceItemNumber, iterationID, projectID, timeProjectID, assigneeID, creatorID, statusID, priorityID sql.NullInt64
var dueDate, startDate, endDate sql.NullTime
var statusSince sql.NullString
- var itemTypeName, parentTitle, iterationName, iterationEndDate, projectName, timeProjectName sql.NullString
+ var itemTypeName, itemTypeBuiltinKey, parentTitle, iterationName, iterationEndDate, projectName, timeProjectName sql.NullString
var assigneeName, assigneeEmail, assigneeAvatar, creatorName, creatorEmail, statusName, statusColor sql.NullString
- var priorityName, priorityIcon, priorityColor sql.NullString
+ var statusBuiltinKey, priorityName, priorityBuiltinKey, priorityIcon, priorityColor sql.NullString
var fracIndex sql.NullString
var storyPoints sql.NullFloat64
var estimateMinutes sql.NullInt64
@@ -869,8 +870,8 @@ func (r *ItemRepository) scanItemList(rows *sql.Rows) ([]models.Item, error) {
err := rows.Scan(
&item.ID, &item.WorkspaceID, &item.WorkspaceItemNumber, &itemTypeID, &item.Title, &item.Description,
&statusID, &priorityID, &dueDate, &startDate, &endDate, &item.IsTask, &iterationID, &projectID, &inheritProject, &timeProjectID, &assigneeID, &creatorID, &customFieldValuesJSON, &calendarDataJSON, &parentID,
- &storyPoints, &estimateMinutes, &fracIndex, &item.CreatedAt, &item.UpdatedAt, &lastActiveAt, &item.WorkspaceName, &item.WorkspaceKey, &itemTypeName, &parentTitle, &parentWorkspaceItemNumber, &iterationName, &iterationEndDate, &projectName, &timeProjectName,
- &assigneeName, &assigneeEmail, &assigneeAvatar, &creatorName, &creatorEmail, &statusName, &statusColor, &priorityName, &priorityIcon, &priorityColor,
+ &storyPoints, &estimateMinutes, &fracIndex, &item.CreatedAt, &item.UpdatedAt, &lastActiveAt, &item.WorkspaceName, &item.WorkspaceKey, &itemTypeName, &itemTypeBuiltinKey, &parentTitle, &parentWorkspaceItemNumber, &iterationName, &iterationEndDate, &projectName, &timeProjectName,
+ &assigneeName, &assigneeEmail, &assigneeAvatar, &creatorName, &creatorEmail, &statusName, &statusBuiltinKey, &statusColor, &priorityName, &priorityBuiltinKey, &priorityIcon, &priorityColor,
&statusSince,
)
if err != nil {
@@ -915,14 +916,17 @@ func (r *ItemRepository) scanItemList(rows *sql.Rows) ([]models.Item, error) {
item.InheritProject = inheritProject
assignNullableString(&item.ItemTypeName, itemTypeName)
+ assignNullableString(&item.ItemTypeBuiltinKey, itemTypeBuiltinKey)
assignNullableString(&item.ParentTitle, parentTitle)
assignNullableString(&item.IterationName, iterationName)
assignNullableString(&item.IterationEndDate, iterationEndDate)
assignNullableString(&item.StatusName, statusName)
+ assignNullableString(&item.StatusBuiltinKey, statusBuiltinKey)
assignNullableString(&item.StatusColor, statusColor)
assignNullableString(&item.ProjectName, projectName)
assignNullableString(&item.TimeProjectName, timeProjectName)
assignNullableString(&item.PriorityName, priorityName)
+ assignNullableString(&item.PriorityBuiltinKey, priorityBuiltinKey)
assignNullableString(&item.PriorityIcon, priorityIcon)
assignNullableString(&item.PriorityColor, priorityColor)
assignNullableString(&item.AssigneeName, assigneeName)
diff --git a/internal/repository/item_repository.go b/internal/repository/item_repository.go
index 09ebf66fe..e95182db5 100644
--- a/internal/repository/item_repository.go
+++ b/internal/repository/item_repository.go
@@ -256,9 +256,9 @@ const itemDetailsSelectBody = `
p.workspace_item_number as parent_workspace_item_number,
assignee.first_name || ' ' || assignee.last_name as assignee_name, assignee.email as assignee_email, assignee.avatar_url as assignee_avatar,
creator.first_name || ' ' || creator.last_name as creator_name, creator.email as creator_email,
- pri.name as priority_name, pri.icon as priority_icon, pri.color as priority_color,
- s.name as status_name,
- it.name as item_type_name,
+ pri.name as priority_name, COALESCE(pri.builtin_key, '') as priority_builtin_key, pri.icon as priority_icon, pri.color as priority_color,
+ s.name as status_name, COALESCE(s.builtin_key, '') as status_builtin_key,
+ it.name as item_type_name, COALESCE(it.builtin_key, '') as item_type_builtin_key,
i.related_work_item_id,
rw.title as related_work_item_title,
rw_ws.key as related_work_item_workspace_key,
@@ -292,9 +292,9 @@ func scanItemDetailsRow(scanner rowScanner) (models.Item, bool, error) {
var projectName, iterationName, timeProjectName, parentTitle sql.NullString
var parentWorkspaceItemNumber sql.NullInt64
var assigneeName, assigneeEmail, assigneeAvatar, creatorName, creatorEmail sql.NullString
- var priorityName, priorityIcon, priorityColor sql.NullString
- var statusName sql.NullString
- var itemTypeName sql.NullString
+ var priorityName, priorityBuiltinKey, priorityIcon, priorityColor sql.NullString
+ var statusName, statusBuiltinKey sql.NullString
+ var itemTypeName, itemTypeBuiltinKey sql.NullString
var relatedWorkItemID sql.NullInt64
var relatedWorkItemTitle, relatedWorkItemWorkspaceKey sql.NullString
var relatedWorkItemWorkspaceID, relatedWorkItemNumber sql.NullInt64
@@ -313,9 +313,9 @@ func scanItemDetailsRow(scanner rowScanner) (models.Item, bool, error) {
&item.WorkspaceName, &item.WorkspaceKey, &workspaceActive,
&iterationName, &projectName, &timeProjectName, &parentTitle, &parentWorkspaceItemNumber,
&assigneeName, &assigneeEmail, &assigneeAvatar, &creatorName, &creatorEmail,
- &priorityName, &priorityIcon, &priorityColor,
- &statusName,
- &itemTypeName,
+ &priorityName, &priorityBuiltinKey, &priorityIcon, &priorityColor,
+ &statusName, &statusBuiltinKey,
+ &itemTypeName, &itemTypeBuiltinKey,
&relatedWorkItemID,
&relatedWorkItemTitle,
&relatedWorkItemWorkspaceKey,
@@ -357,10 +357,13 @@ func scanItemDetailsRow(scanner rowScanner) (models.Item, bool, error) {
assignNullableString(&item.CreatorName, creatorName)
assignNullableString(&item.CreatorEmail, creatorEmail)
assignNullableString(&item.PriorityName, priorityName)
+ assignNullableString(&item.PriorityBuiltinKey, priorityBuiltinKey)
assignNullableString(&item.PriorityIcon, priorityIcon)
assignNullableString(&item.PriorityColor, priorityColor)
assignNullableString(&item.StatusName, statusName)
+ assignNullableString(&item.StatusBuiltinKey, statusBuiltinKey)
assignNullableString(&item.ItemTypeName, itemTypeName)
+ assignNullableString(&item.ItemTypeBuiltinKey, itemTypeBuiltinKey)
assignNullableInt(&item.RelatedWorkItemID, relatedWorkItemID)
assignNullableString(&item.RelatedWorkItemTitle, relatedWorkItemTitle)
diff --git a/internal/repository/item_type_repository.go b/internal/repository/item_type_repository.go
index 4d162666d..8ce85998a 100644
--- a/internal/repository/item_type_repository.go
+++ b/internal/repository/item_type_repository.go
@@ -24,13 +24,13 @@ func NewItemTypeRepository(db database.Database) *ItemTypeRepository {
}
const (
- itemTypeColumns = "id, name, description, is_default, icon, color, hierarchy_level, sort_order, created_at, updated_at"
- itemTypeColumnsIT = "it.id, it.name, it.description, it.is_default, it.icon, it.color, it.hierarchy_level, it.sort_order, it.created_at, it.updated_at"
+ itemTypeColumns = "id, COALESCE(builtin_key, ''), name, description, is_default, icon, color, hierarchy_level, sort_order, created_at, updated_at"
+ itemTypeColumnsIT = "it.id, COALESCE(it.builtin_key, ''), it.name, it.description, it.is_default, it.icon, it.color, it.hierarchy_level, it.sort_order, it.created_at, it.updated_at"
)
// scanItemType reads a full item_types row (in itemTypeColumns order) into it.
func scanItemType(row interface{ Scan(...any) error }, it *models.ItemType) error {
- return row.Scan(&it.ID, &it.Name, &it.Description, &it.IsDefault,
+ return row.Scan(&it.ID, &it.BuiltinKey, &it.Name, &it.Description, &it.IsDefault,
&it.Icon, &it.Color, &it.HierarchyLevel, &it.SortOrder, &it.CreatedAt, &it.UpdatedAt)
}
@@ -73,7 +73,7 @@ func (r *ItemTypeRepository) List(configurationSetID *int) ([]models.ItemType, e
// ListForWorkspace returns the workspace-applicable item type catalog.
func (r *ItemTypeRepository) ListForWorkspace(workspaceID int) ([]models.ItemType, error) {
rows, err := r.db.Query(`
- SELECT it.id, it.name, COALESCE(it.description, ''), COALESCE(it.icon, ''), COALESCE(it.color, ''),
+ SELECT it.id, COALESCE(it.builtin_key, ''), it.name, COALESCE(it.description, ''), COALESCE(it.icon, ''), COALESCE(it.color, ''),
it.hierarchy_level, it.sort_order, it.is_default
FROM item_types it
WHERE NOT EXISTS (
@@ -96,7 +96,7 @@ func (r *ItemTypeRepository) ListForWorkspace(workspaceID int) ([]models.ItemTyp
out := make([]models.ItemType, 0)
for rows.Next() {
var itemType models.ItemType
- if err := rows.Scan(&itemType.ID, &itemType.Name, &itemType.Description,
+ if err := rows.Scan(&itemType.ID, &itemType.BuiltinKey, &itemType.Name, &itemType.Description,
&itemType.Icon, &itemType.Color, &itemType.HierarchyLevel, &itemType.SortOrder,
&itemType.IsDefault); err != nil {
return nil, fmt.Errorf("scan workspace item type: %w", err)
@@ -288,45 +288,47 @@ func (r *ItemTypeRepository) insertConfigurationSets(itemTypeID int, ids []int,
return nil
}
-// loadConfigurationSets returns the configuration-set ids and names linked to
-// the item type, ordered by name.
-func (r *ItemTypeRepository) loadConfigurationSets(itemTypeID int) (ids []int, names []string, err error) {
+// loadConfigurationSets returns the configuration-set ids, names, and stable
+// built-in keys linked to the item type, ordered by name.
+func (r *ItemTypeRepository) loadConfigurationSets(itemTypeID int) (ids []int, names, builtinKeys []string, err error) {
rows, err := r.db.Query(`
- SELECT cs.id, cs.name
+ SELECT cs.id, cs.name, COALESCE(cs.builtin_key, '')
FROM configuration_set_item_types csit
JOIN configuration_sets cs ON csit.configuration_set_id = cs.id
WHERE csit.item_type_id = ?
ORDER BY cs.name
`, itemTypeID)
if err != nil {
- return nil, nil, fmt.Errorf("load configuration sets for item type %d: %w", itemTypeID, err)
+ return nil, nil, nil, fmt.Errorf("load configuration sets for item type %d: %w", itemTypeID, err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id int
- var name string
- if err := rows.Scan(&id, &name); err != nil {
- return nil, nil, fmt.Errorf("scan configuration set: %w", err)
+ var name, builtinKey string
+ if err := rows.Scan(&id, &name, &builtinKey); err != nil {
+ return nil, nil, nil, fmt.Errorf("scan configuration set: %w", err)
}
ids = append(ids, id)
names = append(names, name)
+ builtinKeys = append(builtinKeys, builtinKey)
}
if err := rows.Err(); err != nil {
- return nil, nil, fmt.Errorf("load configuration sets for item type %d: %w", itemTypeID, err)
+ return nil, nil, nil, fmt.Errorf("load configuration sets for item type %d: %w", itemTypeID, err)
}
- return ids, names, nil
+ return ids, names, builtinKeys, nil
}
// populateConfigurationSets fills it.ConfigurationSet* from the junction table,
// keeping the deprecated single-set fields in sync for backward compatibility.
func (r *ItemTypeRepository) populateConfigurationSets(it *models.ItemType) error {
- ids, names, err := r.loadConfigurationSets(it.ID)
+ ids, names, builtinKeys, err := r.loadConfigurationSets(it.ID)
if err != nil {
return err
}
it.ConfigurationSetIDs = ids
it.ConfigurationSetNames = names
+ it.ConfigurationSetBuiltinKeys = builtinKeys
if len(ids) > 0 {
it.ConfigurationSetID = ids[0]
it.ConfigurationSetName = names[0]
diff --git a/internal/repository/link_type_repository.go b/internal/repository/link_type_repository.go
index 7e69b8a1c..5795bfd37 100644
--- a/internal/repository/link_type_repository.go
+++ b/internal/repository/link_type_repository.go
@@ -95,7 +95,7 @@ func (r *LinkTypeRepository) FindNameByID(id int) (string, error) {
return name, nil
}
-const linkTypeColumns = "id, name, description, forward_label, reverse_label, color, is_system, active, allowed_entity_types, created_at, updated_at"
+const linkTypeColumns = "id, COALESCE(builtin_key, ''), name, description, forward_label, reverse_label, color, is_system, active, allowed_entity_types, created_at, updated_at"
// scanLinkType scans a *sql.Rows or *sql.Row into a models.LinkType, decoding
// the allowed_entity_types JSON column.
@@ -105,7 +105,7 @@ func scanLinkType(scanner interface {
var lt models.LinkType
var aetRaw sql.NullString
if err := scanner.Scan(
- <.ID, <.Name, <.Description, <.ForwardLabel, <.ReverseLabel,
+ <.ID, <.BuiltinKey, <.Name, <.Description, <.ForwardLabel, <.ReverseLabel,
<.Color, <.IsSystem, <.Active, &aetRaw, <.CreatedAt, <.UpdatedAt,
); err != nil {
return lt, err
diff --git a/internal/repository/notification_settings_repository.go b/internal/repository/notification_settings_repository.go
index 067fcd3a7..7eea5274c 100644
--- a/internal/repository/notification_settings_repository.go
+++ b/internal/repository/notification_settings_repository.go
@@ -23,7 +23,7 @@ func NewNotificationSettingsRepository(db database.Database) *NotificationSettin
func (r *NotificationSettingsRepository) ListAll() ([]models.NotificationSetting, error) {
rows, err := r.db.Query(`
SELECT
- ns.id, ns.name, ns.description, ns.is_active, ns.created_by, ns.created_at, ns.updated_at,
+ ns.id, COALESCE(ns.builtin_key, ''), ns.name, ns.description, ns.is_active, ns.created_by, ns.created_at, ns.updated_at,
u.first_name || ' ' || u.last_name as created_by_name
FROM notification_settings ns
LEFT JOIN users u ON ns.created_by = u.id
@@ -56,7 +56,7 @@ func (r *NotificationSettingsRepository) ListAll() ([]models.NotificationSetting
func (r *NotificationSettingsRepository) FindByID(id int) (*models.NotificationSetting, error) {
row := r.db.QueryRow(`
SELECT
- ns.id, ns.name, ns.description, ns.is_active, ns.created_by, ns.created_at, ns.updated_at,
+ ns.id, COALESCE(ns.builtin_key, ''), ns.name, ns.description, ns.is_active, ns.created_by, ns.created_at, ns.updated_at,
u.first_name || ' ' || u.last_name as created_by_name
FROM notification_settings ns
LEFT JOIN users u ON ns.created_by = u.id
@@ -160,9 +160,9 @@ func (r *NotificationSettingsRepository) EnsureDefault() error {
return database.WithTx(r.db, func(tx database.Tx) error {
var notificationSettingID int64
if err := tx.QueryRow(
- `INSERT INTO notification_settings (name, description, is_active, created_by)
- VALUES (?, ?, ?, ?) RETURNING id`,
- "Default Notifications", "Standard notification rules for work item updates", true, nil,
+ `INSERT INTO notification_settings (builtin_key, name, description, is_active, created_by)
+ VALUES (?, ?, ?, ?, ?) RETURNING id`,
+ "default", "Default Notifications", "Standard notification rules for work item updates", true, nil,
).Scan(¬ificationSettingID); err != nil {
return err
}
@@ -219,7 +219,7 @@ func scanNotificationSettingRow(s rowScanner) (models.NotificationSetting, error
var createdBy sql.NullInt64
var createdByName sql.NullString
if err := s.Scan(
- &setting.ID, &setting.Name, &setting.Description, &setting.IsActive,
+ &setting.ID, &setting.BuiltinKey, &setting.Name, &setting.Description, &setting.IsActive,
&createdBy, &setting.CreatedAt, &setting.UpdatedAt, &createdByName,
); err != nil {
return models.NotificationSetting{}, err
diff --git a/internal/repository/planning_repository.go b/internal/repository/planning_repository.go
index f1bc70545..6b1d28226 100644
--- a/internal/repository/planning_repository.go
+++ b/internal/repository/planning_repository.go
@@ -47,10 +47,11 @@ func (r *PlanningRepository) ListMilestoneNamesForItem(itemID int) ([]string, er
}
type WorkspaceMilestoneStatusBreakdown struct {
- CategoryName string
- CategoryColor string
- ItemCount int
- IsCompleted bool
+ CategoryName string
+ CategoryBuiltinKey string
+ CategoryColor string
+ ItemCount int
+ IsCompleted bool
}
type WorkspaceMilestoneProgress struct {
@@ -70,7 +71,7 @@ type WorkspaceMilestoneProgress struct {
func (r *PlanningRepository) GetWorkspaceMilestoneProgress(workspaceID int, filterSQL string, filterArgs []any) ([]WorkspaceMilestoneProgress, error) {
query := `
SELECT m.id, m.name, m.target_date, m.status, mc.color,
- sc.name, sc.color, sc.is_completed, COUNT(i.id)
+ sc.name, COALESCE(sc.builtin_key, ''), sc.color, sc.is_completed, COUNT(i.id)
` + ItemListFilterFromClause() + `
JOIN item_milestones im ON im.item_id = i.id
JOIN milestones m ON m.id = im.milestone_id
@@ -84,7 +85,7 @@ func (r *PlanningRepository) GetWorkspaceMilestoneProgress(workspaceID int, filt
}
query += `
GROUP BY m.id, m.name, m.target_date, m.status, mc.color,
- sc.name, sc.color, sc.is_completed
+ sc.name, sc.builtin_key, sc.color, sc.is_completed
ORDER BY m.target_date IS NULL, m.target_date, m.name`
rows, err := r.db.Query(query, args...)
@@ -98,10 +99,10 @@ func (r *PlanningRepository) GetWorkspaceMilestoneProgress(workspaceID int, filt
var milestoneID, itemCount int
var milestoneName string
var targetDate, milestoneStatus, milestoneColor sql.NullString
- var categoryName, categoryColor sql.NullString
+ var categoryName, categoryBuiltinKey, categoryColor sql.NullString
var categoryCompleted sql.NullBool
if err := rows.Scan(&milestoneID, &milestoneName, &targetDate, &milestoneStatus,
- &milestoneColor, &categoryName, &categoryColor, &categoryCompleted, &itemCount); err != nil {
+ &milestoneColor, &categoryName, &categoryBuiltinKey, &categoryColor, &categoryCompleted, &itemCount); err != nil {
return nil, fmt.Errorf("scan workspace milestone progress: %w", err)
}
if itemCount == 0 {
@@ -126,10 +127,11 @@ func (r *PlanningRepository) GetWorkspaceMilestoneProgress(workspaceID int, filt
label = "No Status"
}
breakdown := WorkspaceMilestoneStatusBreakdown{
- CategoryName: label,
- CategoryColor: categoryColor.String,
- ItemCount: itemCount,
- IsCompleted: categoryCompleted.Valid && categoryCompleted.Bool,
+ CategoryName: label,
+ CategoryBuiltinKey: categoryBuiltinKey.String,
+ CategoryColor: categoryColor.String,
+ ItemCount: itemCount,
+ IsCompleted: categoryCompleted.Valid && categoryCompleted.Bool,
}
progress.StatusBreakdown = append(progress.StatusBreakdown, breakdown)
progress.TotalItems += itemCount
diff --git a/internal/repository/priority_repository.go b/internal/repository/priority_repository.go
index e85081f59..cba9c593b 100644
--- a/internal/repository/priority_repository.go
+++ b/internal/repository/priority_repository.go
@@ -19,7 +19,7 @@ func NewPriorityRepository(db database.Database) *PriorityRepository {
// configuration set, falling back to the global catalog when none are mapped.
func (r *PriorityRepository) ListForWorkspace(workspaceID int) ([]models.Priority, error) {
rows, err := r.db.Query(`
- SELECT DISTINCT p.id, p.name, COALESCE(p.description, ''), COALESCE(p.icon, ''), COALESCE(p.color, ''),
+ SELECT DISTINCT p.id, COALESCE(p.builtin_key, ''), p.name, COALESCE(p.description, ''), COALESCE(p.icon, ''), COALESCE(p.color, ''),
p.sort_order, p.is_default
FROM priorities p
WHERE NOT EXISTS (
@@ -41,7 +41,7 @@ func (r *PriorityRepository) ListForWorkspace(workspaceID int) ([]models.Priorit
out := make([]models.Priority, 0)
for rows.Next() {
var priority models.Priority
- if err := rows.Scan(&priority.ID, &priority.Name, &priority.Description,
+ if err := rows.Scan(&priority.ID, &priority.BuiltinKey, &priority.Name, &priority.Description,
&priority.Icon, &priority.Color, &priority.SortOrder, &priority.IsDefault); err != nil {
return nil, fmt.Errorf("scan workspace priority: %w", err)
}
diff --git a/internal/repository/status_repository.go b/internal/repository/status_repository.go
index b7abe27aa..d8aeab398 100644
--- a/internal/repository/status_repository.go
+++ b/internal/repository/status_repository.go
@@ -45,8 +45,8 @@ func (r *StatusRepository) ListForWorkspaces(workspaceIDs []int) ([]models.Statu
SELECT wt.to_status_id AS status_id FROM effective_workflows ew
JOIN workflow_transitions wt ON wt.workflow_id = ew.workflow_id
)
- SELECT DISTINCT s.id, s.name, s.description, s.category_id, s.is_default,
- sc.name, sc.color, sc.is_completed
+ SELECT DISTINCT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default,
+ sc.name, COALESCE(sc.builtin_key, ''), sc.color, sc.is_completed
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id
WHERE s.id IN (SELECT status_id FROM available_statuses)
@@ -61,8 +61,8 @@ func (r *StatusRepository) ListForWorkspaces(workspaceIDs []int) ([]models.Statu
for rows.Next() {
var status models.Status
var description sql.NullString
- if err := rows.Scan(&status.ID, &status.Name, &description, &status.CategoryID,
- &status.IsDefault, &status.CategoryName, &status.CategoryColor, &status.IsCompleted); err != nil {
+ if err := rows.Scan(&status.ID, &status.BuiltinKey, &status.Name, &description, &status.CategoryID,
+ &status.IsDefault, &status.CategoryName, &status.CategoryBuiltinKey, &status.CategoryColor, &status.IsCompleted); err != nil {
return nil, fmt.Errorf("scan workspace status: %w", err)
}
status.Description = description.String
@@ -78,6 +78,7 @@ func (r *StatusRepository) ListForWorkspaces(workspaceIDs []int) ([]models.Statu
type StatusTransitionOption struct {
TransitionID int
StatusID int
+ BuiltinKey string
StatusName string
CategoryColor *string
}
@@ -88,8 +89,8 @@ func NewStatusRepository(db database.Database) *StatusRepository {
}
const statusJoinedSelect = `
- SELECT s.id, s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
- sc.name as category_name, sc.color as category_color, sc.is_completed
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color, sc.is_completed
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id`
@@ -205,11 +206,11 @@ func (r *StatusRepository) GetTransitionOption(statusID int64) (*StatusTransitio
var option StatusTransitionOption
var categoryColor sql.NullString
err := r.db.QueryRow(`
- SELECT s.id, s.name, sc.color
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, sc.color
FROM statuses s
LEFT JOIN status_categories sc ON s.category_id = sc.id
WHERE s.id = ?
- `, statusID).Scan(&option.StatusID, &option.StatusName, &categoryColor)
+ `, statusID).Scan(&option.StatusID, &option.BuiltinKey, &option.StatusName, &categoryColor)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -227,7 +228,7 @@ func (r *StatusRepository) GetTransitionOption(statusID int64) (*StatusTransitio
// direct edge from this status.
func (r *StatusRepository) ListAvailableTransitionOptions(workflowID int, fromStatusID int64) ([]StatusTransitionOption, error) {
rows, err := r.db.Query(`
- SELECT wt.id, s.id, s.name, sc.color
+ SELECT wt.id, s.id, COALESCE(s.builtin_key, ''), s.name, sc.color
FROM workflow_transitions wt
JOIN statuses s ON wt.to_status_id = s.id
LEFT JOIN status_categories sc ON s.category_id = sc.id
@@ -252,7 +253,7 @@ func (r *StatusRepository) ListAvailableTransitionOptions(workflowID int, fromSt
for rows.Next() {
var option StatusTransitionOption
var categoryColor sql.NullString
- if err := rows.Scan(&option.TransitionID, &option.StatusID, &option.StatusName, &categoryColor); err != nil {
+ if err := rows.Scan(&option.TransitionID, &option.StatusID, &option.BuiltinKey, &option.StatusName, &categoryColor); err != nil {
continue
}
if categoryColor.Valid {
@@ -303,9 +304,9 @@ func scanStatusJoined(scanner interface {
}) (models.Status, error) {
var s models.Status
if err := scanner.Scan(
- &s.ID, &s.Name, &s.Description, &s.CategoryID, &s.IsDefault,
+ &s.ID, &s.BuiltinKey, &s.Name, &s.Description, &s.CategoryID, &s.IsDefault,
&s.CreatedAt, &s.UpdatedAt,
- &s.CategoryName, &s.CategoryColor, &s.IsCompleted,
+ &s.CategoryName, &s.CategoryBuiltinKey, &s.CategoryColor, &s.IsCompleted,
); err != nil {
return s, err
}
diff --git a/internal/repository/theme_repository.go b/internal/repository/theme_repository.go
index 9ddd80a65..d9629865e 100644
--- a/internal/repository/theme_repository.go
+++ b/internal/repository/theme_repository.go
@@ -20,7 +20,7 @@ func NewThemeRepository(db database.Database) *ThemeRepository {
return &ThemeRepository{db: db}
}
-const themeColumns = `id, name, description, is_default, is_active,
+const themeColumns = `id, COALESCE(builtin_key, ''), name, description, is_default, is_active,
nav_background_color_light, nav_text_color_light,
nav_background_color_dark, nav_text_color_dark,
created_at, updated_at`
@@ -31,7 +31,7 @@ type themeScanner interface {
func scanTheme(s themeScanner, t *models.Theme) error {
return s.Scan(
- &t.ID, &t.Name, &t.Description,
+ &t.ID, &t.BuiltinKey, &t.Name, &t.Description,
&t.IsDefault, &t.IsActive,
&t.NavBackgroundColorLight, &t.NavTextColorLight,
&t.NavBackgroundColorDark, &t.NavTextColorDark,
diff --git a/internal/repository/workflow_repository.go b/internal/repository/workflow_repository.go
index 44e8f2f26..7cc353f73 100644
--- a/internal/repository/workflow_repository.go
+++ b/internal/repository/workflow_repository.go
@@ -37,7 +37,7 @@ func NewWorkflowRepository(db database.Database) *WorkflowRepository {
// The result is never nil.
func (r *WorkflowRepository) List() ([]models.Workflow, error) {
rows, err := r.db.Query(`
- SELECT id, name, description, is_default, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default, created_at, updated_at
FROM workflows
ORDER BY is_default DESC, name ASC`)
if err != nil {
@@ -48,7 +48,7 @@ func (r *WorkflowRepository) List() ([]models.Workflow, error) {
var workflows []models.Workflow
for rows.Next() {
var workflow models.Workflow
- if err := rows.Scan(&workflow.ID, &workflow.Name, &workflow.Description,
+ if err := rows.Scan(&workflow.ID, &workflow.BuiltinKey, &workflow.Name, &workflow.Description,
&workflow.IsDefault, &workflow.CreatedAt, &workflow.UpdatedAt); err != nil {
return nil, err
}
@@ -72,7 +72,7 @@ func (r *WorkflowRepository) ListByIDs(ids []int) ([]models.Workflow, error) {
}
placeholders, args := inPlaceholders(ids)
rows, err := r.db.Query(`
- SELECT id, name, description, is_default, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default, created_at, updated_at
FROM workflows
WHERE id IN (`+placeholders+`)
ORDER BY name
@@ -84,7 +84,7 @@ func (r *WorkflowRepository) ListByIDs(ids []int) ([]models.Workflow, error) {
out := []models.Workflow{}
for rows.Next() {
var workflow models.Workflow
- if err := rows.Scan(&workflow.ID, &workflow.Name, &workflow.Description,
+ if err := rows.Scan(&workflow.ID, &workflow.BuiltinKey, &workflow.Name, &workflow.Description,
&workflow.IsDefault, &workflow.CreatedAt, &workflow.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan workflow by id: %w", err)
}
@@ -101,10 +101,10 @@ func (r *WorkflowRepository) ListByIDs(ids []int) ([]models.Workflow, error) {
func (r *WorkflowRepository) Get(id int) (*models.Workflow, error) {
var workflow models.Workflow
err := r.db.QueryRow(`
- SELECT id, name, description, is_default, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default, created_at, updated_at
FROM workflows
WHERE id = ?
- `, id).Scan(&workflow.ID, &workflow.Name, &workflow.Description,
+ `, id).Scan(&workflow.ID, &workflow.BuiltinKey, &workflow.Name, &workflow.Description,
&workflow.IsDefault, &workflow.CreatedAt, &workflow.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
diff --git a/internal/repository/workspace_role_repository.go b/internal/repository/workspace_role_repository.go
index 58808f6e4..76839d0dd 100644
--- a/internal/repository/workspace_role_repository.go
+++ b/internal/repository/workspace_role_repository.go
@@ -23,7 +23,7 @@ func NewWorkspaceRoleRepository(db database.Database) *WorkspaceRoleRepository {
return &WorkspaceRoleRepository{db: db}
}
-const workspaceRoleSelectColumns = "id, name, description, is_system, permissions_enabled, display_order, created_at, updated_at"
+const workspaceRoleSelectColumns = "id, COALESCE(builtin_key, ''), name, description, is_system, permissions_enabled, display_order, created_at, updated_at"
// List returns all workspace roles ordered by display_order then name.
func (r *WorkspaceRoleRepository) List() ([]models.WorkspaceRole, error) {
@@ -38,7 +38,7 @@ func (r *WorkspaceRoleRepository) List() ([]models.WorkspaceRole, error) {
var roles []models.WorkspaceRole
for rows.Next() {
var role models.WorkspaceRole
- if err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.IsSystem,
+ if err := rows.Scan(&role.ID, &role.BuiltinKey, &role.Name, &role.Description, &role.IsSystem,
&role.PermissionsEnabled, &role.DisplayOrder, &role.CreatedAt, &role.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan workspace_role: %w", err)
}
@@ -59,7 +59,7 @@ func (r *WorkspaceRoleRepository) GetByID(id int) (*models.WorkspaceRole, error)
var role models.WorkspaceRole
err := r.db.QueryRow(
"SELECT "+workspaceRoleSelectColumns+" FROM workspace_roles WHERE id = ?", id,
- ).Scan(&role.ID, &role.Name, &role.Description, &role.IsSystem,
+ ).Scan(&role.ID, &role.BuiltinKey, &role.Name, &role.Description, &role.IsSystem,
&role.PermissionsEnabled, &role.DisplayOrder, &role.CreatedAt, &role.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -236,7 +236,7 @@ func (r *WorkspaceRoleRepository) RevokeFromGroup(groupID, workspaceID, roleID i
// The returned slice is always non-nil.
func (r *WorkspaceRoleRepository) ListUserRoles(userID, workspaceID int) ([]models.WorkspaceRole, error) {
rows, err := r.db.Query(`
- SELECT wr.id, wr.name, wr.description, wr.is_system, wr.display_order, wr.created_at, wr.updated_at
+ SELECT wr.id, COALESCE(wr.builtin_key, ''), wr.name, wr.description, wr.is_system, wr.display_order, wr.created_at, wr.updated_at
FROM workspace_roles wr
JOIN user_workspace_roles uwr ON wr.id = uwr.role_id
WHERE uwr.user_id = ? AND uwr.workspace_id = ?
@@ -250,7 +250,7 @@ func (r *WorkspaceRoleRepository) ListUserRoles(userID, workspaceID int) ([]mode
var roles []models.WorkspaceRole
for rows.Next() {
var role models.WorkspaceRole
- err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.IsSystem,
+ err := rows.Scan(&role.ID, &role.BuiltinKey, &role.Name, &role.Description, &role.IsSystem,
&role.DisplayOrder, &role.CreatedAt, &role.UpdatedAt)
if err == nil {
roles = append(roles, role)
@@ -275,6 +275,7 @@ type WorkspaceRoleUserAssignment struct {
LastName *string
AvatarURL *string
RoleID int
+ RoleBuiltinKey string
RoleName string
RoleDescription string
AssignmentID int
@@ -288,7 +289,7 @@ func (r *WorkspaceRoleRepository) ListUserAssignments(workspaceID int) ([]Worksp
rows, err := r.db.Query(`
SELECT
u.id, u.username, u.email, u.first_name, u.last_name, u.avatar_url,
- wr.id, wr.name, wr.description,
+ wr.id, COALESCE(wr.builtin_key, ''), wr.name, wr.description,
uwr.id, uwr.granted_at
FROM user_workspace_roles uwr
JOIN users u ON uwr.user_id = u.id
@@ -306,7 +307,7 @@ func (r *WorkspaceRoleRepository) ListUserAssignments(workspaceID int) ([]Worksp
var a WorkspaceRoleUserAssignment
err := rows.Scan(
&a.UserID, &a.Username, &a.Email, &a.FirstName, &a.LastName, &a.AvatarURL,
- &a.RoleID, &a.RoleName, &a.RoleDescription,
+ &a.RoleID, &a.RoleBuiltinKey, &a.RoleName, &a.RoleDescription,
&a.AssignmentID, &a.GrantedAt,
)
if err != nil {
@@ -327,6 +328,7 @@ type WorkspaceRoleGroupAssignment struct {
GroupName string
GroupDescription *string
RoleID int
+ RoleBuiltinKey string
RoleName string
RoleDescription string
AssignmentID int
@@ -340,7 +342,7 @@ func (r *WorkspaceRoleRepository) ListGroupAssignments(workspaceID int) ([]Works
rows, err := r.db.Query(`
SELECT
g.id, g.name, g.description,
- wr.id, wr.name, wr.description,
+ wr.id, COALESCE(wr.builtin_key, ''), wr.name, wr.description,
gwr.id, gwr.granted_at
FROM group_workspace_roles gwr
JOIN groups g ON gwr.group_id = g.id
@@ -358,7 +360,7 @@ func (r *WorkspaceRoleRepository) ListGroupAssignments(workspaceID int) ([]Works
var a WorkspaceRoleGroupAssignment
err := rows.Scan(
&a.GroupID, &a.GroupName, &a.GroupDescription,
- &a.RoleID, &a.RoleName, &a.RoleDescription,
+ &a.RoleID, &a.RoleBuiltinKey, &a.RoleName, &a.RoleDescription,
&a.AssignmentID, &a.GrantedAt,
)
if err != nil {
diff --git a/internal/restapi/v1/dto/common.go b/internal/restapi/v1/dto/common.go
index e6e32bfc4..bae81a11d 100644
--- a/internal/restapi/v1/dto/common.go
+++ b/internal/restapi/v1/dto/common.go
@@ -16,25 +16,29 @@ type UserSummary struct {
// StatusSummary provides a minimal status representation
type StatusSummary struct {
- ID int `json:"id"`
- Name string `json:"name"`
- CategoryID int `json:"category_id"`
- CategoryName string `json:"category_name,omitempty"`
- CategoryColor string `json:"category_color,omitempty"`
- IsCompleted bool `json:"is_completed,omitempty"`
+ ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
+ Name string `json:"name"`
+ CategoryID int `json:"category_id"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryName string `json:"category_name,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ IsCompleted bool `json:"is_completed,omitempty"`
}
// PrioritySummary provides a minimal priority representation
type PrioritySummary struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Icon string `json:"icon,omitempty"`
- Color string `json:"color,omitempty"`
+ ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
+ Name string `json:"name"`
+ Icon string `json:"icon,omitempty"`
+ Color string `json:"color,omitempty"`
}
// ItemTypeSummary provides a minimal item type representation
type ItemTypeSummary struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Icon string `json:"icon,omitempty"`
Color string `json:"color,omitempty"`
diff --git a/internal/restapi/v1/dto/mappers.go b/internal/restapi/v1/dto/mappers.go
index 13402f4e2..64bb69565 100644
--- a/internal/restapi/v1/dto/mappers.go
+++ b/internal/restapi/v1/dto/mappers.go
@@ -45,6 +45,7 @@ func mapItemToResponse(item *models.Item, baseURL string) *ItemResponse {
if item.StatusID != nil {
resp.Status = &StatusSummary{
ID: *item.StatusID,
+ BuiltinKey: item.StatusBuiltinKey,
Name: item.StatusName,
CategoryColor: "", // Would need to be populated from expanded data
}
@@ -53,18 +54,20 @@ func mapItemToResponse(item *models.Item, baseURL string) *ItemResponse {
// Map priority
if item.PriorityID != nil {
resp.Priority = &PrioritySummary{
- ID: *item.PriorityID,
- Name: item.PriorityName,
- Icon: item.PriorityIcon,
- Color: item.PriorityColor,
+ ID: *item.PriorityID,
+ BuiltinKey: item.PriorityBuiltinKey,
+ Name: item.PriorityName,
+ Icon: item.PriorityIcon,
+ Color: item.PriorityColor,
}
}
// Map item type
if item.ItemTypeID != nil {
resp.ItemType = &ItemTypeSummary{
- ID: *item.ItemTypeID,
- Name: item.ItemTypeName,
+ ID: *item.ItemTypeID,
+ BuiltinKey: item.ItemTypeBuiltinKey,
+ Name: item.ItemTypeName,
}
}
@@ -284,14 +287,18 @@ func MapServiceTransitionsToResponse(transitions []services.WorkflowTransitionRe
if t.FromStatusID != nil {
resp.FromStatus = &StatusSummary{
- ID: *t.FromStatusID,
- Name: t.FromStatusName,
+ ID: *t.FromStatusID,
+ BuiltinKey: t.FromStatusBuiltinKey,
+ Name: t.FromStatusName,
+ CategoryBuiltinKey: t.FromCategoryBuiltinKey,
}
}
resp.ToStatus = &StatusSummary{
- ID: t.ToStatusID,
- Name: t.ToStatusName,
+ ID: t.ToStatusID,
+ BuiltinKey: t.ToStatusBuiltinKey,
+ Name: t.ToStatusName,
+ CategoryBuiltinKey: t.ToCategoryBuiltinKey,
}
result[i] = resp
diff --git a/internal/restapi/v1/handlers/config.go b/internal/restapi/v1/handlers/config.go
index 1044f05c6..57c92a4a4 100644
--- a/internal/restapi/v1/handlers/config.go
+++ b/internal/restapi/v1/handlers/config.go
@@ -26,6 +26,7 @@ func NewItemTypeHandler(db database.Database, permissionService *services.Permis
type ItemTypeResponse struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
@@ -62,6 +63,7 @@ func (h *ItemTypeHandler) List(w http.ResponseWriter, r *http.Request) {
for _, t := range results {
types = append(types, ItemTypeResponse{
ID: t.ID,
+ BuiltinKey: t.BuiltinKey,
Name: t.Name,
Description: t.Description,
Icon: t.Icon,
@@ -112,6 +114,7 @@ func (h *ItemTypeHandler) Get(w http.ResponseWriter, r *http.Request) {
h.RespondOK(w, ItemTypeResponse{
ID: t.ID,
+ BuiltinKey: t.BuiltinKey,
Name: t.Name,
Description: t.Description,
Icon: t.Icon,
@@ -140,6 +143,7 @@ func NewPriorityHandler(db database.Database, permissionService *services.Permis
type PriorityResponse struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
@@ -175,6 +179,7 @@ func (h *PriorityHandler) List(w http.ResponseWriter, r *http.Request) {
for _, p := range results {
priorities = append(priorities, PriorityResponse{
ID: p.ID,
+ BuiltinKey: p.BuiltinKey,
Name: p.Name,
Description: p.Description,
Icon: p.Icon,
@@ -224,6 +229,7 @@ func (h *PriorityHandler) Get(w http.ResponseWriter, r *http.Request) {
h.RespondOK(w, PriorityResponse{
ID: p.ID,
+ BuiltinKey: p.BuiltinKey,
Name: p.Name,
Description: p.Description,
Icon: p.Icon,
diff --git a/internal/restapi/v1/handlers/planning.go b/internal/restapi/v1/handlers/planning.go
index cd583d294..67130135a 100644
--- a/internal/restapi/v1/handlers/planning.go
+++ b/internal/restapi/v1/handlers/planning.go
@@ -550,24 +550,27 @@ type MilestoneProgressResponse struct {
}
type MilestoneStatusBreakdownResponse struct {
- CategoryName string `json:"category_name"`
- CategoryColor string `json:"category_color,omitempty"`
- ItemCount int `json:"item_count"`
- IsCompleted bool `json:"is_completed"`
+ CategoryName string `json:"category_name"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ ItemCount int `json:"item_count"`
+ IsCompleted bool `json:"is_completed"`
}
type MilestoneProgressItemResponse struct {
- ID int `json:"id"`
- Title string `json:"title"`
- WorkspaceID int `json:"workspace_id"`
- WorkspaceKey string `json:"workspace_key"`
- ItemNumber int `json:"item_number"`
- StatusName string `json:"status_name,omitempty"`
- StatusColor string `json:"status_color,omitempty"`
- PriorityName string `json:"priority_name,omitempty"`
- PriorityColor string `json:"priority_color,omitempty"`
- AssigneeName string `json:"assignee_name,omitempty"`
- AssigneeAvatar string `json:"assignee_avatar,omitempty"`
+ ID int `json:"id"`
+ Title string `json:"title"`
+ WorkspaceID int `json:"workspace_id"`
+ WorkspaceKey string `json:"workspace_key"`
+ ItemNumber int `json:"item_number"`
+ StatusName string `json:"status_name,omitempty"`
+ StatusBuiltinKey string `json:"status_builtin_key,omitempty"`
+ StatusColor string `json:"status_color,omitempty"`
+ PriorityName string `json:"priority_name,omitempty"`
+ PriorityBuiltinKey string `json:"priority_builtin_key,omitempty"`
+ PriorityColor string `json:"priority_color,omitempty"`
+ AssigneeName string `json:"assignee_name,omitempty"`
+ AssigneeAvatar string `json:"assignee_avatar,omitempty"`
}
func toMilestoneProgressResponse(r *services.MilestoneProgressReport) MilestoneProgressResponse {
@@ -585,10 +588,11 @@ func toMilestoneProgressResponse(r *services.MilestoneProgressReport) MilestoneP
resp.StatusBreakdown = make([]MilestoneStatusBreakdownResponse, 0, len(r.StatusBreakdown))
for _, sb := range r.StatusBreakdown {
resp.StatusBreakdown = append(resp.StatusBreakdown, MilestoneStatusBreakdownResponse{
- CategoryName: sb.CategoryName,
- CategoryColor: sb.CategoryColor,
- ItemCount: sb.ItemCount,
- IsCompleted: sb.IsCompleted,
+ CategoryName: sb.CategoryName,
+ CategoryBuiltinKey: sb.CategoryBuiltinKey,
+ CategoryColor: sb.CategoryColor,
+ ItemCount: sb.ItemCount,
+ IsCompleted: sb.IsCompleted,
})
}
if len(r.ItemsByCategory) > 0 {
@@ -597,17 +601,19 @@ func toMilestoneProgressResponse(r *services.MilestoneProgressReport) MilestoneP
converted := make([]MilestoneProgressItemResponse, 0, len(items))
for _, it := range items {
converted = append(converted, MilestoneProgressItemResponse{
- ID: it.ID,
- Title: it.Title,
- WorkspaceID: it.WorkspaceID,
- WorkspaceKey: it.WorkspaceKey,
- ItemNumber: it.ItemNumber,
- StatusName: it.StatusName,
- StatusColor: it.StatusColor,
- PriorityName: it.PriorityName,
- PriorityColor: it.PriorityColor,
- AssigneeName: it.AssigneeName,
- AssigneeAvatar: it.AssigneeAvatar,
+ ID: it.ID,
+ Title: it.Title,
+ WorkspaceID: it.WorkspaceID,
+ WorkspaceKey: it.WorkspaceKey,
+ ItemNumber: it.ItemNumber,
+ StatusName: it.StatusName,
+ StatusBuiltinKey: it.StatusBuiltinKey,
+ StatusColor: it.StatusColor,
+ PriorityName: it.PriorityName,
+ PriorityBuiltinKey: it.PriorityBuiltinKey,
+ PriorityColor: it.PriorityColor,
+ AssigneeName: it.AssigneeName,
+ AssigneeAvatar: it.AssigneeAvatar,
})
}
resp.ItemsByCategory[category] = converted
diff --git a/internal/restapi/v1/handlers/statuses.go b/internal/restapi/v1/handlers/statuses.go
index a60e72b37..742dc6991 100644
--- a/internal/restapi/v1/handlers/statuses.go
+++ b/internal/restapi/v1/handlers/statuses.go
@@ -23,19 +23,22 @@ func NewStatusHandler(db database.Database, permissionService *services.Permissi
// StatusResponse is the public API representation of a Status
type StatusResponse struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Description string `json:"description,omitempty"`
- CategoryID int `json:"category_id"`
- CategoryName string `json:"category_name,omitempty"`
- CategoryColor string `json:"category_color,omitempty"`
- IsDefault bool `json:"is_default"`
- IsCompleted bool `json:"is_completed"`
+ ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ CategoryID int `json:"category_id"`
+ CategoryName string `json:"category_name,omitempty"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ IsDefault bool `json:"is_default"`
+ IsCompleted bool `json:"is_completed"`
}
// StatusCategoryResponse is the public API representation of a StatusCategory
type StatusCategoryResponse struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description,omitempty"`
@@ -70,14 +73,16 @@ func (h *StatusHandler) List(w http.ResponseWriter, r *http.Request) {
var statuses []StatusResponse
for _, s := range results {
statuses = append(statuses, StatusResponse{
- ID: s.ID,
- Name: s.Name,
- Description: s.Description,
- CategoryID: s.CategoryID,
- CategoryName: s.CategoryName,
- CategoryColor: s.CategoryColor,
- IsDefault: s.IsDefault,
- IsCompleted: s.IsCompleted,
+ ID: s.ID,
+ BuiltinKey: s.BuiltinKey,
+ Name: s.Name,
+ Description: s.Description,
+ CategoryID: s.CategoryID,
+ CategoryName: s.CategoryName,
+ CategoryBuiltinKey: s.CategoryBuiltinKey,
+ CategoryColor: s.CategoryColor,
+ IsDefault: s.IsDefault,
+ IsCompleted: s.IsCompleted,
})
}
@@ -120,14 +125,16 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
}
h.RespondOK(w, StatusResponse{
- ID: s.ID,
- Name: s.Name,
- Description: s.Description,
- CategoryID: s.CategoryID,
- CategoryName: s.CategoryName,
- CategoryColor: s.CategoryColor,
- IsDefault: s.IsDefault,
- IsCompleted: s.IsCompleted,
+ ID: s.ID,
+ BuiltinKey: s.BuiltinKey,
+ Name: s.Name,
+ Description: s.Description,
+ CategoryID: s.CategoryID,
+ CategoryName: s.CategoryName,
+ CategoryBuiltinKey: s.CategoryBuiltinKey,
+ CategoryColor: s.CategoryColor,
+ IsDefault: s.IsDefault,
+ IsCompleted: s.IsCompleted,
})
}
@@ -159,6 +166,7 @@ func (h *StatusHandler) ListCategories(w http.ResponseWriter, r *http.Request) {
for _, c := range results {
categories = append(categories, StatusCategoryResponse{
ID: c.ID,
+ BuiltinKey: c.BuiltinKey,
Name: c.Name,
Color: c.Color,
Description: c.Description,
@@ -207,6 +215,7 @@ func (h *StatusHandler) GetCategory(w http.ResponseWriter, r *http.Request) {
h.RespondOK(w, StatusCategoryResponse{
ID: c.ID,
+ BuiltinKey: c.BuiltinKey,
Name: c.Name,
Color: c.Color,
Description: c.Description,
diff --git a/internal/restapi/v1/handlers/workflows.go b/internal/restapi/v1/handlers/workflows.go
index c9756c88c..8e76b38f2 100644
--- a/internal/restapi/v1/handlers/workflows.go
+++ b/internal/restapi/v1/handlers/workflows.go
@@ -26,6 +26,7 @@ func NewWorkflowHandler(db database.Database, permissionService *services.Permis
// WorkflowResponse is the public API representation of a Workflow
type WorkflowResponse struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
IsDefault bool `json:"is_default"`
@@ -61,6 +62,7 @@ func (h *WorkflowHandler) List(w http.ResponseWriter, r *http.Request) {
for _, wf := range results {
workflows = append(workflows, WorkflowResponse{
ID: wf.ID,
+ BuiltinKey: wf.BuiltinKey,
Name: wf.Name,
Description: wf.Description,
IsDefault: wf.IsDefault,
@@ -111,6 +113,7 @@ func (h *WorkflowHandler) Get(w http.ResponseWriter, r *http.Request) {
wf := WorkflowResponse{
ID: wfResult.ID,
+ BuiltinKey: wfResult.BuiltinKey,
Name: wfResult.Name,
Description: wfResult.Description,
IsDefault: wfResult.IsDefault,
@@ -183,20 +186,24 @@ func (h *WorkflowHandler) getWorkflowTransitions(workflowID int) ([]dto.Transiti
FromAllStatuses: t.FromAllStatuses,
ToStatusID: t.ToStatusID,
ToStatus: &dto.StatusSummary{
- ID: t.ToStatusID,
- Name: t.ToStatusName,
- CategoryName: t.ToCategoryName,
- CategoryColor: t.ToCategoryColor,
+ ID: t.ToStatusID,
+ BuiltinKey: t.ToStatusBuiltinKey,
+ Name: t.ToStatusName,
+ CategoryBuiltinKey: t.ToCategoryBuiltinKey,
+ CategoryName: t.ToCategoryName,
+ CategoryColor: t.ToCategoryColor,
},
}
if t.FromStatusID != nil {
tr.FromStatusID = t.FromStatusID
tr.FromStatus = &dto.StatusSummary{
- ID: *t.FromStatusID,
- Name: t.FromStatusName,
- CategoryName: t.FromCategoryName,
- CategoryColor: t.FromCategoryColor,
+ ID: *t.FromStatusID,
+ BuiltinKey: t.FromStatusBuiltinKey,
+ Name: t.FromStatusName,
+ CategoryBuiltinKey: t.FromCategoryBuiltinKey,
+ CategoryName: t.FromCategoryName,
+ CategoryColor: t.FromCategoryColor,
}
}
diff --git a/internal/restapi/v1/handlers/workspaces.go b/internal/restapi/v1/handlers/workspaces.go
index edf297a43..e0c17f3be 100644
--- a/internal/restapi/v1/handlers/workspaces.go
+++ b/internal/restapi/v1/handlers/workspaces.go
@@ -651,6 +651,7 @@ func (h *WorkspaceHandler) GetWorkflows(w http.ResponseWriter, r *http.Request)
for _, wf := range results {
workflows = append(workflows, WorkflowResponse{
ID: wf.ID,
+ BuiltinKey: wf.BuiltinKey,
Name: wf.Name,
Description: wf.Description,
IsDefault: wf.IsDefault,
diff --git a/internal/services/condition_service.go b/internal/services/condition_service.go
index 3cde83a65..5ce7fbd23 100644
--- a/internal/services/condition_service.go
+++ b/internal/services/condition_service.go
@@ -155,6 +155,7 @@ func (s *ConditionService) FilterTransitionsByConditions(ctx context.Context, co
type TransitionWithID struct {
TransitionID int
StatusID int
+ BuiltinKey string
StatusName string
CategoryColor string
}
diff --git a/internal/services/config_read_service.go b/internal/services/config_read_service.go
index af391618e..14bd9892c 100644
--- a/internal/services/config_read_service.go
+++ b/internal/services/config_read_service.go
@@ -26,6 +26,7 @@ func NewConfigReadService(db database.Database) *ConfigReadService {
// ItemTypeResult represents an item type for API responses.
type ItemTypeResult struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
@@ -36,7 +37,7 @@ type ItemTypeResult struct {
}
// ScanItemTypes scans rows from an item_types query into a slice of ItemTypeResult.
-// The rows must select: id, name, description, icon, color, hierarchy_level, sort_order, is_default.
+// The rows must select: id, builtin_key, name, description, icon, color, hierarchy_level, sort_order, is_default.
func ScanItemTypes(rows *sql.Rows) ([]ItemTypeResult, error) {
defer rows.Close()
@@ -44,7 +45,7 @@ func ScanItemTypes(rows *sql.Rows) ([]ItemTypeResult, error) {
for rows.Next() {
var t ItemTypeResult
var description, icon, color sql.NullString
- err := rows.Scan(&t.ID, &t.Name, &description, &icon, &color, &t.HierarchyLevel, &t.SortOrder, &t.IsDefault)
+ err := rows.Scan(&t.ID, &t.BuiltinKey, &t.Name, &description, &icon, &color, &t.HierarchyLevel, &t.SortOrder, &t.IsDefault)
if err != nil {
continue
}
@@ -64,7 +65,7 @@ func ScanItemTypes(rows *sql.Rows) ([]ItemTypeResult, error) {
// ListItemTypes retrieves all item types ordered by hierarchy and sort order.
func (s *ConfigReadService) ListItemTypes() ([]ItemTypeResult, error) {
rows, err := s.db.Query(`
- SELECT id, name, description, icon, color, hierarchy_level, sort_order, is_default
+ SELECT id, COALESCE(builtin_key, ''), name, description, icon, color, hierarchy_level, sort_order, is_default
FROM item_types
ORDER BY CASE WHEN hierarchy_level = -1 THEN 1 ELSE 0 END, hierarchy_level, sort_order, name
`)
@@ -80,9 +81,9 @@ func (s *ConfigReadService) GetItemType(id int) (*ItemTypeResult, error) {
var t ItemTypeResult
var description, icon, color sql.NullString
err := s.db.QueryRow(`
- SELECT id, name, description, icon, color, hierarchy_level, sort_order, is_default
+ SELECT id, COALESCE(builtin_key, ''), name, description, icon, color, hierarchy_level, sort_order, is_default
FROM item_types WHERE id = ?
- `, id).Scan(&t.ID, &t.Name, &description, &icon, &color, &t.HierarchyLevel, &t.SortOrder, &t.IsDefault)
+ `, id).Scan(&t.ID, &t.BuiltinKey, &t.Name, &description, &icon, &color, &t.HierarchyLevel, &t.SortOrder, &t.IsDefault)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("item type not found: %d", id)
@@ -104,6 +105,7 @@ func (s *ConfigReadService) GetItemType(id int) (*ItemTypeResult, error) {
// PriorityResult represents a priority for API responses.
type PriorityResult struct {
ID int
+ BuiltinKey string
Name string
Description string
Icon string
@@ -113,7 +115,7 @@ type PriorityResult struct {
}
// ScanPriorities scans rows from a priorities query into a slice of PriorityResult.
-// The rows must select: id, name, description, icon, color, sort_order, is_default.
+// The rows must select: id, builtin_key, name, description, icon, color, sort_order, is_default.
func ScanPriorities(rows *sql.Rows) ([]PriorityResult, error) {
defer rows.Close()
@@ -121,7 +123,7 @@ func ScanPriorities(rows *sql.Rows) ([]PriorityResult, error) {
for rows.Next() {
var p PriorityResult
var description, icon, color sql.NullString
- err := rows.Scan(&p.ID, &p.Name, &description, &icon, &color, &p.SortOrder, &p.IsDefault)
+ err := rows.Scan(&p.ID, &p.BuiltinKey, &p.Name, &description, &icon, &color, &p.SortOrder, &p.IsDefault)
if err != nil {
continue
}
@@ -144,7 +146,7 @@ func ScanPriorities(rows *sql.Rows) ([]PriorityResult, error) {
// ListPriorities retrieves all priorities ordered by sort order and name.
func (s *ConfigReadService) ListPriorities() ([]PriorityResult, error) {
rows, err := s.db.Query(`
- SELECT id, name, description, icon, color, sort_order, is_default
+ SELECT id, COALESCE(builtin_key, ''), name, description, icon, color, sort_order, is_default
FROM priorities
ORDER BY sort_order, name
`)
@@ -160,9 +162,9 @@ func (s *ConfigReadService) GetPriority(id int) (*PriorityResult, error) {
var p PriorityResult
var description, icon, color sql.NullString
err := s.db.QueryRow(`
- SELECT id, name, description, icon, color, sort_order, is_default
+ SELECT id, COALESCE(builtin_key, ''), name, description, icon, color, sort_order, is_default
FROM priorities WHERE id = ?
- `, id).Scan(&p.ID, &p.Name, &description, &icon, &color, &p.SortOrder, &p.IsDefault)
+ `, id).Scan(&p.ID, &p.BuiltinKey, &p.Name, &description, &icon, &color, &p.SortOrder, &p.IsDefault)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("priority not found: %d", id)
diff --git a/internal/services/enum_configs.go b/internal/services/enum_configs.go
index befbf4775..c7805d6ea 100644
--- a/internal/services/enum_configs.go
+++ b/internal/services/enum_configs.go
@@ -18,19 +18,19 @@ func NewStatusCategoryConfig() EnumConfig {
return EnumConfig{
TableName: "status_categories",
EntityName: "Status category",
- SelectColumns: "id, name, color, description, is_default, is_completed, created_at, updated_at",
+ SelectColumns: "id, COALESCE(builtin_key, ''), name, color, description, is_default, is_completed, created_at, updated_at",
DefaultOrderBy: "is_default DESC, name ASC",
ScanRow: func(rows *sql.Rows) (EnumEntity, error) {
var c models.StatusCategory
- err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.Description,
+ err := rows.Scan(&c.ID, &c.BuiltinKey, &c.Name, &c.Color, &c.Description,
&c.IsDefault, &c.IsCompleted, &c.CreatedAt, &c.UpdatedAt)
return &c, err
},
ScanSingleRow: func(row *sql.Row) (EnumEntity, error) {
var c models.StatusCategory
- err := row.Scan(&c.ID, &c.Name, &c.Color, &c.Description,
+ err := row.Scan(&c.ID, &c.BuiltinKey, &c.Name, &c.Color, &c.Description,
&c.IsDefault, &c.IsCompleted, &c.CreatedAt, &c.UpdatedAt)
return &c, err
},
@@ -458,18 +458,18 @@ func NewHierarchyLevelConfig() EnumConfig {
return EnumConfig{
TableName: "hierarchy_levels",
EntityName: "Hierarchy level",
- SelectColumns: "id, level, name, description, created_at, updated_at",
+ SelectColumns: "id, COALESCE(builtin_key, ''), level, name, description, created_at, updated_at",
DefaultOrderBy: "level ASC",
ScanRow: func(rows *sql.Rows) (EnumEntity, error) {
var h models.HierarchyLevel
- err := rows.Scan(&h.ID, &h.Level, &h.Name, &h.Description, &h.CreatedAt, &h.UpdatedAt)
+ err := rows.Scan(&h.ID, &h.BuiltinKey, &h.Level, &h.Name, &h.Description, &h.CreatedAt, &h.UpdatedAt)
return &h, err
},
ScanSingleRow: func(row *sql.Row) (EnumEntity, error) {
var h models.HierarchyLevel
- err := row.Scan(&h.ID, &h.Level, &h.Name, &h.Description, &h.CreatedAt, &h.UpdatedAt)
+ err := row.Scan(&h.ID, &h.BuiltinKey, &h.Level, &h.Name, &h.Description, &h.CreatedAt, &h.UpdatedAt)
return &h, err
},
@@ -608,17 +608,17 @@ func NewStatusConfig() EnumConfig {
return EnumConfig{
TableName: "statuses",
EntityName: "Status",
- SelectColumns: `s.id, s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
- sc.name as category_name, sc.color as category_color`,
+ SelectColumns: `s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color`,
SelectQuery: `
- SELECT s.id, s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
- sc.name as category_name, sc.color as category_color
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id
ORDER BY s.is_default DESC, sc.name ASC, s.name ASC`,
GetByIDQuery: `
- SELECT s.id, s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
- sc.name as category_name, sc.color as category_color
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id
WHERE s.id = ?`,
@@ -626,17 +626,17 @@ func NewStatusConfig() EnumConfig {
ScanRow: func(rows *sql.Rows) (EnumEntity, error) {
var s models.Status
- err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.CategoryID,
+ err := rows.Scan(&s.ID, &s.BuiltinKey, &s.Name, &s.Description, &s.CategoryID,
&s.IsDefault, &s.CreatedAt, &s.UpdatedAt,
- &s.CategoryName, &s.CategoryColor)
+ &s.CategoryName, &s.CategoryBuiltinKey, &s.CategoryColor)
return &s, err
},
ScanSingleRow: func(row *sql.Row) (EnumEntity, error) {
var s models.Status
- err := row.Scan(&s.ID, &s.Name, &s.Description, &s.CategoryID,
+ err := row.Scan(&s.ID, &s.BuiltinKey, &s.Name, &s.Description, &s.CategoryID,
&s.IsDefault, &s.CreatedAt, &s.UpdatedAt,
- &s.CategoryName, &s.CategoryColor)
+ &s.CategoryName, &s.CategoryBuiltinKey, &s.CategoryColor)
return &s, err
},
@@ -727,19 +727,19 @@ func NewLinkTypeConfig() EnumConfig {
return EnumConfig{
TableName: "link_types",
EntityName: "Link type",
- SelectColumns: "id, name, description, forward_label, reverse_label, color, is_system, active, created_at, updated_at",
+ SelectColumns: "id, COALESCE(builtin_key, ''), name, description, forward_label, reverse_label, color, is_system, active, created_at, updated_at",
DefaultOrderBy: "is_system DESC, name ASC",
ScanRow: func(rows *sql.Rows) (EnumEntity, error) {
var l models.LinkType
- err := rows.Scan(&l.ID, &l.Name, &l.Description, &l.ForwardLabel, &l.ReverseLabel,
+ err := rows.Scan(&l.ID, &l.BuiltinKey, &l.Name, &l.Description, &l.ForwardLabel, &l.ReverseLabel,
&l.Color, &l.IsSystem, &l.Active, &l.CreatedAt, &l.UpdatedAt)
return &l, err
},
ScanSingleRow: func(row *sql.Row) (EnumEntity, error) {
var l models.LinkType
- err := row.Scan(&l.ID, &l.Name, &l.Description, &l.ForwardLabel, &l.ReverseLabel,
+ err := row.Scan(&l.ID, &l.BuiltinKey, &l.Name, &l.Description, &l.ForwardLabel, &l.ReverseLabel,
&l.Color, &l.IsSystem, &l.Active, &l.CreatedAt, &l.UpdatedAt)
return &l, err
},
@@ -830,13 +830,13 @@ func NewItemTypeConfig() EnumConfig {
return EnumConfig{
TableName: "item_types",
EntityName: "Item type",
- SelectColumns: "id, name, description, is_default, icon, color, hierarchy_level, sort_order, created_at, updated_at",
+ SelectColumns: "id, COALESCE(builtin_key, ''), name, description, is_default, icon, color, hierarchy_level, sort_order, created_at, updated_at",
DefaultOrderBy: "hierarchy_level ASC, sort_order ASC, name ASC",
ScanRow: func(rows *sql.Rows) (EnumEntity, error) {
var it models.ItemType
var description sql.NullString
- err := rows.Scan(&it.ID, &it.Name, &description, &it.IsDefault,
+ err := rows.Scan(&it.ID, &it.BuiltinKey, &it.Name, &description, &it.IsDefault,
&it.Icon, &it.Color, &it.HierarchyLevel, &it.SortOrder,
&it.CreatedAt, &it.UpdatedAt)
if description.Valid {
@@ -848,7 +848,7 @@ func NewItemTypeConfig() EnumConfig {
ScanSingleRow: func(row *sql.Row) (EnumEntity, error) {
var it models.ItemType
var description sql.NullString
- err := row.Scan(&it.ID, &it.Name, &description, &it.IsDefault,
+ err := row.Scan(&it.ID, &it.BuiltinKey, &it.Name, &description, &it.IsDefault,
&it.Icon, &it.Color, &it.HierarchyLevel, &it.SortOrder,
&it.CreatedAt, &it.UpdatedAt)
if description.Valid {
diff --git a/internal/services/planning_progress.go b/internal/services/planning_progress.go
index 3cfa59327..a8d3129bf 100644
--- a/internal/services/planning_progress.go
+++ b/internal/services/planning_progress.go
@@ -13,26 +13,29 @@ import (
// StatusBreakdown represents item counts per status category in a progress report.
// Used by both milestones and iterations.
type StatusBreakdown struct {
- CategoryName string `json:"category_name"`
- CategoryColor string `json:"category_color,omitempty"`
- ItemCount int `json:"item_count"`
- IsCompleted bool `json:"is_completed"`
+ CategoryName string `json:"category_name"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ ItemCount int `json:"item_count"`
+ IsCompleted bool `json:"is_completed"`
}
// ProgressItem represents a work item in a progress report.
// Used by both milestones and iterations.
type ProgressItem struct {
- ID int `json:"id"`
- Title string `json:"title"`
- WorkspaceID int `json:"workspace_id"`
- WorkspaceKey string `json:"workspace_key"`
- ItemNumber int `json:"item_number"`
- StatusName string `json:"status_name,omitempty"`
- StatusColor string `json:"status_color,omitempty"`
- PriorityName string `json:"priority_name,omitempty"`
- PriorityColor string `json:"priority_color,omitempty"`
- AssigneeName string `json:"assignee_name,omitempty"`
- AssigneeAvatar string `json:"assignee_avatar,omitempty"`
+ ID int `json:"id"`
+ Title string `json:"title"`
+ WorkspaceID int `json:"workspace_id"`
+ WorkspaceKey string `json:"workspace_key"`
+ ItemNumber int `json:"item_number"`
+ StatusName string `json:"status_name,omitempty"`
+ StatusBuiltinKey string `json:"status_builtin_key,omitempty"`
+ StatusColor string `json:"status_color,omitempty"`
+ PriorityName string `json:"priority_name,omitempty"`
+ PriorityBuiltinKey string `json:"priority_builtin_key,omitempty"`
+ PriorityColor string `json:"priority_color,omitempty"`
+ AssigneeName string `json:"assignee_name,omitempty"`
+ AssigneeAvatar string `json:"assignee_avatar,omitempty"`
}
// progressAccumulator collects items and computes progress stats.
@@ -100,12 +103,14 @@ func (s *PlanningService) buildProgressReport(filters repository.ItemFilters, wo
for _, item := range items {
categoryName := "No Status"
+ categoryBuiltinKey := ""
categoryColor := "#9ca3af"
statusColor := ""
isCompleted := false
if item.StatusID != nil {
if st, ok := statusByID[*item.StatusID]; ok {
categoryName = st.CategoryName
+ categoryBuiltinKey = st.CategoryBuiltinKey
categoryColor = st.CategoryColor
statusColor = st.CategoryColor
isCompleted = st.IsCompleted
@@ -114,25 +119,28 @@ func (s *PlanningService) buildProgressReport(filters repository.ItemFilters, wo
if _, exists := breakdownMap[categoryName]; !exists {
breakdownMap[categoryName] = &StatusBreakdown{
- CategoryName: categoryName,
- CategoryColor: categoryColor,
- IsCompleted: isCompleted,
+ CategoryName: categoryName,
+ CategoryBuiltinKey: categoryBuiltinKey,
+ CategoryColor: categoryColor,
+ IsCompleted: isCompleted,
}
}
breakdownMap[categoryName].ItemCount++
acc.ItemsByCategory[categoryName] = append(acc.ItemsByCategory[categoryName], ProgressItem{
- ID: item.ID,
- Title: item.Title,
- WorkspaceID: item.WorkspaceID,
- WorkspaceKey: item.WorkspaceKey,
- ItemNumber: item.WorkspaceItemNumber,
- StatusName: item.StatusName,
- StatusColor: statusColor,
- PriorityName: item.PriorityName,
- PriorityColor: item.PriorityColor,
- AssigneeName: item.AssigneeName,
- AssigneeAvatar: item.AssigneeAvatar,
+ ID: item.ID,
+ Title: item.Title,
+ WorkspaceID: item.WorkspaceID,
+ WorkspaceKey: item.WorkspaceKey,
+ ItemNumber: item.WorkspaceItemNumber,
+ StatusName: item.StatusName,
+ StatusBuiltinKey: item.StatusBuiltinKey,
+ StatusColor: statusColor,
+ PriorityName: item.PriorityName,
+ PriorityBuiltinKey: item.PriorityBuiltinKey,
+ PriorityColor: item.PriorityColor,
+ AssigneeName: item.AssigneeName,
+ AssigneeAvatar: item.AssigneeAvatar,
})
acc.TotalItems++
if isCompleted {
diff --git a/internal/services/status_service.go b/internal/services/status_service.go
index 759c59960..319ea006d 100644
--- a/internal/services/status_service.go
+++ b/internal/services/status_service.go
@@ -21,23 +21,25 @@ func NewStatusService(db database.Database) *StatusService {
// StatusResult represents a status with category details.
type StatusResult struct {
- ID int
- Name string
- Description string
- CategoryID int
- CategoryName string
- CategoryColor string
- IsDefault bool
- IsCompleted bool
- CreatedAt time.Time
- UpdatedAt time.Time
+ ID int
+ BuiltinKey string
+ Name string
+ Description string
+ CategoryID int
+ CategoryName string
+ CategoryBuiltinKey string
+ CategoryColor string
+ IsDefault bool
+ IsCompleted bool
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// ListStatuses retrieves all statuses with their category details.
func (s *StatusService) ListStatuses() ([]StatusResult, error) {
rows, err := s.db.Query(`
- SELECT s.id, s.name, s.description, s.category_id, s.is_default,
- sc.name as category_name, sc.color as category_color, sc.is_completed,
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color, sc.is_completed,
s.created_at, s.updated_at
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id
@@ -52,8 +54,8 @@ func (s *StatusService) ListStatuses() ([]StatusResult, error) {
for rows.Next() {
var s StatusResult
var description sql.NullString
- err := rows.Scan(&s.ID, &s.Name, &description, &s.CategoryID, &s.IsDefault,
- &s.CategoryName, &s.CategoryColor, &s.IsCompleted,
+ err := rows.Scan(&s.ID, &s.BuiltinKey, &s.Name, &description, &s.CategoryID, &s.IsDefault,
+ &s.CategoryName, &s.CategoryBuiltinKey, &s.CategoryColor, &s.IsCompleted,
&s.CreatedAt, &s.UpdatedAt)
if err != nil {
continue
@@ -77,14 +79,14 @@ func (s *StatusService) GetStatus(id int) (*StatusResult, error) {
var status StatusResult
var description sql.NullString
err := s.db.QueryRow(`
- SELECT s.id, s.name, s.description, s.category_id, s.is_default,
- sc.name as category_name, sc.color as category_color, sc.is_completed,
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color, sc.is_completed,
s.created_at, s.updated_at
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id
WHERE s.id = ?
- `, id).Scan(&status.ID, &status.Name, &description, &status.CategoryID, &status.IsDefault,
- &status.CategoryName, &status.CategoryColor, &status.IsCompleted,
+ `, id).Scan(&status.ID, &status.BuiltinKey, &status.Name, &description, &status.CategoryID, &status.IsDefault,
+ &status.CategoryName, &status.CategoryBuiltinKey, &status.CategoryColor, &status.IsCompleted,
&status.CreatedAt, &status.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
@@ -107,8 +109,8 @@ func (s *StatusService) GetStatus(id int) (*StatusResult, error) {
// terminal" as a fallback get a deterministic choice.
func (s *StatusService) GetTerminalStatuses(workflowID int) ([]StatusResult, error) {
rows, err := s.db.Query(`
- SELECT s.id, s.name, s.description, s.category_id, s.is_default,
- sc.name as category_name, sc.color as category_color, sc.is_completed,
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color, sc.is_completed,
s.created_at, s.updated_at
FROM statuses s
JOIN status_categories sc ON s.category_id = sc.id
@@ -130,8 +132,8 @@ func (s *StatusService) GetTerminalStatuses(workflowID int) ([]StatusResult, err
for rows.Next() {
var st StatusResult
var description sql.NullString
- if err := rows.Scan(&st.ID, &st.Name, &description, &st.CategoryID, &st.IsDefault,
- &st.CategoryName, &st.CategoryColor, &st.IsCompleted,
+ if err := rows.Scan(&st.ID, &st.BuiltinKey, &st.Name, &description, &st.CategoryID, &st.IsDefault,
+ &st.CategoryName, &st.CategoryBuiltinKey, &st.CategoryColor, &st.IsCompleted,
&st.CreatedAt, &st.UpdatedAt); err != nil {
continue
}
@@ -150,8 +152,8 @@ func (s *StatusService) GetTerminalStatuses(workflowID int) ([]StatusResult, err
// ListWorkflowStatuses returns statuses that participate in a workflow.
func (s *StatusService) ListWorkflowStatuses(workflowID int) ([]StatusResult, error) {
rows, err := s.db.Query(`
- SELECT DISTINCT s.id, s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
- sc.name as category_name, sc.color as category_color, sc.is_completed
+ SELECT DISTINCT s.id, COALESCE(s.builtin_key, ''), s.name, s.description, s.category_id, s.is_default, s.created_at, s.updated_at,
+ sc.name as category_name, COALESCE(sc.builtin_key, ''), sc.color as category_color, sc.is_completed
FROM workflow_transitions wt
JOIN statuses s ON s.id = wt.to_status_id OR (wt.from_status_id IS NOT NULL AND s.id = wt.from_status_id)
LEFT JOIN status_categories sc ON s.category_id = sc.id
@@ -169,9 +171,9 @@ func (s *StatusService) ListWorkflowStatuses(workflowID int) ([]StatusResult, er
var categoryName, categoryColor sql.NullString
var isCompleted sql.NullBool
if err := rows.Scan(
- &st.ID, &st.Name, &st.Description, &st.CategoryID,
+ &st.ID, &st.BuiltinKey, &st.Name, &st.Description, &st.CategoryID,
&st.IsDefault, &st.CreatedAt, &st.UpdatedAt,
- &categoryName, &categoryColor, &isCompleted,
+ &categoryName, &st.CategoryBuiltinKey, &categoryColor, &isCompleted,
); err != nil {
return nil, fmt.Errorf("scan workflow status: %w", err)
}
@@ -192,6 +194,7 @@ func (s *StatusService) ListWorkflowStatuses(workflowID int) ([]StatusResult, er
// StatusCategoryResult represents a status category.
type StatusCategoryResult struct {
ID int
+ BuiltinKey string
Name string
Color string
Description string
@@ -202,7 +205,7 @@ type StatusCategoryResult struct {
// ListCategories retrieves all status categories.
func (s *StatusService) ListCategories() ([]StatusCategoryResult, error) {
rows, err := s.db.Query(`
- SELECT id, name, color, description, is_default, is_completed
+ SELECT id, COALESCE(builtin_key, ''), name, color, description, is_default, is_completed
FROM status_categories
ORDER BY id
`)
@@ -215,7 +218,7 @@ func (s *StatusService) ListCategories() ([]StatusCategoryResult, error) {
for rows.Next() {
var c StatusCategoryResult
var description sql.NullString
- err := rows.Scan(&c.ID, &c.Name, &c.Color, &description, &c.IsDefault, &c.IsCompleted)
+ err := rows.Scan(&c.ID, &c.BuiltinKey, &c.Name, &c.Color, &description, &c.IsDefault, &c.IsCompleted)
if err != nil {
continue
}
@@ -238,9 +241,9 @@ func (s *StatusService) GetCategory(id int) (*StatusCategoryResult, error) {
var c StatusCategoryResult
var description sql.NullString
err := s.db.QueryRow(`
- SELECT id, name, color, description, is_default, is_completed
+ SELECT id, COALESCE(builtin_key, ''), name, color, description, is_default, is_completed
FROM status_categories WHERE id = ?
- `, id).Scan(&c.ID, &c.Name, &c.Color, &description, &c.IsDefault, &c.IsCompleted)
+ `, id).Scan(&c.ID, &c.BuiltinKey, &c.Name, &c.Color, &description, &c.IsDefault, &c.IsCompleted)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("category not found: %d", id)
diff --git a/internal/services/transition_matrix_service.go b/internal/services/transition_matrix_service.go
index 871d2c4f3..530bc96ab 100644
--- a/internal/services/transition_matrix_service.go
+++ b/internal/services/transition_matrix_service.go
@@ -179,7 +179,7 @@ func (s *TransitionMatrixService) loadItemTypeWorkflows(ctx context.Context, wor
func (s *TransitionMatrixService) loadStatuses(ctx context.Context, workflowIDs []int) ([]StatusTransitionOption, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(workflowIDs)), ",")
query := fmt.Sprintf(`
- SELECT s.id, s.name, sc.color
+ SELECT s.id, COALESCE(s.builtin_key, ''), s.name, sc.color
FROM statuses s
LEFT JOIN status_categories sc ON sc.id = s.category_id
WHERE NOT EXISTS (
@@ -209,7 +209,7 @@ func (s *TransitionMatrixService) loadStatuses(ctx context.Context, workflowIDs
for rows.Next() {
var status StatusTransitionOption
var categoryColor sql.NullString
- if err := rows.Scan(&status.StatusID, &status.StatusName, &categoryColor); err != nil {
+ if err := rows.Scan(&status.StatusID, &status.BuiltinKey, &status.StatusName, &categoryColor); err != nil {
return nil, fmt.Errorf("scan transition-matrix status: %w", err)
}
if categoryColor.Valid {
@@ -226,7 +226,7 @@ func (s *TransitionMatrixService) loadStatuses(ctx context.Context, workflowIDs
func (s *TransitionMatrixService) loadEdges(ctx context.Context, workflowIDs []int) (edgesByStatus map[int]map[int][]StatusTransitionOption, anyEdgesByWorkflow map[int][]StatusTransitionOption, err error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(workflowIDs)), ",")
query := fmt.Sprintf(`
- SELECT wt.workflow_id, wt.from_status_id, wt.from_all_statuses, wt.id, s.id, s.name, sc.color
+ SELECT wt.workflow_id, wt.from_status_id, wt.from_all_statuses, wt.id, s.id, COALESCE(s.builtin_key, ''), s.name, sc.color
FROM workflow_transitions wt
JOIN statuses s ON s.id = wt.to_status_id
LEFT JOIN status_categories sc ON sc.id = s.category_id
@@ -257,6 +257,7 @@ func (s *TransitionMatrixService) loadEdges(ctx context.Context, workflowIDs []i
&fromAll,
&option.TransitionID,
&option.StatusID,
+ &option.BuiltinKey,
&option.StatusName,
&categoryColor,
); err != nil {
diff --git a/internal/services/workflow_service.go b/internal/services/workflow_service.go
index 5e797dbdd..53a624f8d 100644
--- a/internal/services/workflow_service.go
+++ b/internal/services/workflow_service.go
@@ -812,6 +812,7 @@ func (s *WorkflowService) GetInitialStatusID(workflowID int) (*int, error) {
// WorkflowResult represents a workflow for listing/reading.
type WorkflowResult struct {
ID int
+ BuiltinKey string
Name string
Description string
IsDefault bool
@@ -821,22 +822,26 @@ type WorkflowResult struct {
// WorkflowTransitionResult represents a workflow transition.
type WorkflowTransitionResult struct {
- ID int
- FromStatusID *int
- FromAllStatuses bool
- FromStatusName string
- FromCategoryName string
- FromCategoryColor string
- ToStatusID int
- ToStatusName string
- ToCategoryName string
- ToCategoryColor string
+ ID int
+ FromStatusID *int
+ FromAllStatuses bool
+ FromStatusBuiltinKey string
+ FromStatusName string
+ FromCategoryBuiltinKey string
+ FromCategoryName string
+ FromCategoryColor string
+ ToStatusID int
+ ToStatusBuiltinKey string
+ ToStatusName string
+ ToCategoryBuiltinKey string
+ ToCategoryName string
+ ToCategoryColor string
}
// List retrieves all workflows.
func (s *WorkflowService) List() ([]WorkflowResult, error) {
rows, err := s.db.Query(`
- SELECT id, name, description, is_default, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default, created_at, updated_at
FROM workflows
ORDER BY name
`)
@@ -849,7 +854,7 @@ func (s *WorkflowService) List() ([]WorkflowResult, error) {
for rows.Next() {
var wf WorkflowResult
var description sql.NullString
- err := rows.Scan(&wf.ID, &wf.Name, &description, &wf.IsDefault, &wf.CreatedAt, &wf.UpdatedAt)
+ err := rows.Scan(&wf.ID, &wf.BuiltinKey, &wf.Name, &description, &wf.IsDefault, &wf.CreatedAt, &wf.UpdatedAt)
if err != nil {
continue
}
@@ -883,7 +888,7 @@ func (s *WorkflowService) ListForWorkspace(workspaceID int) ([]WorkflowResult, e
workflows := make([]WorkflowResult, 0, len(workflowModels))
for _, workflow := range workflowModels {
workflows = append(workflows, WorkflowResult{
- ID: workflow.ID, Name: workflow.Name, Description: workflow.Description,
+ ID: workflow.ID, BuiltinKey: workflow.BuiltinKey, Name: workflow.Name, Description: workflow.Description,
IsDefault: workflow.IsDefault, CreatedAt: workflow.CreatedAt, UpdatedAt: workflow.UpdatedAt,
})
}
@@ -895,9 +900,9 @@ func (s *WorkflowService) GetByID(id int) (*WorkflowResult, error) {
var wf WorkflowResult
var description sql.NullString
err := s.db.QueryRow(`
- SELECT id, name, description, is_default, created_at, updated_at
+ SELECT id, COALESCE(builtin_key, ''), name, description, is_default, created_at, updated_at
FROM workflows WHERE id = ?
- `, id).Scan(&wf.ID, &wf.Name, &description, &wf.IsDefault, &wf.CreatedAt, &wf.UpdatedAt)
+ `, id).Scan(&wf.ID, &wf.BuiltinKey, &wf.Name, &description, &wf.IsDefault, &wf.CreatedAt, &wf.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("workflow not found: %d", id)
@@ -929,12 +934,13 @@ func (s *WorkflowService) scanTransitions(rows *sql.Rows) ([]WorkflowTransitionR
for rows.Next() {
var t WorkflowTransitionResult
var fromStatusID sql.NullInt64
- var fromStatusName, fromCategoryName, fromCategoryColor sql.NullString
+ var fromStatusBuiltinKey, fromStatusName sql.NullString
+ var fromCategoryBuiltinKey, fromCategoryName, fromCategoryColor sql.NullString
err := rows.Scan(&t.ID, &fromStatusID, &t.ToStatusID, &t.FromAllStatuses,
- &fromStatusName, &t.ToStatusName,
- &fromCategoryName, &fromCategoryColor,
- &t.ToCategoryName, &t.ToCategoryColor)
+ &fromStatusBuiltinKey, &fromStatusName, &t.ToStatusBuiltinKey, &t.ToStatusName,
+ &fromCategoryBuiltinKey, &fromCategoryName, &fromCategoryColor,
+ &t.ToCategoryBuiltinKey, &t.ToCategoryName, &t.ToCategoryColor)
if err != nil {
continue
}
@@ -942,7 +948,9 @@ func (s *WorkflowService) scanTransitions(rows *sql.Rows) ([]WorkflowTransitionR
if fromStatusID.Valid {
id := int(fromStatusID.Int64)
t.FromStatusID = &id
+ t.FromStatusBuiltinKey = fromStatusBuiltinKey.String
t.FromStatusName = fromStatusName.String
+ t.FromCategoryBuiltinKey = fromCategoryBuiltinKey.String
t.FromCategoryName = fromCategoryName.String
t.FromCategoryColor = fromCategoryColor.String
}
@@ -961,9 +969,10 @@ func (s *WorkflowService) scanTransitions(rows *sql.Rows) ([]WorkflowTransitionR
func (s *WorkflowService) GetTransitions(workflowID int) ([]WorkflowTransitionResult, error) {
rows, err := s.db.Query(`
SELECT wt.id, wt.from_status_id, wt.to_status_id, wt.from_all_statuses,
- fs.name as from_status_name, ts.name as to_status_name,
- fsc.name as from_category_name, fsc.color as from_category_color,
- tsc.name as to_category_name, tsc.color as to_category_color
+ fs.builtin_key as from_status_builtin_key, fs.name as from_status_name,
+ COALESCE(ts.builtin_key, ''), ts.name as to_status_name,
+ fsc.builtin_key as from_category_builtin_key, fsc.name as from_category_name, fsc.color as from_category_color,
+ COALESCE(tsc.builtin_key, ''), tsc.name as to_category_name, tsc.color as to_category_color
FROM workflow_transitions wt
LEFT JOIN statuses fs ON wt.from_status_id = fs.id
JOIN statuses ts ON wt.to_status_id = ts.id
@@ -986,9 +995,10 @@ func (s *WorkflowService) GetTransitions(workflowID int) ([]WorkflowTransitionRe
func (s *WorkflowService) GetTransitionsFromStatus(statusID int) ([]WorkflowTransitionResult, error) {
rows, err := s.db.Query(`
SELECT wt.id, wt.from_status_id, wt.to_status_id, wt.from_all_statuses,
- fs.name as from_status_name, ts.name as to_status_name,
- fsc.name as from_category_name, fsc.color as from_category_color,
- tsc.name as to_category_name, tsc.color as to_category_color
+ fs.builtin_key as from_status_builtin_key, fs.name as from_status_name,
+ COALESCE(ts.builtin_key, ''), ts.name as to_status_name,
+ fsc.builtin_key as from_category_builtin_key, fsc.name as from_category_name, fsc.color as from_category_color,
+ COALESCE(tsc.builtin_key, ''), tsc.name as to_category_name, tsc.color as to_category_color
FROM workflow_transitions wt
LEFT JOIN statuses fs ON wt.from_status_id = fs.id
JOIN statuses ts ON wt.to_status_id = ts.id
@@ -1022,9 +1032,10 @@ func (s *WorkflowService) GetTransitionsForItem(
rows, err := s.db.Query(`
SELECT wt.id, wt.from_status_id, wt.to_status_id, wt.from_all_statuses,
- fs.name as from_status_name, ts.name as to_status_name,
- fsc.name as from_category_name, fsc.color as from_category_color,
- tsc.name as to_category_name, tsc.color as to_category_color
+ fs.builtin_key as from_status_builtin_key, fs.name as from_status_name,
+ COALESCE(ts.builtin_key, ''), ts.name as to_status_name,
+ fsc.builtin_key as from_category_builtin_key, fsc.name as from_category_name, fsc.color as from_category_color,
+ COALESCE(tsc.builtin_key, ''), tsc.name as to_category_name, tsc.color as to_category_color
FROM workflow_transitions wt
LEFT JOIN statuses fs ON wt.from_status_id = fs.id
JOIN statuses ts ON wt.to_status_id = ts.id
diff --git a/internal/services/workspace_service.go b/internal/services/workspace_service.go
index 6222146d3..ec02dd6ae 100644
--- a/internal/services/workspace_service.go
+++ b/internal/services/workspace_service.go
@@ -409,7 +409,7 @@ func (s *WorkspaceService) GetItemTypes(workspaceID int) ([]ItemTypeResult, erro
}
out := make([]ItemTypeResult, 0, len(rows))
for _, row := range rows {
- out = append(out, ItemTypeResult{ID: row.ID, Name: row.Name, Description: row.Description,
+ out = append(out, ItemTypeResult{ID: row.ID, BuiltinKey: row.BuiltinKey, Name: row.Name, Description: row.Description,
Icon: row.Icon, Color: row.Color, HierarchyLevel: row.HierarchyLevel,
SortOrder: row.SortOrder, IsDefault: row.IsDefault})
}
@@ -426,7 +426,7 @@ func (s *WorkspaceService) GetPriorities(workspaceID int) ([]PriorityResult, err
}
out := make([]PriorityResult, 0, len(rows))
for _, row := range rows {
- out = append(out, PriorityResult{ID: row.ID, Name: row.Name, Description: row.Description,
+ out = append(out, PriorityResult{ID: row.ID, BuiltinKey: row.BuiltinKey, Name: row.Name, Description: row.Description,
Icon: row.Icon, Color: row.Color, SortOrder: row.SortOrder, IsDefault: row.IsDefault})
}
return out, nil
diff --git a/internal/wscli/models.go b/internal/wscli/models.go
index 020d3508f..e807cb2bd 100644
--- a/internal/wscli/models.go
+++ b/internal/wscli/models.go
@@ -343,10 +343,11 @@ type MilestoneProgress struct {
}
type MilestoneStatusBreakdown struct {
- CategoryName string `json:"category_name"`
- CategoryColor string `json:"category_color,omitempty"`
- ItemCount int `json:"item_count"`
- IsCompleted bool `json:"is_completed"`
+ CategoryName string `json:"category_name"`
+ CategoryBuiltinKey string `json:"category_builtin_key,omitempty"`
+ CategoryColor string `json:"category_color,omitempty"`
+ ItemCount int `json:"item_count"`
+ IsCompleted bool `json:"is_completed"`
}
type MilestoneProgressItem struct {
@@ -823,6 +824,7 @@ type PageLabelAddRequest struct {
// are valid.
type LinkType struct {
ID int `json:"id"`
+ BuiltinKey string `json:"builtin_key,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
ForwardLabel string `json:"forward_label"`
From 4728fc5affd19e9e2c1b6779273d652325d6f35c Mon Sep 17 00:00:00 2001
From: Kostenko Nikita <244571@niuitmo.ru>
Date: Sun, 30 Aug 2026 00:42:53 +0300
Subject: [PATCH 9/9] fix: preserve startup and backend error details
---
frontend/src/App.svelte | 27 +++++++++++--------
.../src/lib/settings/ItemTypeManager.svelte | 6 ++---
frontend/src/lib/stores/i18n-utils.spec.js | 19 +++++++++++++
frontend/src/lib/utils/startupCopy.js | 16 +++++++++++
4 files changed, 54 insertions(+), 14 deletions(-)
create mode 100644 frontend/src/lib/utils/startupCopy.js
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 2a37e90ad..ddde452e7 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -8,6 +8,7 @@
import { themeStore } from './lib/stores/theme.svelte.js';
import { i18n, SUPPORTED_LOCALES, t } from './lib/stores/i18n.svelte.js';
import { safeLoginReturnPath } from './lib/utils/loginReturnPath.js';
+ import { getStartupCopy } from './lib/utils/startupCopy.js';
import BrandedLoader from './lib/components/BrandedLoader.svelte';
import LazyRootDialog from './lib/components/LazyRootDialog.svelte';
import LazyRootView from './lib/components/LazyRootView.svelte';
@@ -115,9 +116,7 @@
setupLoading = false;
appInitialized = false;
startupError =
- error?.code === 'REQUEST_TIMEOUT'
- ? t('errors.TIMEOUT')
- : t('errors.NETWORK_ERROR');
+ error?.code === 'REQUEST_TIMEOUT' ? 'errors.TIMEOUT' : 'errors.NETWORK_ERROR';
} finally {
window.clearTimeout(slowTimer);
}
@@ -282,7 +281,9 @@
}
-
{t('common.skipToMainContent')}
+
+ {getStartupCopy('common.skipToMainContent', i18nReady, t)}
+

-
{t('errors.failedToLoad')} Windshift
-
{startupError}
-
{t('errors.NETWORK_ERROR')}
+
+ {getStartupCopy('errors.failedToLoad', i18nReady, t)} Windshift
+
+
{getStartupCopy(startupError, i18nReady, t)}
initializeApp()}
data-testid="startup-retry"
- >{t('common.retry')}
+ >{getStartupCopy('common.retry', i18nReady, t)}
{:else if setupLoading}
{:else if $currentRoute.view === 'public-board'}
@@ -360,7 +362,10 @@
{#if $authStore.loading}
-
+
{:else if showLoginDialog}
diff --git a/frontend/src/lib/settings/ItemTypeManager.svelte b/frontend/src/lib/settings/ItemTypeManager.svelte
index 083d8ca15..cc263fd6e 100644
--- a/frontend/src/lib/settings/ItemTypeManager.svelte
+++ b/frontend/src/lib/settings/ItemTypeManager.svelte
@@ -82,7 +82,7 @@
// Group by hierarchy level for better display
itemTypes = sortItemTypesByHierarchy(itemTypes);
} catch (err) {
- error = t('settings.itemTypes.failedToLoad');
+ error = err?.message || t('settings.itemTypes.failedToLoad');
} finally {
isLoading = false;
}
@@ -176,7 +176,7 @@
error = null;
window.dispatchEvent(new CustomEvent('refresh-workspace-data'));
} catch (err) {
- errorToast(t('settings.itemTypes.failedToSave'));
+ errorToast(err?.message || t('settings.itemTypes.failedToSave'));
}
}
@@ -196,7 +196,7 @@
error = null;
window.dispatchEvent(new CustomEvent('refresh-workspace-data'));
} catch (err) {
- error = t('settings.itemTypes.deleteFailed');
+ error = err?.message || t('settings.itemTypes.deleteFailed');
}
}
diff --git a/frontend/src/lib/stores/i18n-utils.spec.js b/frontend/src/lib/stores/i18n-utils.spec.js
index 2400b0cfe..95604f5a4 100644
--- a/frontend/src/lib/stores/i18n-utils.spec.js
+++ b/frontend/src/lib/stores/i18n-utils.spec.js
@@ -5,6 +5,7 @@ import {
getDashboardSectionSaveValues,
} from '../services/dashboardWidgetRegistry.js';
import { formatRelativeTime } from '../utils/dateFormatter.js';
+import { EAGER_STARTUP_COPY, getStartupCopy } from '../utils/startupCopy.js';
import { i18n } from './i18n.svelte.js';
import { getPluralCategory, negotiateLocale } from './i18n-utils.js';
@@ -66,6 +67,24 @@ describe('getPluralCategory', () => {
});
});
+describe('startup copy', () => {
+ it('does not touch the translator before i18n is ready', () => {
+ const translate = vi.fn((key) => `translated:${key}`);
+
+ for (const [key, value] of Object.entries(EAGER_STARTUP_COPY)) {
+ expect(getStartupCopy(key, false, translate)).toBe(value);
+ }
+ expect(translate).not.toHaveBeenCalled();
+ });
+
+ it('uses the active locale after i18n is ready', () => {
+ const translate = vi.fn((key) => `translated:${key}`);
+
+ expect(getStartupCopy('common.retry', true, translate)).toBe('translated:common.retry');
+ expect(translate).toHaveBeenCalledWith('common.retry');
+ });
+});
+
describe('dashboard section localization', () => {
it('translates untouched default sections', () => {
const [section] = buildDefaultDashboardLayout().sections;
diff --git a/frontend/src/lib/utils/startupCopy.js b/frontend/src/lib/utils/startupCopy.js
new file mode 100644
index 000000000..f28c46ed0
--- /dev/null
+++ b/frontend/src/lib/utils/startupCopy.js
@@ -0,0 +1,16 @@
+// Startup can fail before a locale catalog has been loaded. Keep the small
+// recovery surface eager so it never renders translation keys when the locale
+// chunk is offline, stalled, or otherwise unavailable.
+export const EAGER_STARTUP_COPY = Object.freeze({
+ 'common.loading': 'Loading...',
+ 'common.loadingSlow': 'This can take a moment on a slow connection.',
+ 'common.retry': 'Retry',
+ 'common.skipToMainContent': 'Skip to main content',
+ 'errors.failedToLoad': 'Failed to load',
+ 'errors.NETWORK_ERROR': 'Network error. Please check your connection.',
+ 'errors.TIMEOUT': 'The request timed out. Please try again.',
+});
+
+export function getStartupCopy(key, i18nReady, translate) {
+ return i18nReady ? translate(key) : (EAGER_STARTUP_COPY[key] ?? key);
+}