From 8541bf133469fd0388d324a5458490c2eab1d6de Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Mon, 6 Jul 2026 09:49:24 +0700 Subject: [PATCH 1/2] [frontend] change task URLs from /tasks/:id to /task/:slug/:number - Add backend endpoint GET /api/v1/tasks/by-project/:slug/:number - Update frontend routes: /task/:slug/:number and /task/:slug/:number/edit - Update all navigation calls to use project_slug + number - Replace onMount with for reactive data fetching on param change - Fix infinite loop in App.svelte by removing sync call from effect body - Use plain variable instead of for assigning mutex - Update parseProjectSlugFromPath to recognize new URL pattern - Add example request to requests.http --- frontend/src/App.svelte | 11 ++-- frontend/src/lib/api/tasks.ts | 4 ++ .../src/lib/pages/dashboard-personal.svelte | 2 +- frontend/src/lib/pages/dashboard-tasks.svelte | 2 +- frontend/src/lib/pages/project-tasks.svelte | 2 +- frontend/src/lib/pages/task-detail.svelte | 48 +++++++++----- frontend/src/lib/pages/task-edit.svelte | 32 +++++++--- frontend/src/lib/pages/task-new.svelte | 2 +- .../src/lib/stores/recent-projects.svelte.ts | 6 ++ internal/server/docs/docs.go | 62 +++++++++++++++++++ internal/server/tasks/handler.go | 40 ++++++++++++ requests.http | 6 ++ 12 files changed, 182 insertions(+), 35 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6bd4b5d..4671151 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -49,12 +49,11 @@ }); $effect(() => { - const syncRecent = () => { + const handler = () => { trackRecentFromPath(window.location.hash.slice(1) || "/"); }; - syncRecent(); - window.addEventListener("hashchange", syncRecent); - return () => window.removeEventListener("hashchange", syncRecent); + window.addEventListener("hashchange", handler); + return () => window.removeEventListener("hashchange", handler); }); const routes: RouteDef[] = [ @@ -66,9 +65,9 @@ { pattern: "/dashboard/all", component: DashboardTasks, auth: true }, { pattern: "/projects", component: ProjectsPage, auth: true }, { pattern: "/projects/:slug", component: ProjectTasks, auth: true }, + { pattern: "/task/:slug/:number/edit", component: TaskEditPage, auth: true }, + { pattern: "/task/:slug/:number", component: TaskDetailPage, auth: true }, { pattern: "/tasks/new", component: TaskNewPage, auth: true }, - { pattern: "/tasks/:id/edit", component: TaskEditPage, auth: true }, - { pattern: "/tasks/:id", component: TaskDetailPage, auth: true }, { pattern: "/profile", component: ProfilePage, auth: true }, { pattern: "/settings/security", component: SecurityPage, auth: true }, { pattern: "/admin/users", component: AdminUsers, auth: true, role: "admin" }, diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index b5d0b14..7125d6a 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -47,6 +47,10 @@ export function getTask(id: number): Promise { return apiRequest('GET', `/tasks/${id}`) } +export function getTaskByProjectAndNumber(slug: string, number: number): Promise { + return apiRequest('GET', `/tasks/${slug}/${number}`) +} + export function createTask(data: TaskCreateRequest): Promise { return apiRequest('POST', '/tasks', data) } diff --git a/frontend/src/lib/pages/dashboard-personal.svelte b/frontend/src/lib/pages/dashboard-personal.svelte index c269505..6bfb947 100644 --- a/frontend/src/lib/pages/dashboard-personal.svelte +++ b/frontend/src/lib/pages/dashboard-personal.svelte @@ -57,7 +57,7 @@ } function handleTaskClick(task: Task) { - navigate(`/tasks/${task.id}`); + navigate(`/task/${task.project_slug}/${task.number}`); } $effect(() => { diff --git a/frontend/src/lib/pages/dashboard-tasks.svelte b/frontend/src/lib/pages/dashboard-tasks.svelte index 742dc73..660a55d 100644 --- a/frontend/src/lib/pages/dashboard-tasks.svelte +++ b/frontend/src/lib/pages/dashboard-tasks.svelte @@ -99,7 +99,7 @@ $effect(load); function handleTaskClick(task: Task) { - navigate(`/tasks/${task.id}`); + navigate(`/task/${task.project_slug}/${task.number}`); } diff --git a/frontend/src/lib/pages/project-tasks.svelte b/frontend/src/lib/pages/project-tasks.svelte index 7bef64d..27cb9fc 100644 --- a/frontend/src/lib/pages/project-tasks.svelte +++ b/frontend/src/lib/pages/project-tasks.svelte @@ -90,7 +90,7 @@ } function handleTaskClick(task: Task) { - navigate(`/tasks/${task.id}`); + navigate(`/task/${task.project_slug}/${task.number}`); } diff --git a/frontend/src/lib/pages/task-detail.svelte b/frontend/src/lib/pages/task-detail.svelte index 20a2a8b..12b4c9d 100644 --- a/frontend/src/lib/pages/task-detail.svelte +++ b/frontend/src/lib/pages/task-detail.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/pages/task-new.svelte b/frontend/src/lib/pages/task-new.svelte index 948be10..37a938a 100644 --- a/frontend/src/lib/pages/task-new.svelte +++ b/frontend/src/lib/pages/task-new.svelte @@ -58,7 +58,7 @@ id: data.project_slug, name: match?.name || data.project_slug, }); - navigate(`/tasks/${task.id}`); + navigate(`/task/${task.project_slug}/${task.number}`); } diff --git a/frontend/src/lib/stores/recent-projects.svelte.ts b/frontend/src/lib/stores/recent-projects.svelte.ts index 92e030c..de1a9e5 100644 --- a/frontend/src/lib/stores/recent-projects.svelte.ts +++ b/frontend/src/lib/stores/recent-projects.svelte.ts @@ -95,6 +95,12 @@ export function parseProjectSlugFromPath(path: string): string { const legacyMatch = base.match(/^\/projects\/([^/]+)\/tasks$/) if (legacyMatch) return decodeURIComponent(legacyMatch[1] || '').trim() + const taskMatch = base.match(/^\/task\/([^/]+)\/\d+$/) + if (taskMatch) return decodeURIComponent(taskMatch[1] || '').trim() + + const taskEditMatch = base.match(/^\/task\/([^/]+)\/\d+\/edit$/) + if (taskEditMatch) return decodeURIComponent(taskEditMatch[1] || '').trim() + if (base.startsWith('/tasks/new/')) { return decodeURIComponent(base.slice('/tasks/new/'.length) || '').trim() } diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 9b9f598..bdc51cf 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -926,6 +926,68 @@ const docTemplate = `{ } } }, + "/tasks/{slug}/{number}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns detailed information about a task identified by its project slug and sequential number.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Get task by project slug and number", + "parameters": [ + { + "type": "string", + "description": "Project slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Task number within project", + "name": "number", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/tasks.TaskDetailsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, "/tasks/{task_id}/comments": { "post": { "security": [ diff --git a/internal/server/tasks/handler.go b/internal/server/tasks/handler.go index 6ba6888..b443faf 100644 --- a/internal/server/tasks/handler.go +++ b/internal/server/tasks/handler.go @@ -70,6 +70,7 @@ func (h *Handler) Register(r fiber.Router) { tasks.Get("/", h.list) tasks.Get("/me", h.myTasks) tasks.Get("/:id", h.get) + tasks.Get("/:slug/:number", h.getByProjectAndNumber) tasks.Post("/", validation.DecorateWithBodyEx(h.Validator, h.post), ) @@ -170,6 +171,45 @@ func (h *Handler) get(c *fiber.Ctx) error { return c.JSON(response) } +// @Summary Get task by project slug and number +// @Description Returns detailed information about a task identified by its project slug and sequential number. +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param slug path string true "Project slug" +// @Param number path int true "Task number within project" +// @Success 200 {object} TaskDetailsResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{slug}/{number} [get] +// +// getByProjectAndNumber retrieves a single task by its project slug and sequential number. +func (h *Handler) getByProjectAndNumber(c *fiber.Ctx) error { + slug := c.Params("slug") + if slug == "" { + return fiber.NewError(fiber.StatusBadRequest, "invalid project slug") + } + + number, err := strconv.ParseInt(c.Params("number"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task number") + } + + task, err := h.tasksSvc.GetByProjectAndNumber(c.Context(), slug, int(number)) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + response, err := h.prepareDetailsResponse(c.Context(), task) + if err != nil { + return fmt.Errorf("failed to prepare details response: %w", err) + } + + return c.JSON(response) +} + // @Summary Create a new task // @Description Creates a new task in the specified project. // @Tags Tasks diff --git a/requests.http b/requests.http index f901a85..d7a5b91 100644 --- a/requests.http +++ b/requests.http @@ -248,6 +248,12 @@ Authorization: Bearer {{accessToken}} GET {{baseURL}}/tasks/{{taskId}} Authorization: Bearer {{accessToken}} +### +# Get task by project slug and number +# Uses the new friendly URL pattern: project-slug/task-number +GET {{baseURL}}/tasks/by-project/my-new-project/1 +Authorization: Bearer {{accessToken}} + ### # Create a new task # POST to /tasks From 2bbd06ebde3fe188f62a984d99a52f03be63bcee Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 10 Jul 2026 14:00:18 +0700 Subject: [PATCH 2/2] [server] minor fixes --- frontend/src/lib/pages/task-new.svelte | 4 ++-- internal/server/tasks/handler.go | 5 +---- requests.http | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/pages/task-new.svelte b/frontend/src/lib/pages/task-new.svelte index 37a938a..13c1fee 100644 --- a/frontend/src/lib/pages/task-new.svelte +++ b/frontend/src/lib/pages/task-new.svelte @@ -58,7 +58,7 @@ id: data.project_slug, name: match?.name || data.project_slug, }); - navigate(`/task/${task.project_slug}/${task.number}`); + navigate(`/task/${encodeURIComponent(task.project_slug)}/${task.number}`); } @@ -69,7 +69,7 @@ {/if} diff --git a/internal/server/tasks/handler.go b/internal/server/tasks/handler.go index b443faf..a9a70a8 100644 --- a/internal/server/tasks/handler.go +++ b/internal/server/tasks/handler.go @@ -188,12 +188,9 @@ func (h *Handler) get(c *fiber.Ctx) error { // getByProjectAndNumber retrieves a single task by its project slug and sequential number. func (h *Handler) getByProjectAndNumber(c *fiber.Ctx) error { slug := c.Params("slug") - if slug == "" { - return fiber.NewError(fiber.StatusBadRequest, "invalid project slug") - } number, err := strconv.ParseInt(c.Params("number"), 10, 64) - if err != nil { + if err != nil || number < 1 { return fiber.NewError(fiber.StatusBadRequest, "invalid task number") } diff --git a/requests.http b/requests.http index d7a5b91..f4c86ab 100644 --- a/requests.http +++ b/requests.http @@ -251,7 +251,7 @@ Authorization: Bearer {{accessToken}} ### # Get task by project slug and number # Uses the new friendly URL pattern: project-slug/task-number -GET {{baseURL}}/tasks/by-project/my-new-project/1 +GET {{baseURL}}/tasks/my-new-project/1 Authorization: Bearer {{accessToken}} ###