diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 53acca3f33..8bb79640f0 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -779,12 +779,14 @@ "assignedTo": { "label": "Assigned to" }, "measure": { "label": "Measure" }, "timeEstimate": { "label": "Time estimate" }, - "deadline": { "label": "Deadline" } + "deadline": { "label": "Deadline" }, + "recurrence": { "label": "Repeats" } }, "states": { "todo": "To do", "inProgress": "In progress", "done": "Done" }, "priorities": { "urgent": "Urgent", "high": "High", "medium": "Medium", "low": "Low" }, + "recurrenceIntervalUnits": { "none": "Does not repeat", "day": "Day", "week": "Week", "month": "Month", "year": "Year" }, "messages": { "created": "Task created successfully.", "updated": "Task updated successfully." }, - "errors": { "create": "Failed to create task", "update": "Failed to update task" }, + "errors": { "create": "Failed to create task", "update": "Failed to update task", "recurrenceRequiresDeadline": "Deadline is required for a recurring task", "recurrenceRequiresCount": "Recurrence count is required", "recurrenceRequiresUnit": "Recurrence unit is required" }, "actions": { "create": "Create task", "update": "Update task" } }, "tasksCard": { @@ -806,7 +808,11 @@ "days_other": "{{count}} Days" }, "deleteConfirmation": "Are you sure you want to delete this task?", - "actions": { "moveToInProgress": "Move to In progress", "moveToDone": "Move to Done", "edit": "Edit", "delete": "Delete" } + "actions": { "moveToInProgress": "Move to In progress", "moveToDone": "Move to Done", "edit": "Edit", "delete": "Delete" }, + "recurringBadge": { + "tooltip_one": "Repeats every {{unit}}", + "tooltip_other": "Repeats every {{count}} {{unit}}s" + } }, "deleteTrustCenterReferenceDialog": { "title": "Delete Reference", diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index 84a875106a..94799996c4 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -1355,6 +1355,9 @@ }, "deadline": { "label": "Échéance" + }, + "recurrence": { + "label": "Répétition" } }, "states": { @@ -1368,13 +1371,23 @@ "medium": "Moyenne", "low": "Basse" }, + "recurrenceIntervalUnits": { + "none": "Ne se répète pas", + "day": "Jour", + "week": "Semaine", + "month": "Mois", + "year": "Année" + }, "messages": { "created": "Tâche créée avec succès.", "updated": "Tâche mise à jour avec succès." }, "errors": { "create": "Échec de la création de la tâche", - "update": "Échec de la mise à jour de la tâche" + "update": "Échec de la mise à jour de la tâche", + "recurrenceRequiresDeadline": "Une échéance est requise pour une tâche récurrente", + "recurrenceRequiresCount": "Le nombre de répétitions est requis", + "recurrenceRequiresUnit": "L'unité de répétition est requise" }, "actions": { "create": "Créer la tâche", @@ -1413,6 +1426,10 @@ "moveToDone": "Déplacer vers Terminé", "edit": "Modifier", "delete": "Supprimer" + }, + "recurringBadge": { + "tooltip_one": "Se répète tous les {{count}} {{unit}}", + "tooltip_other": "Se répète tous les {{count}} {{unit}}" } }, "deleteTrustCenterReferenceDialog": { diff --git a/apps/console/src/components/tasks/TaskFormDialog.tsx b/apps/console/src/components/tasks/TaskFormDialog.tsx index 967614ee2f..0d6466ca5d 100644 --- a/apps/console/src/components/tasks/TaskFormDialog.tsx +++ b/apps/console/src/components/tasks/TaskFormDialog.tsx @@ -61,6 +61,8 @@ const taskFragment = graphql` priority timeEstimate deadline + recurrenceIntervalUnit + recurrenceIntervalCount assignedTo { id } @@ -101,8 +103,49 @@ export const taskUpdateMutation = graphql` export const taskStates = ["TODO", "IN_PROGRESS", "DONE"] as const; export const taskPriorities = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; +export const taskRecurrenceIntervalUnits = ["DAY", "WEEK", "MONTH", "YEAR"] as const; -const createTaskSchema = z.object({ +const recurrenceIntervalUnitField = z.preprocess( + val => (val === "" || val == null ? null : val), + z.enum(taskRecurrenceIntervalUnits).nullable().optional(), +); + +const recurrenceIntervalCountField = z.preprocess( + val => (typeof val === "number" && Number.isNaN(val) ? null : val), + z.number().int().min(1).nullable().optional(), +); + +// Recurrence refinements emit translation keys instead of literal messages so +// the form can localize them at render time. +const recurrenceErrorKeys = [ + "taskFormDialog.errors.recurrenceRequiresCount", + "taskFormDialog.errors.recurrenceRequiresUnit", + "taskFormDialog.errors.recurrenceRequiresDeadline", +] as const; + +const recurrenceErrorKeySet = new Set(recurrenceErrorKeys); + +function refineRecurrence>(schema: T) { + return schema + .refine( + data => !(data.recurrenceIntervalUnit && !data.recurrenceIntervalCount), + { message: recurrenceErrorKeys[0], path: ["recurrenceIntervalCount"] }, + ) + .refine( + data => !(data.recurrenceIntervalCount && !data.recurrenceIntervalUnit), + { message: recurrenceErrorKeys[1], path: ["recurrenceIntervalUnit"] }, + ) + .refine( + data => !(data.recurrenceIntervalUnit && !data.deadline), + { message: recurrenceErrorKeys[2], path: ["deadline"] }, + ); +} + +const createTaskSchema = refineRecurrence(z.object({ name: z.string().min(1), description: z.string().optional().nullable(), priority: z.enum(taskPriorities), @@ -113,9 +156,11 @@ const createTaskSchema = z.object({ z.string().nullable().optional(), ), deadline: z.string().optional().nullable(), -}); + recurrenceIntervalUnit: recurrenceIntervalUnitField, + recurrenceIntervalCount: recurrenceIntervalCountField, +})); -const updateTaskSchema = z.object({ +const updateTaskSchema = refineRecurrence(z.object({ name: z.string().min(1), description: z.string().optional().nullable(), state: z.enum(taskStates), @@ -130,7 +175,9 @@ const updateTaskSchema = z.object({ z.string().nullable().optional(), ), deadline: z.string().optional().nullable(), -}); + recurrenceIntervalUnit: recurrenceIntervalUnitField, + recurrenceIntervalCount: recurrenceIntervalCountField, +})); type Props = { children?: ReactNode; @@ -170,6 +217,8 @@ export default function TaskFormDialog(props: Props) { assignedToId: task?.assignedTo?.id ?? "", measureId: task?.measure?.id ?? measureId ?? "", deadline: task?.deadline?.split("T")[0] ?? "", + recurrenceIntervalUnit: task?.recurrenceIntervalUnit ?? "", + recurrenceIntervalCount: task?.recurrenceIntervalCount ?? 1, }, }); @@ -184,12 +233,18 @@ export default function TaskFormDialog(props: Props) { assignedToId: task.assignedTo?.id ?? "", measureId: task.measure?.id ?? measureId ?? "", deadline: task.deadline?.split("T")[0] ?? "", + recurrenceIntervalUnit: task.recurrenceIntervalUnit ?? "", + recurrenceIntervalCount: task.recurrenceIntervalCount ?? 1, }); } }, [ task, reset, measureId, ]); + // Zod default messages come back as plain text, recurrence ones as keys. + const translateError = (message?: string) => + message && recurrenceErrorKeySet.has(message) ? t(message) : message; + const onSubmit = async (data: z.infer) => { if (task) { await mutate({ @@ -204,6 +259,8 @@ export default function TaskFormDialog(props: Props) { deadline: formatDatetime(data.deadline) ?? null, assignedToId: data.assignedToId ?? null, measureId: data.measureId || null, + recurrenceIntervalUnit: data.recurrenceIntervalUnit || null, + recurrenceIntervalCount: data.recurrenceIntervalUnit ? data.recurrenceIntervalCount : null, }, }, onCompleted: (_response, errors) => { @@ -222,6 +279,8 @@ export default function TaskFormDialog(props: Props) { deadline: formatDatetime(data.deadline) ?? null, assignedToId: data.assignedToId || null, measureId: data.measureId || null, + recurrenceIntervalUnit: data.recurrenceIntervalUnit || null, + recurrenceIntervalCount: data.recurrenceIntervalUnit ? data.recurrenceIntervalCount : null, }, connections: [connection!], }, @@ -389,10 +448,43 @@ export default function TaskFormDialog(props: Props) { + +
+ + ( + + )} + /> +
+
diff --git a/apps/console/src/components/tasks/TasksCard.tsx b/apps/console/src/components/tasks/TasksCard.tsx index 84f6113049..acf2b2c9e9 100644 --- a/apps/console/src/components/tasks/TasksCard.tsx +++ b/apps/console/src/components/tasks/TasksCard.tsx @@ -27,6 +27,7 @@ import { IconCircleCheck, IconCircleProgress, IconPencil, + IconRotateCw, IconTrashCan, PriorityLevel, TabBadge, @@ -495,6 +496,8 @@ const fragment = graphql` description timeEstimate deadline + recurrenceIntervalUnit + recurrenceIntervalCount canUpdate: permission(action: "core:task:update") canDelete: permission(action: "core:task:delete") assignedTo { @@ -621,6 +624,16 @@ function TaskRow(props: TaskRowProps) {
+ {task.recurrenceIntervalUnit && ( + + + + )}

{task.name}

diff --git a/e2e/console/task_test.go b/e2e/console/task_test.go index 45770a1c8d..9e5e22d6d7 100644 --- a/e2e/console/task_test.go +++ b/e2e/console/task_test.go @@ -864,6 +864,139 @@ func TestTask_OmittableDeadline(t *testing.T) { }) } +func TestTask_Recurrence(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + measureID := factory.NewMeasure(owner).WithName("Task Recurrence Test").Create() + + createQuery := ` + mutation CreateTask($input: CreateTaskInput!) { + createTask(input: $input) { + taskEdge { + node { + id + deadline + recurrenceIntervalUnit + recurrenceIntervalCount + } + } + } + } + ` + + t.Run("create with recurrence and deadline round-trips", func(t *testing.T) { + t.Parallel() + + var result struct { + CreateTask struct { + TaskEdge struct { + Node struct { + ID string `json:"id"` + Deadline *string `json:"deadline"` + RecurrenceIntervalUnit *string `json:"recurrenceIntervalUnit"` + RecurrenceIntervalCount *int `json:"recurrenceIntervalCount"` + } `json:"node"` + } `json:"taskEdge"` + } `json:"createTask"` + } + + err := owner.Execute(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "measureId": measureID, + "name": factory.SafeName("Recurring Task"), + "priority": "MEDIUM", + "deadline": "2026-01-15T00:00:00Z", + "recurrenceIntervalUnit": "WEEK", + "recurrenceIntervalCount": 3, + }, + }, &result) + require.NoError(t, err) + + node := result.CreateTask.TaskEdge.Node + assert.NotEmpty(t, node.ID) + require.NotNil(t, node.RecurrenceIntervalUnit) + assert.Equal(t, "WEEK", *node.RecurrenceIntervalUnit) + require.NotNil(t, node.RecurrenceIntervalCount) + assert.Equal(t, 3, *node.RecurrenceIntervalCount) + }) + + t.Run("create with recurrence but no deadline fails", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "measureId": measureID, + "name": factory.SafeName("Recurring Task"), + "priority": "MEDIUM", + "recurrenceIntervalUnit": "WEEK", + "recurrenceIntervalCount": 3, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "deadline") + }) + + t.Run("create with unit but no count fails", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "measureId": measureID, + "name": factory.SafeName("Recurring Task"), + "priority": "MEDIUM", + "deadline": "2026-01-15T00:00:00Z", + "recurrenceIntervalUnit": "WEEK", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "recurrence_interval_count") + }) + + t.Run("create with count but no unit fails", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "measureId": measureID, + "name": factory.SafeName("Recurring Task"), + "priority": "MEDIUM", + "deadline": "2026-01-15T00:00:00Z", + "recurrenceIntervalCount": 3, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "recurrence_interval_unit") + }) + + t.Run("update to add recurrence without a deadline fails", func(t *testing.T) { + t.Parallel() + + taskID := factory.NewTask(owner, measureID). + WithName("Task without deadline"). + Create() + + _, err := owner.Do(` + mutation UpdateTask($input: UpdateTaskInput!) { + updateTask(input: $input) { + task { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "taskId": taskID, + "recurrenceIntervalUnit": "MONTH", + "recurrenceIntervalCount": 1, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "deadline") + }) +} + func TestTask_TenantIsolation(t *testing.T) { t.Parallel() diff --git a/e2e/mcp/task_test.go b/e2e/mcp/task_test.go index 01e7537574..adbe24d284 100644 --- a/e2e/mcp/task_test.go +++ b/e2e/mcp/task_test.go @@ -92,3 +92,59 @@ func TestMCP_Task_CRUD(t *testing.T) { }, &deleteResult) assert.Equal(t, addResult.Task.ID, deleteResult.DeletedTaskID) } + +func TestMCP_Task_Recurrence(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + mc := testutil.NewMCPClient(t, owner) + measureID := factory.CreateMeasure(owner) + + t.Run("add with recurrence and deadline round-trips", func(t *testing.T) { + t.Parallel() + + var addResult struct { + Task struct { + ID string `json:"id"` + RecurrenceIntervalUnit string `json:"recurrence_interval_unit"` + RecurrenceIntervalCount int `json:"recurrence_interval_count"` + } `json:"task"` + } + mc.CallToolInto("addTask", map[string]any{ + "organization_id": owner.GetOrganizationID().String(), + "measure_id": measureID, + "name": factory.SafeName("Recurring Task"), + "deadline": "2026-01-15T00:00:00Z", + "recurrence_interval_unit": "WEEK", + "recurrence_interval_count": 3, + }, &addResult) + require.NotEmpty(t, addResult.Task.ID) + assert.Equal(t, "WEEK", addResult.Task.RecurrenceIntervalUnit) + assert.Equal(t, 3, addResult.Task.RecurrenceIntervalCount) + }) + + t.Run("add with recurrence but no deadline fails", func(t *testing.T) { + t.Parallel() + + errText := mc.CallToolExpectToolError("addTask", map[string]any{ + "organization_id": owner.GetOrganizationID().String(), + "measure_id": measureID, + "name": factory.SafeName("Recurring Task"), + "recurrence_interval_unit": "WEEK", + "recurrence_interval_count": 3, + }) + assert.Contains(t, errText, "deadline") + }) + + t.Run("update to add recurrence without a deadline fails", func(t *testing.T) { + t.Parallel() + + taskID := factory.CreateTask(owner, &measureID, factory.Attrs{"name": factory.SafeName("Task")}) + + errText := mc.CallToolExpectToolError("updateTask", map[string]any{ + "id": taskID, + "recurrence_interval_unit": "MONTH", + "recurrence_interval_count": 1, + }) + assert.Contains(t, errText, "deadline") + }) +} diff --git a/packages/n8n-node/nodes/Probo/actions/task/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/task/create.operation.ts index eea9f02285..38d61493ab 100644 --- a/packages/n8n-node/nodes/Probo/actions/task/create.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/task/create.operation.ts @@ -147,6 +147,57 @@ export const description: INodeProperties[] = [ default: '', description: 'The deadline for the task', }, + { + displayName: 'Recurrence Interval Unit', + name: 'recurrenceIntervalUnit', + type: 'options', + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + options: [ + { + name: 'Day', + value: 'DAY', + }, + { + name: 'Month', + value: 'MONTH', + }, + { + name: 'None', + value: '', + }, + { + name: 'Week', + value: 'WEEK', + }, + { + name: 'Year', + value: 'YEAR', + }, + ], + default: '', + description: 'The recurrence unit for the task, e.g. "Week" with a count of 3 means "every 3 weeks". Requires a deadline to be set.', + }, + { + displayName: 'Recurrence Interval Count', + name: 'recurrenceIntervalCount', + type: 'number', + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 1, + description: 'The recurrence count, used together with the recurrence interval unit', + }, ]; export async function execute( @@ -161,6 +212,8 @@ export async function execute( const timeEstimate = this.getNodeParameter('timeEstimate', itemIndex, '') as string; const assignedToId = this.getNodeParameter('assignedToId', itemIndex, '') as string; const deadline = this.getNodeParameter('deadline', itemIndex, '') as string; + const recurrenceIntervalUnit = this.getNodeParameter('recurrenceIntervalUnit', itemIndex, '') as string; + const recurrenceIntervalCount = this.getNodeParameter('recurrenceIntervalCount', itemIndex, 1) as number; const query = ` mutation CreateTask($input: CreateTaskInput!) { @@ -174,6 +227,8 @@ export async function execute( priority timeEstimate deadline + recurrenceIntervalUnit + recurrenceIntervalCount createdAt updatedAt } @@ -192,6 +247,8 @@ export async function execute( ...(timeEstimate && { timeEstimate }), ...(assignedToId && { assignedToId }), ...(deadline && { deadline }), + ...(recurrenceIntervalUnit && { recurrenceIntervalUnit }), + ...(recurrenceIntervalUnit && { recurrenceIntervalCount }), }, }; diff --git a/packages/n8n-node/nodes/Probo/actions/task/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/task/update.operation.ts index 75de5bc97f..6c1bde849e 100644 --- a/packages/n8n-node/nodes/Probo/actions/task/update.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/task/update.operation.ts @@ -193,6 +193,54 @@ export const description: INodeProperties[] = [ default: '', description: 'The ID of the measure this task belongs to', }, + { + displayName: 'Recurrence Interval Unit', + name: 'recurrenceIntervalUnit', + type: 'options', + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + options: [ + { + name: '(Unchanged)', + value: '', + }, + { + name: 'Day', + value: 'DAY', + }, + { + name: 'Month', + value: 'MONTH', + }, + { + name: 'Week', + value: 'WEEK', + }, + { + name: 'Year', + value: 'YEAR', + }, + ], + default: '', + description: 'The recurrence unit for the task, e.g. "Week" with a count of 3 means "every 3 weeks". Requires a deadline to be set.', + }, + { + displayName: 'Recurrence Interval Count', + name: 'recurrenceIntervalCount', + type: 'string', + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + default: '', + description: 'The recurrence count, used together with the recurrence interval unit', + }, ]; export async function execute( @@ -209,6 +257,8 @@ export async function execute( const deadline = this.getNodeParameter('deadline', itemIndex, '') as string; const assignedToId = this.getNodeParameter('assignedToId', itemIndex, '') as string; const measureId = this.getNodeParameter('measureId', itemIndex, '') as string; + const recurrenceIntervalUnit = this.getNodeParameter('recurrenceIntervalUnit', itemIndex, '') as string; + const recurrenceIntervalCount = this.getNodeParameter('recurrenceIntervalCount', itemIndex, '') as string; const query = ` mutation UpdateTask($input: UpdateTaskInput!) { @@ -221,6 +271,8 @@ export async function execute( priority timeEstimate deadline + recurrenceIntervalUnit + recurrenceIntervalCount createdAt updatedAt } @@ -238,6 +290,8 @@ export async function execute( if (deadline) input.deadline = deadline; if (assignedToId) input.assignedToId = assignedToId; if (measureId) input.measureId = measureId; + if (recurrenceIntervalUnit) input.recurrenceIntervalUnit = recurrenceIntervalUnit; + if (recurrenceIntervalCount) input.recurrenceIntervalCount = recurrenceIntervalCount; const responseData = await proboApiRequest.call(this, query, { input }); diff --git a/pkg/cmd/task/create/create.go b/pkg/cmd/task/create/create.go index 99be758da9..b809609024 100644 --- a/pkg/cmd/task/create/create.go +++ b/pkg/cmd/task/create/create.go @@ -60,14 +60,16 @@ type createResponse struct { func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { var ( - flagOrg string - flagName string - flagDescription string - flagPriority string - flagMeasure string - flagTimeEstimate string - flagAssignedTo string - flagDeadline string + flagOrg string + flagName string + flagDescription string + flagPriority string + flagMeasure string + flagTimeEstimate string + flagAssignedTo string + flagDeadline string + flagRecurrenceUnit string + flagRecurrenceCount int ) cmd := &cobra.Command{ @@ -137,6 +139,16 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("name is required; pass --name or run interactively") } + if flagRecurrenceUnit != "" || flagRecurrenceCount != 0 { + if flagRecurrenceUnit == "" { + return fmt.Errorf("--recurrence-unit is required when --recurrence-count is set") + } + + if flagRecurrenceCount <= 0 { + return fmt.Errorf("--recurrence-count must be greater than 0 when --recurrence-unit is set") + } + } + input := map[string]any{ "organizationId": flagOrg, "name": flagName, @@ -166,6 +178,14 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { input["deadline"] = flagDeadline } + if flagRecurrenceUnit != "" { + input["recurrenceIntervalUnit"] = flagRecurrenceUnit + } + + if flagRecurrenceCount != 0 { + input["recurrenceIntervalCount"] = flagRecurrenceCount + } + data, err := client.Do( createMutation, map[string]any{"input": input}, @@ -199,6 +219,8 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&flagTimeEstimate, "time-estimate", "", "Time estimate") cmd.Flags().StringVar(&flagAssignedTo, "assigned-to", "", "Assigned profile ID") cmd.Flags().StringVar(&flagDeadline, "deadline", "", "Deadline") + cmd.Flags().StringVar(&flagRecurrenceUnit, "recurrence-unit", "", "Recurrence interval unit: DAY, WEEK, MONTH, YEAR (requires --deadline)") + cmd.Flags().IntVar(&flagRecurrenceCount, "recurrence-count", 0, "Recurrence interval count, e.g. 3 with --recurrence-unit WEEK means \"every 3 weeks\"") return cmd } diff --git a/pkg/cmd/task/update/update.go b/pkg/cmd/task/update/update.go index 1fe54841fe..2028c42d9e 100644 --- a/pkg/cmd/task/update/update.go +++ b/pkg/cmd/task/update/update.go @@ -55,14 +55,16 @@ type updateResponse struct { func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { var ( - flagName string - flagDescription string - flagState string - flagPriority string - flagTimeEstimate string - flagDeadline string - flagAssignedTo string - flagMeasure string + flagName string + flagDescription string + flagState string + flagPriority string + flagTimeEstimate string + flagDeadline string + flagAssignedTo string + flagMeasure string + flagRecurrenceUnit string + flagRecurrenceCount int ) cmd := &cobra.Command{ @@ -132,6 +134,26 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { } } + if cmd.Flags().Changed("recurrence-unit") { + if flagRecurrenceUnit == "" { + input["recurrenceIntervalUnit"] = nil + } else { + input["recurrenceIntervalUnit"] = flagRecurrenceUnit + } + } + + if cmd.Flags().Changed("recurrence-count") { + if flagRecurrenceCount < 0 { + return fmt.Errorf("--recurrence-count must be greater than or equal to 0") + } + + if flagRecurrenceCount == 0 { + input["recurrenceIntervalCount"] = nil + } else { + input["recurrenceIntervalCount"] = flagRecurrenceCount + } + } + if len(input) == 1 { return fmt.Errorf("at least one field must be specified for update") } @@ -169,6 +191,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&flagDeadline, "deadline", "", "Deadline") cmd.Flags().StringVar(&flagAssignedTo, "assigned-to", "", "Assigned profile ID") cmd.Flags().StringVar(&flagMeasure, "measure", "", "Measure ID") + cmd.Flags().StringVar(&flagRecurrenceUnit, "recurrence-unit", "", "Recurrence interval unit: DAY, WEEK, MONTH, YEAR (empty clears recurrence)") + cmd.Flags().IntVar(&flagRecurrenceCount, "recurrence-count", 0, "Recurrence interval count, e.g. 3 with --recurrence-unit WEEK means \"every 3 weeks\" (0 clears recurrence)") return cmd } diff --git a/pkg/coredata/migrations/20260728T195457Z.sql b/pkg/coredata/migrations/20260728T195457Z.sql new file mode 100644 index 0000000000..67875835ea --- /dev/null +++ b/pkg/coredata/migrations/20260728T195457Z.sql @@ -0,0 +1,35 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +CREATE TYPE task_recurrence_interval_unit AS ENUM ( + 'DAY', + 'WEEK', + 'MONTH', + 'YEAR' +); + +ALTER TABLE tasks ADD COLUMN recurrence_interval_unit task_recurrence_interval_unit; +ALTER TABLE tasks ADD COLUMN recurrence_interval_count INTEGER; + +ALTER TABLE tasks + ADD CONSTRAINT tasks_recurrence_interval_check CHECK ( + (recurrence_interval_unit IS NULL) = (recurrence_interval_count IS NULL) + AND (recurrence_interval_count IS NULL OR recurrence_interval_count >= 1) + ); diff --git a/pkg/coredata/task.go b/pkg/coredata/task.go index 8e7c4c286d..07c8db69eb 100644 --- a/pkg/coredata/task.go +++ b/pkg/coredata/task.go @@ -37,20 +37,22 @@ import ( type ( Task struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - MeasureID *gid.GID `db:"measure_id"` - Name string `db:"name"` - Description *string `db:"description"` - State TaskState `db:"state"` - Priority TaskPriority `db:"priority"` - ReferenceID string `db:"reference_id"` - TimeEstimate *time.Duration `db:"time_estimate"` - AssignedToID *gid.GID `db:"assigned_to_profile_id"` - Deadline *time.Time `db:"deadline"` - Rank int `db:"rank"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + MeasureID *gid.GID `db:"measure_id"` + Name string `db:"name"` + Description *string `db:"description"` + State TaskState `db:"state"` + Priority TaskPriority `db:"priority"` + ReferenceID string `db:"reference_id"` + TimeEstimate *time.Duration `db:"time_estimate"` + AssignedToID *gid.GID `db:"assigned_to_profile_id"` + Deadline *time.Time `db:"deadline"` + RecurrenceIntervalUnit *TaskRecurrenceIntervalUnit `db:"recurrence_interval_unit"` + RecurrenceIntervalCount *int `db:"recurrence_interval_count"` + Rank int `db:"rank"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` // ordering only PriorityRank int `db:"priority_rank"` @@ -128,6 +130,8 @@ SELECT time_estimate, assigned_to_profile_id, deadline, + recurrence_interval_unit, + recurrence_interval_count, rank, priority_rank, created_at, @@ -183,6 +187,8 @@ SELECT time_estimate, assigned_to_profile_id, deadline, + recurrence_interval_unit, + recurrence_interval_count, rank, priority_rank, created_at, @@ -243,6 +249,8 @@ INSERT INTO time_estimate, assigned_to_profile_id, deadline, + recurrence_interval_unit, + recurrence_interval_count, rank, created_at, updated_at @@ -260,6 +268,8 @@ VALUES ( @time_estimate, @assigned_to_profile_id, @deadline, + @recurrence_interval_unit, + @recurrence_interval_count, (SELECT value FROM next_rank), @created_at, @updated_at @@ -268,20 +278,22 @@ RETURNING rank, priority_rank; ` args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "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, - "priority": t.Priority, - "time_estimate": t.TimeEstimate, - "assigned_to_profile_id": t.AssignedToID, - "deadline": t.Deadline, - "created_at": t.CreatedAt, - "updated_at": t.UpdatedAt, + "tenant_id": scope.GetTenantID(), + "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, + "priority": t.Priority, + "time_estimate": t.TimeEstimate, + "assigned_to_profile_id": t.AssignedToID, + "deadline": t.Deadline, + "recurrence_interval_unit": t.RecurrenceIntervalUnit, + "recurrence_interval_count": t.RecurrenceIntervalCount, + "created_at": t.CreatedAt, + "updated_at": t.UpdatedAt, } err := conn.QueryRow(ctx, q, args).Scan(&t.Rank, &t.PriorityRank) @@ -452,6 +464,8 @@ func (t *Tasks) LoadByOrganizationID( time_estimate, assigned_to_profile_id, deadline, + recurrence_interval_unit, + recurrence_interval_count, rank, priority_rank, created_at, @@ -537,6 +551,8 @@ SELECT time_estimate, assigned_to_profile_id, deadline, + recurrence_interval_unit, + recurrence_interval_count, rank, priority_rank, created_at, @@ -585,23 +601,27 @@ SET time_estimate = @time_estimate, updated_at = @updated_at, assigned_to_profile_id = @assigned_to_profile_id, - deadline = @deadline + deadline = @deadline, + recurrence_interval_unit = @recurrence_interval_unit, + recurrence_interval_count = @recurrence_interval_count WHERE %s AND id = @task_id ` q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.NamedArgs{ - "task_id": t.ID, - "name": t.Name, - "description": t.Description, - "state": t.State, - "priority": t.Priority, - "rank": t.Rank, - "time_estimate": t.TimeEstimate, - "updated_at": t.UpdatedAt, - "assigned_to_profile_id": t.AssignedToID, - "deadline": t.Deadline, + "task_id": t.ID, + "name": t.Name, + "description": t.Description, + "state": t.State, + "priority": t.Priority, + "rank": t.Rank, + "time_estimate": t.TimeEstimate, + "updated_at": t.UpdatedAt, + "assigned_to_profile_id": t.AssignedToID, + "deadline": t.Deadline, + "recurrence_interval_unit": t.RecurrenceIntervalUnit, + "recurrence_interval_count": t.RecurrenceIntervalCount, } maps.Copy(args, scope.SQLArguments()) @@ -731,3 +751,60 @@ WHERE %s return nil } + +// LoadNextOverdueRecurringForUpdateSkipLocked loads the next recurring task +// whose deadline has passed, across all tenants, locking the row so +// concurrent workers claim distinct tasks. +func (t *Task) LoadNextOverdueRecurringForUpdateSkipLocked( + ctx context.Context, + tx pg.Tx, + now time.Time, +) error { + q := ` +SELECT + id, + organization_id, + measure_id, + name, + description, + state, + priority, + reference_id, + time_estimate, + assigned_to_profile_id, + deadline, + recurrence_interval_unit, + recurrence_interval_count, + rank, + priority_rank, + created_at, + updated_at +FROM + tasks +WHERE + recurrence_interval_unit IS NOT NULL + AND deadline < @now +ORDER BY + deadline ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +` + + rows, err := tx.Query(ctx, q, pgx.StrictNamedArgs{"now": now}) + if err != nil { + return fmt.Errorf("cannot query overdue recurring tasks: %w", err) + } + + task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect task: %w", err) + } + + *t = task + + return nil +} diff --git a/pkg/coredata/task_recurrence_interval_unit.go b/pkg/coredata/task_recurrence_interval_unit.go new file mode 100644 index 0000000000..ff6df9afa6 --- /dev/null +++ b/pkg/coredata/task_recurrence_interval_unit.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" +) + +type TaskRecurrenceIntervalUnit string + +const ( + TaskRecurrenceIntervalUnitDay TaskRecurrenceIntervalUnit = "DAY" + TaskRecurrenceIntervalUnitWeek TaskRecurrenceIntervalUnit = "WEEK" + TaskRecurrenceIntervalUnitMonth TaskRecurrenceIntervalUnit = "MONTH" + TaskRecurrenceIntervalUnitYear TaskRecurrenceIntervalUnit = "YEAR" +) + +var ( + _ fmt.Stringer = TaskRecurrenceIntervalUnit("") + _ encoding.TextMarshaler = TaskRecurrenceIntervalUnit("") + _ encoding.TextUnmarshaler = (*TaskRecurrenceIntervalUnit)(nil) +) + +func TaskRecurrenceIntervalUnits() []TaskRecurrenceIntervalUnit { + return []TaskRecurrenceIntervalUnit{ + TaskRecurrenceIntervalUnitDay, + TaskRecurrenceIntervalUnitWeek, + TaskRecurrenceIntervalUnitMonth, + TaskRecurrenceIntervalUnitYear, + } +} + +func (v TaskRecurrenceIntervalUnit) IsValid() bool { + switch v { + case + TaskRecurrenceIntervalUnitDay, + TaskRecurrenceIntervalUnitWeek, + TaskRecurrenceIntervalUnitMonth, + TaskRecurrenceIntervalUnitYear: + return true + } + + return false +} + +func (v TaskRecurrenceIntervalUnit) String() string { + return string(v) +} + +func (v TaskRecurrenceIntervalUnit) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *TaskRecurrenceIntervalUnit) UnmarshalText(text []byte) error { + val := TaskRecurrenceIntervalUnit(text) + if !val.IsValid() { + return fmt.Errorf("invalid TaskRecurrenceIntervalUnit value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/probo/recurring_task_worker.go b/pkg/probo/recurring_task_worker.go new file mode 100644 index 0000000000..9ebb6a8526 --- /dev/null +++ b/pkg/probo/recurring_task_worker.go @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package probo + +import ( + "context" + "errors" + "fmt" + "time" + + "go.gearno.de/crypto/uuid" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/kit/worker" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +type recurringTaskHandler struct { + service *Service + logger *log.Logger +} + +var _ worker.Handler[coredata.Task] = (*recurringTaskHandler)(nil) + +// NewRecurringTaskWorker builds the worker that advances recurring tasks past +// their deadline: it detaches the overdue task from its recurrence series and +// spawns the next occurrence, so the series keeps moving forward on its own +// schedule regardless of whether the overdue task was ever completed. +func NewRecurringTaskWorker( + service *Service, + logger *log.Logger, + opts ...worker.Option, +) *worker.Worker[coredata.Task] { + h := &recurringTaskHandler{ + service: service, + logger: logger, + } + + return worker.New( + "recurring-task-worker", + h, + logger, + opts..., + ) +} + +func (h *recurringTaskHandler) Claim(ctx context.Context) (coredata.Task, error) { + var nextTask coredata.Task + + err := h.service.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + task := &coredata.Task{} + now := time.Now() + + if err := task.LoadNextOverdueRecurringForUpdateSkipLocked(ctx, tx, now); err != nil { + return err + } + + unit := *task.RecurrenceIntervalUnit + count := *task.RecurrenceIntervalCount + + deadline, err := nextRecurrenceDeadline(*task.Deadline, unit, count, now) + if err != nil { + return fmt.Errorf("cannot compute next deadline of task %q: %w", task.ID, err) + } + + task.RecurrenceIntervalUnit = nil + task.RecurrenceIntervalCount = nil + task.UpdatedAt = now + + if err := task.Update(ctx, tx, coredata.NewNoScope()); err != nil { + return fmt.Errorf("cannot detach recurring task: %w", err) + } + + referenceID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("cannot generate reference id: %w", err) + } + + nextTask = coredata.Task{ + ID: gid.New(task.OrganizationID.TenantID(), coredata.TaskEntityType), + OrganizationID: task.OrganizationID, + MeasureID: task.MeasureID, + Name: task.Name, + Description: task.Description, + Priority: task.Priority, + ReferenceID: "custom-task-" + referenceID.String(), + TimeEstimate: task.TimeEstimate, + AssignedToID: task.AssignedToID, + Deadline: &deadline, + RecurrenceIntervalUnit: &unit, + RecurrenceIntervalCount: &count, + State: coredata.TaskStateTodo, + CreatedAt: now, + UpdatedAt: now, + } + + scope := coredata.NewScopeFromObjectID(task.OrganizationID) + + if err := nextTask.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert next recurring task: %w", err) + } + + return nil + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return coredata.Task{}, worker.ErrNoTask + } + + return coredata.Task{}, err + } + + return nextTask, nil +} + +func (h *recurringTaskHandler) Process(ctx context.Context, task coredata.Task) error { + h.logger.InfoCtx( + ctx, + "generated next occurrence of recurring task", + log.String("task_id", task.ID.String()), + log.Time("deadline", *task.Deadline), + ) + + return nil +} + +// nextRecurrenceDeadline advances deadline by count units of unit, repeating +// until the result is after now. This collapses any number of missed cycles +// (e.g. after a long outage) into a single upcoming occurrence instead of +// backfilling one task per missed cycle. +// +// It errors on any recurrence that cannot move a deadline forward — a +// non-positive count or a unit this function does not handle — because such a +// recurrence would otherwise spin the loop forever. +func nextRecurrenceDeadline( + deadline time.Time, + unit coredata.TaskRecurrenceIntervalUnit, + count int, + now time.Time, +) (time.Time, error) { + if count < 1 { + return time.Time{}, fmt.Errorf("recurrence interval count must be at least 1, got %d", count) + } + + next := deadline + + for !next.After(now) { + switch unit { + case coredata.TaskRecurrenceIntervalUnitDay: + next = next.AddDate(0, 0, count) + case coredata.TaskRecurrenceIntervalUnitWeek: + next = next.AddDate(0, 0, 7*count) + case coredata.TaskRecurrenceIntervalUnitMonth: + next = next.AddDate(0, count, 0) + case coredata.TaskRecurrenceIntervalUnitYear: + next = next.AddDate(count, 0, 0) + default: + return time.Time{}, fmt.Errorf("unhandled recurrence interval unit %q", unit) + } + } + + return next, nil +} diff --git a/pkg/probo/recurring_task_worker_test.go b/pkg/probo/recurring_task_worker_test.go new file mode 100644 index 0000000000..afc3c6a41d --- /dev/null +++ b/pkg/probo/recurring_task_worker_test.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package probo + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestNextRecurrenceDeadline(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.July, 28, 12, 0, 0, 0, time.UTC) + oneDayOverdue := now.AddDate(0, 0, -1) + + tests := []struct { + name string + deadline time.Time + unit coredata.TaskRecurrenceIntervalUnit + count int + want time.Time + }{ + { + name: "every 2 days, overdue by a day", + deadline: oneDayOverdue, + unit: coredata.TaskRecurrenceIntervalUnitDay, + count: 2, + want: oneDayOverdue.AddDate(0, 0, 2), + }, + { + name: "every 3 weeks, overdue by a day", + deadline: oneDayOverdue, + unit: coredata.TaskRecurrenceIntervalUnitWeek, + count: 3, + want: oneDayOverdue.AddDate(0, 0, 21), + }, + { + name: "every month, overdue by a day", + deadline: oneDayOverdue, + unit: coredata.TaskRecurrenceIntervalUnitMonth, + count: 1, + want: oneDayOverdue.AddDate(0, 1, 0), + }, + { + name: "quarterly (every 3 months), not yet overdue", + deadline: now.AddDate(0, 1, 0), + unit: coredata.TaskRecurrenceIntervalUnitMonth, + count: 3, + want: now.AddDate(0, 1, 0), + }, + { + name: "every year, overdue by a day", + deadline: oneDayOverdue, + unit: coredata.TaskRecurrenceIntervalUnitYear, + count: 1, + want: oneDayOverdue.AddDate(1, 0, 0), + }, + { + name: "long outage collapses to a single future occurrence", + deadline: now.AddDate(-1, 0, 0), + unit: coredata.TaskRecurrenceIntervalUnitDay, + count: 2, + want: now.AddDate(-1, 0, 0).AddDate(0, 0, 366), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := nextRecurrenceDeadline(tt.deadline, tt.unit, tt.count, now) + + require.NoError(t, err) + assert.True(t, got.After(now), "next deadline must be strictly after now") + assert.True(t, got.Equal(tt.want), "got %v, want %v", got, tt.want) + }) + } +} + +// TestNextRecurrenceDeadlineInvalidRecurrence covers recurrences that cannot +// move a deadline forward: they must be rejected rather than spin the advance +// loop forever and hang the worker. +func TestNextRecurrenceDeadlineInvalidRecurrence(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.July, 28, 12, 0, 0, 0, time.UTC) + oneDayOverdue := now.AddDate(0, 0, -1) + + tests := []struct { + name string + unit coredata.TaskRecurrenceIntervalUnit + count int + }{ + { + name: "zero count never advances", + unit: coredata.TaskRecurrenceIntervalUnitDay, + count: 0, + }, + { + name: "negative count advances backwards", + unit: coredata.TaskRecurrenceIntervalUnitDay, + count: -1, + }, + { + name: "unknown unit is not handled by the switch", + unit: coredata.TaskRecurrenceIntervalUnit("QUARTER"), + count: 1, + }, + { + name: "empty unit is not handled by the switch", + unit: coredata.TaskRecurrenceIntervalUnit(""), + count: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := nextRecurrenceDeadline(oneDayOverdue, tt.unit, tt.count, now) + + assert.Error(t, err) + }) + } +} diff --git a/pkg/probo/task_service.go b/pkg/probo/task_service.go index 7450a91b0a..78acece695 100644 --- a/pkg/probo/task_service.go +++ b/pkg/probo/task_service.go @@ -40,27 +40,31 @@ type ( } CreateTaskRequest struct { - OrganizationID gid.GID - MeasureID *gid.GID - Name string - Description *string - Priority coredata.TaskPriority - TimeEstimate *time.Duration - AssignedToID *gid.GID - Deadline *time.Time + OrganizationID gid.GID + MeasureID *gid.GID + Name string + Description *string + Priority coredata.TaskPriority + TimeEstimate *time.Duration + AssignedToID *gid.GID + Deadline *time.Time + RecurrenceIntervalUnit *coredata.TaskRecurrenceIntervalUnit + RecurrenceIntervalCount *int } UpdateTaskRequest struct { - TaskID gid.GID - Name *string - Description **string - State *coredata.TaskState - Priority *coredata.TaskPriority - TimeEstimate **time.Duration - Deadline **time.Time - AssignedToID **gid.GID - MeasureID **gid.GID - Rank *int + TaskID gid.GID + Name *string + Description **string + State *coredata.TaskState + Priority *coredata.TaskPriority + TimeEstimate **time.Duration + Deadline **time.Time + AssignedToID **gid.GID + MeasureID **gid.GID + Rank *int + RecurrenceIntervalUnit **coredata.TaskRecurrenceIntervalUnit + RecurrenceIntervalCount **int } ) @@ -74,6 +78,37 @@ func (ctr *CreateTaskRequest) Validate() error { v.Check(ctr.Priority, "priority", validator.Required(), validator.OneOfSlice(coredata.TaskPriorities())) v.Check(ctr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour)) v.Check(ctr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType)) + v.Check(ctr.RecurrenceIntervalUnit, "recurrence_interval_unit", validator.OneOfSlice(coredata.TaskRecurrenceIntervalUnits())) + v.Check(ctr.RecurrenceIntervalCount, "recurrence_interval_count", validator.Min(1)) + + recurring := ctr.RecurrenceIntervalUnit != nil || ctr.RecurrenceIntervalCount != nil + + if ctr.RecurrenceIntervalUnit == nil && ctr.RecurrenceIntervalCount != nil { + v.Check(ctr.RecurrenceIntervalUnit, "recurrence_interval_unit", func(any) *validator.ValidationError { + return &validator.ValidationError{ + Code: validator.ErrorCodeCustom, + Message: "must be set when recurrence_interval_count is set", + } + }) + } + + if ctr.RecurrenceIntervalUnit != nil && ctr.RecurrenceIntervalCount == nil { + v.Check(ctr.RecurrenceIntervalCount, "recurrence_interval_count", func(any) *validator.ValidationError { + return &validator.ValidationError{ + Code: validator.ErrorCodeCustom, + Message: "must be set when recurrence_interval_unit is set", + } + }) + } + + if recurring && ctr.Deadline == nil { + v.Check(ctr.Deadline, "deadline", func(any) *validator.ValidationError { + return &validator.ValidationError{ + Code: validator.ErrorCodeCustom, + Message: "deadline is required when the task is recurring", + } + }) + } return v.Error() } @@ -90,6 +125,8 @@ func (utr *UpdateTaskRequest) Validate() error { v.Check(utr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType)) v.Check(utr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType)) v.Check(utr.Rank, "rank", validator.Min(1)) + v.Check(utr.RecurrenceIntervalUnit, "recurrence_interval_unit", validator.OneOfSlice(coredata.TaskRecurrenceIntervalUnits())) + v.Check(utr.RecurrenceIntervalCount, "recurrence_interval_count", validator.Min(1)) return v.Error() } @@ -111,19 +148,21 @@ func (s TaskService) Create( } task := &coredata.Task{ - ID: taskID, - OrganizationID: req.OrganizationID, - MeasureID: req.MeasureID, - Name: req.Name, - Description: req.Description, - Priority: req.Priority, - TimeEstimate: req.TimeEstimate, - AssignedToID: req.AssignedToID, - Deadline: req.Deadline, - State: coredata.TaskStateTodo, - ReferenceID: "custom-task-" + referenceID.String(), - CreatedAt: now, - UpdatedAt: now, + ID: taskID, + OrganizationID: req.OrganizationID, + MeasureID: req.MeasureID, + Name: req.Name, + Description: req.Description, + Priority: req.Priority, + TimeEstimate: req.TimeEstimate, + AssignedToID: req.AssignedToID, + Deadline: req.Deadline, + RecurrenceIntervalUnit: req.RecurrenceIntervalUnit, + RecurrenceIntervalCount: req.RecurrenceIntervalCount, + State: coredata.TaskStateTodo, + ReferenceID: "custom-task-" + referenceID.String(), + CreatedAt: now, + UpdatedAt: now, } err = s.svc.pg.WithTx( @@ -340,6 +379,30 @@ func (s TaskService) Update( task.Priority = *req.Priority } + if req.RecurrenceIntervalUnit != nil { + task.RecurrenceIntervalUnit = *req.RecurrenceIntervalUnit + } + + if req.RecurrenceIntervalCount != nil { + task.RecurrenceIntervalCount = *req.RecurrenceIntervalCount + } + + if (task.RecurrenceIntervalUnit == nil) != (task.RecurrenceIntervalCount == nil) { + return validator.ValidationErrors{&validator.ValidationError{ + Field: "recurrence_interval_count", + Code: validator.ErrorCodeCustom, + Message: "recurrence_interval_unit and recurrence_interval_count must be set together", + }} + } + + if task.RecurrenceIntervalUnit != nil && task.Deadline == nil { + return validator.ValidationErrors{&validator.ValidationError{ + Field: "deadline", + Code: validator.ErrorCodeCustom, + Message: "deadline is required when the task is recurring", + }} + } + task.UpdatedAt = time.Now() targetRank := req.Rank diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d44c4cb8ae..127d95179e 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -951,6 +951,21 @@ func (impl *Implm) Run( }, ) + recurringTaskWorker := probo.NewRecurringTaskWorker( + proboService, + l.Named("recurring-task-worker"), + worker.WithInterval(15*time.Minute), + ) + recurringTaskWorkerCtx, stopRecurringTaskWorker := context.WithCancel(context.Background()) + + wg.Go( + func() { + if err := recurringTaskWorker.Run(recurringTaskWorkerCtx); err != nil { + cancel(fmt.Errorf("recurring task worker crashed: %w", err)) + } + }, + ) + accessReviewWorkerCtx, stopAccessReviewWorker := context.WithCancel(context.Background()) wg.Go( @@ -1199,6 +1214,7 @@ func (impl *Implm) Run( stopDocumentPDFWorker() stopDocumentNotification() stopExportJobExporter() + stopRecurringTaskWorker() stopAccessReviewWorker() stopIAMService() stopITAMGC() diff --git a/pkg/server/api/console/v1/graphql/task.graphql b/pkg/server/api/console/v1/graphql/task.graphql index 80d41524d1..08e72e2af3 100644 --- a/pkg/server/api/console/v1/graphql/task.graphql +++ b/pkg/server/api/console/v1/graphql/task.graphql @@ -24,6 +24,28 @@ enum TaskPriority ) } +enum TaskRecurrenceIntervalUnit + @goModel( + model: "go.probo.inc/probo/pkg/coredata.TaskRecurrenceIntervalUnit" + ) { + DAY + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskRecurrenceIntervalUnitDay" + ) + WEEK + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskRecurrenceIntervalUnitWeek" + ) + MONTH + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskRecurrenceIntervalUnitMonth" + ) + YEAR + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskRecurrenceIntervalUnitYear" + ) +} + enum TaskOrderField @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskOrderField") { PRIORITY_RANK @@ -47,6 +69,8 @@ type Task implements Node { rank: Int! timeEstimate: Duration deadline: Datetime + recurrenceIntervalUnit: TaskRecurrenceIntervalUnit + recurrenceIntervalCount: Int assignedTo: Profile @goField(forceResolver: true) organization: Organization! @goField(forceResolver: true) @@ -95,6 +119,8 @@ input CreateTaskInput { timeEstimate: Duration assignedToId: ID deadline: Datetime + recurrenceIntervalUnit: TaskRecurrenceIntervalUnit + recurrenceIntervalCount: Int } input UpdateTaskInput { @@ -108,6 +134,8 @@ input UpdateTaskInput { deadline: Datetime @goField(omittable: true) assignedToId: ID @goField(omittable: true) measureId: ID @goField(omittable: true) + recurrenceIntervalUnit: TaskRecurrenceIntervalUnit @goField(omittable: true) + recurrenceIntervalCount: Int @goField(omittable: true) } input DeleteTaskInput { diff --git a/pkg/server/api/console/v1/task_resolvers.go b/pkg/server/api/console/v1/task_resolvers.go index 26e000cf3b..61a3fcb28b 100644 --- a/pkg/server/api/console/v1/task_resolvers.go +++ b/pkg/server/api/console/v1/task_resolvers.go @@ -32,14 +32,16 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input types.CreateTas task, err := r.probo.Tasks.Create( ctx, scope, probo.CreateTaskRequest{ - MeasureID: input.MeasureID, - OrganizationID: input.OrganizationID, - Name: input.Name, - Description: input.Description, - Priority: input.Priority, - TimeEstimate: input.TimeEstimate, - AssignedToID: input.AssignedToID, - Deadline: input.Deadline, + MeasureID: input.MeasureID, + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: input.Description, + Priority: input.Priority, + TimeEstimate: input.TimeEstimate, + AssignedToID: input.AssignedToID, + Deadline: input.Deadline, + RecurrenceIntervalUnit: input.RecurrenceIntervalUnit, + RecurrenceIntervalCount: input.RecurrenceIntervalCount, }, ) if err != nil { @@ -71,16 +73,18 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, input types.UpdateTas task, err := r.probo.Tasks.Update( ctx, scope, probo.UpdateTaskRequest{ - TaskID: input.TaskID, - Name: input.Name, - Description: gqlutils.UnwrapOmittable(input.Description), - State: input.State, - Priority: input.Priority, - Rank: input.Rank, - TimeEstimate: gqlutils.UnwrapOmittable(input.TimeEstimate), - Deadline: gqlutils.UnwrapOmittable(input.Deadline), - AssignedToID: gqlutils.UnwrapOmittable(input.AssignedToID), - MeasureID: gqlutils.UnwrapOmittable(input.MeasureID), + TaskID: input.TaskID, + Name: input.Name, + Description: gqlutils.UnwrapOmittable(input.Description), + State: input.State, + Priority: input.Priority, + Rank: input.Rank, + TimeEstimate: gqlutils.UnwrapOmittable(input.TimeEstimate), + Deadline: gqlutils.UnwrapOmittable(input.Deadline), + AssignedToID: gqlutils.UnwrapOmittable(input.AssignedToID), + MeasureID: gqlutils.UnwrapOmittable(input.MeasureID), + RecurrenceIntervalUnit: gqlutils.UnwrapOmittable(input.RecurrenceIntervalUnit), + RecurrenceIntervalCount: gqlutils.UnwrapOmittable(input.RecurrenceIntervalCount), }, ) if err != nil { diff --git a/pkg/server/api/console/v1/types/task.go b/pkg/server/api/console/v1/types/task.go index 94be445b4c..1efdfa37e0 100644 --- a/pkg/server/api/console/v1/types/task.go +++ b/pkg/server/api/console/v1/types/task.go @@ -73,15 +73,17 @@ func NewTask(t *coredata.Task) *Task { ID: t.OrganizationID, }, - Name: t.Name, - Description: t.Description, - State: t.State, - Priority: t.Priority, - Rank: t.Rank, - TimeEstimate: t.TimeEstimate, - Deadline: t.Deadline, - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, + Name: t.Name, + Description: t.Description, + State: t.State, + Priority: t.Priority, + Rank: t.Rank, + TimeEstimate: t.TimeEstimate, + Deadline: t.Deadline, + RecurrenceIntervalUnit: t.RecurrenceIntervalUnit, + RecurrenceIntervalCount: t.RecurrenceIntervalCount, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, } if t.MeasureID != nil { diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 35035cb788..df5f0b64f1 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -2063,14 +2063,16 @@ func (r *Resolver) AddTaskTool(ctx context.Context, req *mcp.CallToolRequest, in task, err := svc.Tasks.Create( ctx, scope, probo.CreateTaskRequest{ - OrganizationID: input.OrganizationID, - MeasureID: input.MeasureID, - Name: input.Name, - Description: input.Description, - Priority: priority, - TimeEstimate: input.TimeEstimate, - Deadline: input.Deadline, - AssignedToID: input.AssignedToID, + OrganizationID: input.OrganizationID, + MeasureID: input.MeasureID, + Name: input.Name, + Description: input.Description, + Priority: priority, + TimeEstimate: input.TimeEstimate, + Deadline: input.Deadline, + AssignedToID: input.AssignedToID, + RecurrenceIntervalUnit: input.RecurrenceIntervalUnit, + RecurrenceIntervalCount: input.RecurrenceIntervalCount, }, ) if err != nil { @@ -2093,16 +2095,18 @@ func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest, task, err := svc.Tasks.Update( ctx, scope, probo.UpdateTaskRequest{ - TaskID: input.ID, - Name: input.Name, - Description: UnwrapOmittable(input.Description), - State: input.State, - Priority: input.Priority, - Rank: input.Rank, - TimeEstimate: UnwrapOmittable(input.TimeEstimate), - Deadline: UnwrapOmittable(input.Deadline), - AssignedToID: UnwrapOmittable(input.AssignedToID), - MeasureID: UnwrapOmittable(input.MeasureID), + TaskID: input.ID, + Name: input.Name, + Description: UnwrapOmittable(input.Description), + State: input.State, + Priority: input.Priority, + Rank: input.Rank, + TimeEstimate: UnwrapOmittable(input.TimeEstimate), + Deadline: UnwrapOmittable(input.Deadline), + AssignedToID: UnwrapOmittable(input.AssignedToID), + MeasureID: UnwrapOmittable(input.MeasureID), + RecurrenceIntervalUnit: UnwrapOmittable(input.RecurrenceIntervalUnit), + RecurrenceIntervalCount: UnwrapOmittable(input.RecurrenceIntervalCount), }, ) if err != nil { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 2802ce42f5..7e2ffbb7d5 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -5376,6 +5376,15 @@ components: - LOW go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskPriority + TaskRecurrenceIntervalUnit: + type: string + enum: + - DAY + - WEEK + - MONTH + - YEAR + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskRecurrenceIntervalUnit + Task: type: object required: @@ -5434,6 +5443,20 @@ components: - type: "null" description: No deadline description: Deadline + recurrence_interval_unit: + anyOf: + - $ref: "#/components/schemas/TaskRecurrenceIntervalUnit" + description: Recurrence interval unit + - type: "null" + description: Not recurring + description: Recurrence interval unit + recurrence_interval_count: + anyOf: + - type: integer + description: Recurrence interval count + - type: "null" + description: Not recurring + description: Recurrence interval count assigned_to_id: anyOf: - $ref: "#/components/schemas/GID" @@ -5533,6 +5556,12 @@ components: type: string format: date-time description: Deadline + recurrence_interval_unit: + $ref: "#/components/schemas/TaskRecurrenceIntervalUnit" + description: Recurrence interval unit (requires deadline to be set) + recurrence_interval_count: + type: integer + description: Recurrence interval count, e.g. 3 with unit WEEK means "every 3 weeks" assigned_to_id: anyOf: - $ref: "#/components/schemas/GID" @@ -5601,6 +5630,22 @@ components: description: No deadline description: Deadline go.probo.inc/mcpgen/omittable: true + recurrence_interval_unit: + anyOf: + - $ref: "#/components/schemas/TaskRecurrenceIntervalUnit" + description: Recurrence interval unit + - type: "null" + description: Not recurring + description: Recurrence interval unit + go.probo.inc/mcpgen/omittable: true + recurrence_interval_count: + anyOf: + - type: integer + description: Recurrence interval count + - type: "null" + description: Not recurring + description: Recurrence interval count + go.probo.inc/mcpgen/omittable: true assigned_to_id: anyOf: - $ref: "#/components/schemas/GID" diff --git a/pkg/server/api/mcp/v1/types/task.go b/pkg/server/api/mcp/v1/types/task.go index 5ffbbd0ef8..6c412a9087 100644 --- a/pkg/server/api/mcp/v1/types/task.go +++ b/pkg/server/api/mcp/v1/types/task.go @@ -27,19 +27,21 @@ import ( func NewTask(t *coredata.Task) *Task { return &Task{ - ID: t.ID, - OrganizationID: t.OrganizationID, - MeasureID: t.MeasureID, - Name: t.Name, - Description: t.Description, - State: t.State, - Priority: t.Priority, - Rank: t.Rank, - TimeEstimate: t.TimeEstimate, - AssignedToID: t.AssignedToID, - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, - Deadline: t.Deadline, + ID: t.ID, + OrganizationID: t.OrganizationID, + MeasureID: t.MeasureID, + Name: t.Name, + Description: t.Description, + State: t.State, + Priority: t.Priority, + Rank: t.Rank, + TimeEstimate: t.TimeEstimate, + AssignedToID: t.AssignedToID, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + Deadline: t.Deadline, + RecurrenceIntervalUnit: t.RecurrenceIntervalUnit, + RecurrenceIntervalCount: t.RecurrenceIntervalCount, } }