diff --git a/pkg/routes/api/v2/task_unread_status.go b/pkg/routes/api/v2/task_unread_status.go new file mode 100644 index 000000000..f7a944092 --- /dev/null +++ b/pkg/routes/api/v2/task_unread_status.go @@ -0,0 +1,73 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// taskReadBody confirms the mark-read action: the underlying model carries no +// JSON-exposed fields, so it returns a status message rather than a resource. +type taskReadBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` + } +} + +// RegisterTaskUnreadStatusRoutes wires the mark-task-as-read action onto the Huma API. +// +// Marking a task read clears the caller's unread entry for it, which is what +// drives the per-task "unread" dot shown for mentions and other notifications. +// The model's Update deletes that entry, so the action is idempotent — PUT, not +// POST. It is also unconditional: there is no read entry to clear for a task the +// caller cannot see, so it succeeds as a no-op rather than refusing. +func RegisterTaskUnreadStatusRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-mark-read", + Summary: "Mark a task as read", + Description: "Clears the authenticated user's unread status for a task, dismissing the unread indicator raised by mentions and other task notifications. Idempotent: marking an already-read or inaccessible task succeeds as a no-op.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/read", + Tags: tags, + }, tasksMarkRead) +} + +func init() { AddRouteRegistrar(RegisterTaskUnreadStatusRoutes) } + +func tasksMarkRead(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask" doc:"The numeric id of the task to mark as read."` +}) (*taskReadBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + t := &models.TaskUnreadStatus{TaskID: in.TaskID} + if err := handler.DoUpdate(ctx, t, a); err != nil { + return nil, translateDomainError(err) + } + out := &taskReadBody{} + out.Body.Message = "success" + return out, nil +} diff --git a/pkg/webtests/huma_task_unread_status_test.go b/pkg/webtests/huma_task_unread_status_test.go new file mode 100644 index 000000000..9ea27544d --- /dev/null +++ b/pkg/webtests/huma_task_unread_status_test.go @@ -0,0 +1,88 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTaskUnreadStatus ports v1's POST /tasks/:projecttask/read (no v1 +// webtest exists). The action deletes the caller's unread entry for the task; +// there is no fixture file for task_unread_statuses, so the table starts empty +// and the test seeds the row it expects to clear. +// +// Note on the permission model: the v1 handler enforces nothing — CanUpdate is +// a hardcoded true and Update is an unconditional DELETE on (task_id, user_id). +// A task the caller can't see (or doesn't exist) therefore has no row to clear +// and the call succeeds as a no-op. The only thing actually gated is auth, so +// that is what the negative case covers. +func TestHumaTaskUnreadStatus(t *testing.T) { + t.Run("Normal - clears the caller's unread entry", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + s := db.NewSession() + _, err = s.Insert(&models.TaskUnreadStatus{TaskID: 1, UserID: testuser1.ID}) + require.NoError(t, err) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"success"`) + + db.AssertMissing(t, "task_unread_statuses", map[string]interface{}{ + "task_id": 1, + "user_id": testuser1.ID, + }) + }) + + t.Run("No-op - already read, no entry to clear", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"success"`) + }) + + t.Run("No-op - nonexistent task (unenforced, mirrors v1)", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Anonymous request is rejected with 401", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous must get 401; body: %s", rec.Body.String()) + }) +}