From 257cbcf826a294a0f94694826053447b6ac56a9e Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 26 Mar 2026 19:05:13 +0100 Subject: [PATCH] Add task priority field Introduce a rank-style priority on tasks, scoped by (organization_id, state). New tasks auto-assign the next priority. Reordering uses the same CTE-based algorithm as trust center references and compliance external URLs. Exposed through GraphQL, MCP, and the PRIORITY order field. Signed-off-by: Sacha Al Himdani --- pkg/coredata/migrations/20260326T160000Z.sql | 19 ++ pkg/coredata/task.go | 215 ++++++++++++++----- pkg/coredata/task_order_field.go | 22 +- pkg/probo/task_service.go | 14 ++ pkg/server/api/console/v1/schema.graphql | 3 + pkg/server/api/console/v1/types/task.go | 1 + pkg/server/api/console/v1/v1_resolver.go | 1 + pkg/server/api/mcp/v1/schema.resolvers.go | 1 + pkg/server/api/mcp/v1/specification.yaml | 8 + pkg/server/api/mcp/v1/types/task.go | 1 + 10 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 pkg/coredata/migrations/20260326T160000Z.sql diff --git a/pkg/coredata/migrations/20260326T160000Z.sql b/pkg/coredata/migrations/20260326T160000Z.sql new file mode 100644 index 000000000..756b01b2e --- /dev/null +++ b/pkg/coredata/migrations/20260326T160000Z.sql @@ -0,0 +1,19 @@ +ALTER TABLE tasks ADD COLUMN priority INTEGER; + +WITH ranked_tasks AS ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY organization_id, state ORDER BY created_at DESC, id DESC) AS rn + FROM tasks +) +UPDATE tasks t +SET priority = rt.rn +FROM ranked_tasks rt +WHERE t.id = rt.id; + +ALTER TABLE tasks ALTER COLUMN priority SET NOT NULL; + +ALTER TABLE tasks +ADD CONSTRAINT tasks_organization_id_state_priority_key + UNIQUE (organization_id, state, priority) + DEFERRABLE INITIALLY DEFERRED; diff --git a/pkg/coredata/task.go b/pkg/coredata/task.go index 0749add26..93606770f 100644 --- a/pkg/coredata/task.go +++ b/pkg/coredata/task.go @@ -41,6 +41,7 @@ type ( TimeEstimate *time.Duration `db:"time_estimate"` AssignedToID *gid.GID `db:"assigned_to_profile_id"` Deadline *time.Time `db:"deadline"` + Priority int `db:"priority"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -48,10 +49,12 @@ type ( Tasks []*Task ) -func (c Task) CursorKey(orderBy TaskOrderField) page.CursorKey { +func (t Task) CursorKey(orderBy TaskOrderField) page.CursorKey { switch orderBy { + case TaskOrderFieldPriority: + return page.NewCursorKey(t.ID, t.Priority) case TaskOrderFieldCreatedAt: - return page.NewCursorKey(c.ID, c.CreatedAt) + return page.NewCursorKey(t.ID, t.CreatedAt) } panic(fmt.Sprintf("unsupported order by: %s", orderBy)) @@ -71,7 +74,7 @@ func (t *Task) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[s return map[string]string{"organization_id": organizationID.String()}, nil } -func (c *Task) LoadByID( +func (t *Task) LoadByID( ctx context.Context, conn pg.Conn, scope Scoper, @@ -89,6 +92,7 @@ SELECT time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at FROM @@ -118,7 +122,7 @@ LIMIT 1; return fmt.Errorf("cannot collect tasks: %w", err) } - *c = task + *t = task return nil } @@ -141,6 +145,7 @@ SELECT time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at FROM @@ -170,12 +175,17 @@ WHERE return nil } -func (c Task) Insert( +func (t *Task) Insert( ctx context.Context, conn pg.Conn, scope Scoper, ) error { q := ` +WITH next_priority AS ( + SELECT COALESCE(MAX(priority), 0) + 1 AS value + FROM tasks + WHERE organization_id = @organization_id AND state = @state +) INSERT INTO tasks ( tenant_id, @@ -189,6 +199,7 @@ INSERT INTO time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at ) @@ -204,28 +215,30 @@ VALUES ( @time_estimate, @assigned_to_profile_id, @deadline, + (SELECT value FROM next_priority), @created_at, @updated_at -); +) +RETURNING priority; ` args := pgx.StrictNamedArgs{ "tenant_id": scope.GetTenantID(), - "task_id": c.ID, - "organization_id": c.OrganizationID, - "measure_id": c.MeasureID, - "name": c.Name, - "description": c.Description, - "reference_id": c.ReferenceID, - "state": c.State, - "time_estimate": c.TimeEstimate, - "assigned_to_profile_id": c.AssignedToID, - "deadline": c.Deadline, - "created_at": c.CreatedAt, - "updated_at": c.UpdatedAt, + "task_id": t.ID, + "organization_id": t.OrganizationID, + "measure_id": t.MeasureID, + "name": t.Name, + "description": t.Description, + "reference_id": t.ReferenceID, + "state": t.State, + "time_estimate": t.TimeEstimate, + "assigned_to_profile_id": t.AssignedToID, + "deadline": t.Deadline, + "created_at": t.CreatedAt, + "updated_at": t.UpdatedAt, } - _, err := conn.Exec(ctx, q, args) + err := conn.QueryRow(ctx, q, args).Scan(&t.Priority) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { @@ -239,12 +252,17 @@ VALUES ( return nil } -func (c *Task) Upsert( +func (t *Task) Upsert( ctx context.Context, conn pg.Conn, scope Scoper, ) error { q := ` +WITH next_priority AS ( + SELECT COALESCE(MAX(priority), 0) + 1 AS value + FROM tasks + WHERE organization_id = @organization_id AND state = @state +) INSERT INTO tasks ( tenant_id, @@ -258,6 +276,7 @@ INSERT INTO time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at ) @@ -273,6 +292,7 @@ VALUES ( @time_estimate, @assigned_to_profile_id, @deadline, + (SELECT value FROM next_priority), @created_at, @updated_at ) @@ -292,24 +312,25 @@ RETURNING time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at ` args := pgx.StrictNamedArgs{ "tenant_id": scope.GetTenantID(), - "task_id": c.ID, - "organization_id": c.OrganizationID, - "measure_id": c.MeasureID, - "name": c.Name, - "description": c.Description, - "reference_id": c.ReferenceID, - "state": c.State, - "time_estimate": c.TimeEstimate, - "assigned_to_profile_id": c.AssignedToID, - "deadline": c.Deadline, - "created_at": c.CreatedAt, - "updated_at": c.UpdatedAt, + "task_id": t.ID, + "organization_id": t.OrganizationID, + "measure_id": t.MeasureID, + "name": t.Name, + "description": t.Description, + "reference_id": t.ReferenceID, + "state": t.State, + "time_estimate": t.TimeEstimate, + "assigned_to_profile_id": t.AssignedToID, + "deadline": t.Deadline, + "created_at": t.CreatedAt, + "updated_at": t.UpdatedAt, } rows, err := conn.Query(ctx, q, args) if err != nil { @@ -321,12 +342,12 @@ RETURNING return fmt.Errorf("cannot collect tasks: %w", err) } - *c = task + *t = task return nil } -func (c *Tasks) CountByOrganizationID( +func (t *Tasks) CountByOrganizationID( ctx context.Context, conn pg.Conn, scope Scoper, @@ -358,7 +379,7 @@ func (c *Tasks) CountByOrganizationID( return count, nil } -func (c *Tasks) LoadByOrganizationID( +func (t *Tasks) LoadByOrganizationID( ctx context.Context, conn pg.Conn, scope Scoper, @@ -377,6 +398,7 @@ func (c *Tasks) LoadByOrganizationID( time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at FROM @@ -402,12 +424,12 @@ func (c *Tasks) LoadByOrganizationID( return fmt.Errorf("cannot collect tasks: %w", err) } - *c = tasks + *t = tasks return nil } -func (c *Tasks) CountByMeasureID( +func (t *Tasks) CountByMeasureID( ctx context.Context, conn pg.Conn, scope Scoper, @@ -439,7 +461,7 @@ WHERE return count, nil } -func (c *Tasks) LoadByMeasureID( +func (t *Tasks) LoadByMeasureID( ctx context.Context, conn pg.Conn, scope Scoper, @@ -458,6 +480,7 @@ SELECT time_estimate, assigned_to_profile_id, deadline, + priority, created_at, updated_at FROM @@ -483,12 +506,12 @@ WHERE return fmt.Errorf("cannot collect tasks: %w", err) } - *c = tasks + *t = tasks return nil } -func (c *Task) Update( +func (t *Task) Update( ctx context.Context, conn pg.Conn, scope Scoper, @@ -499,6 +522,7 @@ SET name = @name, description = @description, state = @state, + priority = @priority, time_estimate = @time_estimate, updated_at = @updated_at, assigned_to_profile_id = @assigned_to_profile_id, @@ -509,14 +533,15 @@ WHERE %s q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.NamedArgs{ - "task_id": c.ID, - "name": c.Name, - "description": c.Description, - "state": c.State, - "time_estimate": c.TimeEstimate, - "updated_at": c.UpdatedAt, - "assigned_to_profile_id": c.AssignedToID, - "deadline": c.Deadline, + "task_id": t.ID, + "name": t.Name, + "description": t.Description, + "state": t.State, + "priority": t.Priority, + "time_estimate": t.TimeEstimate, + "updated_at": t.UpdatedAt, + "assigned_to_profile_id": t.AssignedToID, + "deadline": t.Deadline, } maps.Copy(args, scope.SQLArguments()) @@ -525,7 +550,97 @@ WHERE %s return err } -func (c *Task) Delete( +func (t *Task) NextPriorityForState( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +SELECT COALESCE(MAX(priority), 0) + 1 +FROM tasks +WHERE + organization_id = @organization_id + AND state = @state + AND id != @id + AND %s; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "organization_id": t.OrganizationID, + "state": t.State, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot get next priority: %w", err) + } + + priority, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) + if err != nil { + return fmt.Errorf("cannot get next priority: %w", err) + } + + t.Priority = priority + return nil +} + +func (t *Task) UpdatePriority( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +WITH old AS ( + SELECT + priority AS old_priority + FROM tasks + WHERE %s AND id = @id AND organization_id = @organization_id AND state = @state +) + +UPDATE tasks +SET + priority = CASE + WHEN id = @id THEN @new_priority + ELSE priority + CASE + WHEN @new_priority < old.old_priority THEN 1 + WHEN @new_priority > old.old_priority THEN -1 + END + END, + updated_at = @updated_at +FROM old +WHERE %s + AND organization_id = @organization_id + AND state = @state + AND ( + id = @id + OR (priority BETWEEN LEAST(old.old_priority, @new_priority) AND GREATEST(old.old_priority, @new_priority)) + ); +` + + scopeFragment := scope.SQLFragment() + q = fmt.Sprintf(q, scopeFragment, scopeFragment) + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "new_priority": t.Priority, + "organization_id": t.OrganizationID, + "state": t.State, + "updated_at": t.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update task priority: %w", err) + } + + return nil +} + +func (t *Task) Delete( ctx context.Context, conn pg.Conn, scope Scoper, @@ -538,7 +653,7 @@ WHERE %s q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.NamedArgs{ - "task_id": c.ID, + "task_id": t.ID, } maps.Copy(args, scope.SQLArguments()) diff --git a/pkg/coredata/task_order_field.go b/pkg/coredata/task_order_field.go index 2b36c047a..cd4ce49e9 100644 --- a/pkg/coredata/task_order_field.go +++ b/pkg/coredata/task_order_field.go @@ -14,16 +14,33 @@ package coredata +import "fmt" + type ( TaskOrderField string ) const ( + TaskOrderFieldPriority TaskOrderField = "PRIORITY" TaskOrderFieldCreatedAt TaskOrderField = "CREATED_AT" ) func (p TaskOrderField) Column() string { - return string(p) + switch p { + case TaskOrderFieldPriority: + return "priority" + case TaskOrderFieldCreatedAt: + return "created_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p TaskOrderField) IsValid() bool { + switch p { + case TaskOrderFieldPriority, TaskOrderFieldCreatedAt: + return true + } + return false } func (p TaskOrderField) String() string { @@ -36,5 +53,8 @@ func (p TaskOrderField) MarshalText() ([]byte, error) { func (p *TaskOrderField) UnmarshalText(text []byte) error { *p = TaskOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid TaskOrderField", string(text)) + } return nil } diff --git a/pkg/probo/task_service.go b/pkg/probo/task_service.go index 4fc4909df..283a1deef 100644 --- a/pkg/probo/task_service.go +++ b/pkg/probo/task_service.go @@ -51,6 +51,7 @@ type ( Deadline **time.Time AssignedToID **gid.GID MeasureID **gid.GID + Priority *int } ) @@ -273,6 +274,8 @@ func (s TaskService) Update( return fmt.Errorf("cannot load task %q: %w", req.TaskID, err) } + oldState := task.State + if req.Name != nil { task.Name = *req.Name } @@ -319,6 +322,17 @@ func (s TaskService) Update( task.UpdatedAt = time.Now() + if req.Priority != nil { + task.Priority = *req.Priority + if err := task.UpdatePriority(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot update task priority: %w", err) + } + } else if task.State != oldState { + if err := task.NextPriorityForState(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot get next priority: %w", err) + } + } + if err := task.Update(ctx, conn, s.svc.scope); err != nil { return fmt.Errorf("cannot update task: %w", err) } diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 355b129aa..800773a34 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -499,6 +499,7 @@ enum MeasureOrderField enum TaskOrderField @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskOrderField") { + PRIORITY CREATED_AT } @@ -2392,6 +2393,7 @@ type Task implements Node { name: String! description: String state: TaskState! + priority: Int! timeEstimate: Duration deadline: Datetime assignedTo: Profile @goField(forceResolver: true) @@ -4211,6 +4213,7 @@ input UpdateTaskInput { name: String description: String @goField(omittable: true) state: TaskState + priority: Int timeEstimate: Duration @goField(omittable: true) deadline: Datetime @goField(omittable: true) assignedToId: ID @goField(omittable: true) diff --git a/pkg/server/api/console/v1/types/task.go b/pkg/server/api/console/v1/types/task.go index 6692192f2..4aa91d63c 100644 --- a/pkg/server/api/console/v1/types/task.go +++ b/pkg/server/api/console/v1/types/task.go @@ -70,6 +70,7 @@ func NewTask(t *coredata.Task) *Task { Name: t.Name, Description: t.Description, State: t.State, + Priority: t.Priority, TimeEstimate: t.TimeEstimate, Deadline: t.Deadline, CreatedAt: t.CreatedAt, diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index cd0fa0b6e..2e467bde1 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -3766,6 +3766,7 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, input types.UpdateTas Name: input.Name, Description: gqlutils.UnwrapOmittable(input.Description), State: input.State, + Priority: input.Priority, TimeEstimate: gqlutils.UnwrapOmittable(input.TimeEstimate), Deadline: gqlutils.UnwrapOmittable(input.Deadline), AssignedToID: gqlutils.UnwrapOmittable(input.AssignedToID), diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 750107a97..0ed98c3c8 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -1904,6 +1904,7 @@ func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest, Name: input.Name, Description: UnwrapOmittable(input.Description), State: input.State, + Priority: input.Priority, TimeEstimate: UnwrapOmittable(input.TimeEstimate), Deadline: UnwrapOmittable(input.Deadline), AssignedToID: UnwrapOmittable(input.AssignedToID), diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 8ce6f07b6..14cc0bce6 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -4619,6 +4619,7 @@ components: TaskOrderField: type: string enum: + - PRIORITY - CREATED_AT go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskOrderField @@ -4642,6 +4643,7 @@ components: - organization_id - name - state + - priority - created_at - updated_at properties: @@ -4671,6 +4673,9 @@ components: state: $ref: "#/components/schemas/TaskState" description: Task state + priority: + type: integer + description: Task priority within state time_estimate: anyOf: - $ref: "#/components/schemas/Duration" @@ -4822,6 +4827,9 @@ components: - type: "null" description: No state description: Task state + priority: + type: integer + description: Task priority within state time_estimate: anyOf: - $ref: "#/components/schemas/Duration" diff --git a/pkg/server/api/mcp/v1/types/task.go b/pkg/server/api/mcp/v1/types/task.go index d4eba6b84..c1828489e 100644 --- a/pkg/server/api/mcp/v1/types/task.go +++ b/pkg/server/api/mcp/v1/types/task.go @@ -25,6 +25,7 @@ func NewTask(t *coredata.Task) *Task { Name: t.Name, Description: t.Description, State: t.State, + Priority: t.Priority, TimeEstimate: t.TimeEstimate, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,