feat(time-tracking): expose time entries on the v2 API
This commit is contained in:
parent
4bd6a6c4f7
commit
9454cd3ec5
|
|
@ -0,0 +1,285 @@
|
|||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/conditional"
|
||||
)
|
||||
|
||||
// timeTrackingGate is Huma operation middleware that 404s a time-tracking op when the license
|
||||
// feature is off. It's a middleware because license state can change while the instance is running.
|
||||
func timeTrackingGate(api huma.API) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func registerGated[I, O any](api huma.API, op huma.Operation, handler func(context.Context, *I) (*O, error)) {
|
||||
op.Middlewares = append(op.Middlewares, timeTrackingGate(api))
|
||||
Register(api, op, handler)
|
||||
}
|
||||
|
||||
type timeEntryListBody struct {
|
||||
Body Paginated[*models.TimeEntry]
|
||||
}
|
||||
|
||||
// RegisterTimeEntryRoutes wires the time-entry CRUD surface onto the Huma API.
|
||||
func RegisterTimeEntryRoutes(api huma.API) {
|
||||
tags := []string{"time-entries"}
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "time-entries-list",
|
||||
Summary: "List time entries",
|
||||
Description: "Returns the time entries the authenticated user can see, paginated. Filterable by date range, project, task and user.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/time-entries",
|
||||
Tags: tags,
|
||||
}, timeEntriesList)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "time-entries-read",
|
||||
Summary: "Get a time entry",
|
||||
Description: "Returns a single time entry. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/time-entries/{id}",
|
||||
Tags: tags,
|
||||
}, timeEntriesRead)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "time-entries-create",
|
||||
Summary: "Create a time entry",
|
||||
Description: "Logs a manual time entry for the authenticated user. Exactly one of task_id / project_id must be set.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/time-entries",
|
||||
Tags: tags,
|
||||
}, timeEntriesCreate)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "time-entries-update",
|
||||
Summary: "Update a time entry",
|
||||
Description: "Updates a time entry. Only the author may update it. The entry can be moved between a task and a project — exactly one of task_id / project_id must be set, and you need read access to the new one. PUT replaces all editable fields; use PATCH for a partial update.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/time-entries/{id}",
|
||||
Tags: tags,
|
||||
}, timeEntriesUpdate)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "time-entries-delete",
|
||||
Summary: "Delete a time entry",
|
||||
Description: "Deletes a time entry. Only the author may delete it. If it is the running timer, deleting it removes that timer.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/time-entries/{id}",
|
||||
Tags: tags,
|
||||
}, timeEntriesDelete)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "task-time-entries-list",
|
||||
Summary: "List a task's time entries",
|
||||
Description: "Returns the time entries logged against the given task, across all users, paginated. Scoped to what you can read: an inaccessible or unknown task yields an empty list, not an error.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/tasks/{task_id}/time-entries",
|
||||
Tags: tags,
|
||||
}, taskTimeEntriesList)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "project-time-entries-list",
|
||||
Summary: "List a project's time entries",
|
||||
Description: "Returns the time entries for the given project — both standalone project entries and entries on tasks currently in the project — paginated. Scoped to what you can read: an inaccessible or unknown project yields an empty list, not an error.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/{project_id}/time-entries",
|
||||
Tags: tags,
|
||||
}, projectTimeEntriesList)
|
||||
|
||||
registerGated(api, huma.Operation{
|
||||
OperationID: "time-entries-timer-stop",
|
||||
Summary: "Stop the running timer",
|
||||
Description: "Stops the authenticated user's running timer, setting its end time to the server's current time, and returns the stopped entry. Returns 404 when no timer is running. Starting a timer and editing entries go through the regular create/update endpoints.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/time-entries/timer/stop",
|
||||
// Override the wrapper's POST→201: this stops an existing entry, it creates nothing.
|
||||
DefaultStatus: http.StatusOK,
|
||||
Tags: tags,
|
||||
}, timeEntriesTimerStop)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTimeEntryRoutes) }
|
||||
|
||||
// timeEntriesTimerStop is a custom action scoped to the caller: it stops their
|
||||
// own running timer, so it owns its session and needs no resource permission
|
||||
// beyond authentication.
|
||||
func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models.TimeEntry], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
entry, err := models.StopRunningTimer(s, a)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
events.CleanupPending(s)
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
events.CleanupPending(s)
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
events.DispatchPending(s)
|
||||
return &singleBody[models.TimeEntry]{Body: entry}, nil
|
||||
}
|
||||
|
||||
func timeEntriesList(ctx context.Context, in *struct {
|
||||
ListParams
|
||||
Filter string `query:"filter" doc:"Filter entries with the task filter syntax over user_id, task_id, project_id, start_time and end_time — e.g. \"project_id = 5 && start_time > now-7d\". Use end_time = null to match running timers."`
|
||||
FilterTimezone string `query:"filter_timezone" doc:"IANA timezone name used to resolve relative dates (now, now-7d) in the filter, e.g. Europe/Berlin."`
|
||||
}) (*timeEntryListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &models.TimeEntry{
|
||||
Filter: in.Filter,
|
||||
FilterTimezone: in.FilterTimezone,
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, m, a, in.Q, in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return timeEntriesListResponse(result, total, in.Page, in.PerPage)
|
||||
}
|
||||
|
||||
type timeEntryReadBody struct {
|
||||
models.TimeEntry
|
||||
MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this time entry (0=read, 1=read/write, 2=admin)."`
|
||||
}
|
||||
|
||||
func timeEntriesRead(ctx context.Context, in *struct {
|
||||
ID int64 `path:"id"`
|
||||
conditional.Params
|
||||
}) (*singleReadBody[timeEntryReadBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := &models.TimeEntry{ID: in.ID}
|
||||
maxPermission, err := handler.DoReadOne(ctx, entry, a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &timeEntryReadBody{TimeEntry: *entry, MaxPermission: models.Permission(maxPermission)}
|
||||
return conditionalReadResponse(&in.Params, body, entry.Updated, maxPermission)
|
||||
}
|
||||
|
||||
func timeEntriesCreate(ctx context.Context, in *struct {
|
||||
Body models.TimeEntry
|
||||
}) (*singleBody[models.TimeEntry], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.TimeEntry]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func timeEntriesUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"id"`
|
||||
Body timeEntryReadBody
|
||||
}) (*singleBody[models.TimeEntry], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := &in.Body.TimeEntry
|
||||
entry.ID = in.ID // URL wins over body
|
||||
if err := handler.DoUpdate(ctx, entry, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.TimeEntry]{Body: entry}, nil
|
||||
}
|
||||
|
||||
func timeEntriesDelete(ctx context.Context, in *struct {
|
||||
ID int64 `path:"id"`
|
||||
}) (*emptyBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.DoDelete(ctx, &models.TimeEntry{ID: in.ID}, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
||||
func taskTimeEntriesList(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task_id"`
|
||||
ListParams
|
||||
}) (*timeEntryListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return timeEntriesListResponse(result, total, in.Page, in.PerPage)
|
||||
}
|
||||
|
||||
func projectTimeEntriesList(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project_id"`
|
||||
ListParams
|
||||
}) (*timeEntryListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return timeEntriesListResponse(result, total, in.Page, in.PerPage)
|
||||
}
|
||||
|
||||
// timeEntriesListResponse turns the any-typed DoReadAll result into the list
|
||||
// envelope, hard-failing on a type mismatch (the generic-any silent-empty trap).
|
||||
func timeEntriesListResponse(result any, total int64, page, perPage int) (*timeEntryListBody, error) {
|
||||
items, ok := result.([]*models.TimeEntry)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("timeEntries.ReadAll returned unexpected type %T (expected []*models.TimeEntry)", result)
|
||||
}
|
||||
return &timeEntryListBody{Body: NewPaginated(items, total, page, perPage)}, nil
|
||||
}
|
||||
Loading…
Reference in New Issue