// 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 models import ( "time" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/license" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "xorm.io/builder" "xorm.io/xorm" ) // TimeEntry is a single tracked time span attached to either a task or a // project — exactly one of TaskID / ProjectID is set (XOR). A running live // timer is just an entry whose EndTime is still null. // // v2-only: doc: tags are the schema's source of truth (no v1 swaggo), and it // implements CRUDable + Permissions because the shared handler.Do* pipeline needs them. type TimeEntry struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"timeentry" readOnly:"true" doc:"The unique, numeric id of this time entry."` UserID int64 `xorm:"bigint not null INDEX" json:"user_id" readOnly:"true" doc:"The id of the user who logged this time entry. Set by the server."` TaskID int64 `xorm:"bigint null INDEX" json:"task_id" doc:"The task this entry is attached to. Exactly one of task_id / project_id must be set."` ProjectID int64 `xorm:"bigint null INDEX" json:"project_id" doc:"The project this entry is attached to directly. Exactly one of task_id / project_id must be set."` StartTime time.Time `xorm:"not null INDEX" json:"start_time" doc:"When the tracked time started."` EndTime *time.Time `xorm:"null" json:"end_time" doc:"When the tracked time ended. Null means a live timer is still running."` Comment string `xorm:"text null" json:"comment" doc:"An optional comment describing the logged time."` Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this time entry was created. You cannot change this value."` Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this time entry was last updated. You cannot change this value."` // Filter-only fields (not persisted): set by the v2 list route, read by ReadAll. Filter string `xorm:"-" json:"-"` FilterTimezone string `xorm:"-" json:"-"` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` } // TableName is time_entries, not the xorm-default time_entry. func (*TimeEntry) TableName() string { return "time_entries" } // --- CRUDable --- func (te *TimeEntry) Create(s *xorm.Session, a web.Auth) (err error) { te.UserID = a.GetID() // Starting a new running timer auto-stops the previous one; a completed // manual entry (EndTime set) must leave the running timer alone. if te.EndTime == nil { if _, err = stopRunningTimerForUser(s, te.UserID); err != nil { return err } } if te.StartTime.IsZero() { te.StartTime = time.Now() } if err = te.validateTimes(); err != nil { return err } if _, err = s.Insert(te); err != nil { return err } doer, err := user.GetFromAuth(a) if err != nil { return err } events.DispatchOnCommit(s, &TimeEntryCreatedEvent{TimeEntry: te, Doer: doer}) return nil } func (te *TimeEntry) ReadOne(_ *xorm.Session, _ web.Auth) (err error) { // entry got already fetched in CanRead, nothing left to do here return nil } // stopRunningTimerForUser stops the user's active timer (end_time = now) and // returns it, or nil if no timer is running. func stopRunningTimerForUser(s *xorm.Session, userID int64) (*TimeEntry, error) { running := &TimeEntry{} exists, err := s.Where("user_id = ? AND end_time IS NULL", userID).Get(running) if err != nil { return nil, err } if !exists { return nil, nil } if err := running.stop(s); err != nil { return nil, err } doer, err := user.GetUserByID(s, userID) if err != nil { return nil, err } events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: running, Doer: doer}) return running, nil } // StopRunningTimer stops the authenticated user's active timer and returns it, // or ErrNoRunningTimer when none is running. The stop time is the server's now. func StopRunningTimer(s *xorm.Session, a web.Auth) (*TimeEntry, error) { // Link shares have no time tracking (mirrors the Can* methods). Their id is a // share id, not a user id, so without this a share whose id collides with a // user's would stop and read that user's running timer. if _, isShare := a.(*LinkSharing); isShare { return nil, ErrGenericForbidden{} } running, err := stopRunningTimerForUser(s, a.GetID()) if err != nil { return nil, err } if running == nil { return nil, ErrNoRunningTimer{UserID: a.GetID()} } return running, nil } // readableTimeEntriesCond restricts a query to entries the auth can read: a // standalone entry on an accessible project, or one on a task in such a project. func readableTimeEntriesCond(a web.Auth) builder.Cond { return entriesForProjectCond(accessibleProjectIDsSubquery(a, "project_id")) } func (te *TimeEntry) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) { // Link shares have no time-tracking access (mirrors the Can* methods); // DoReadAll skips the permission check, so it must be guarded here too. if _, isShareAuth := a.(*LinkSharing); isShareAuth { return []*TimeEntry{}, 0, 0, nil } cond := readableTimeEntriesCond(a) if te.TaskID > 0 { cond = cond.And(builder.Eq{"task_id": te.TaskID}) } if te.ProjectID > 0 { cond = cond.And(entriesForProjectCond(builder.Eq{"project_id": te.ProjectID})) } filterCond, err := timeEntryFilterCond(te.Filter, te.FilterTimezone) if err != nil { return nil, 0, 0, err } if filterCond != nil { cond = cond.And(filterCond) } if search != "" { cond = cond.And(db.MultiFieldSearch([]string{"comment"}, search)) } total, err := s.Where(cond). Count(&TimeEntry{}) if err != nil { return nil, 0, 0, err } entries := []*TimeEntry{} err = s.Where(cond). OrderBy("start_time ASC"). Limit(getLimitFromPageIndex(page, perPage)). Find(&entries) return entries, len(entries), total, err } func (te *TimeEntry) Update(s *xorm.Session, a web.Auth) (err error) { // A completed entry can't be reopened into a running timer via update — that // would sidestep Create's single-active-timer rule; start a new one instead. existing, err := getTimeEntryByID(s, te.ID) if err != nil { return err } if existing.EndTime != nil && te.EndTime == nil { return ErrTimeEntryAlreadyEnded{TimeEntryID: te.ID} } if err = te.validateTimes(); err != nil { return err } // task_id / project_id are listed so a reassignment (and the zero value of // the side being cleared) is written; the XOR was validated in CanUpdate. _, err = s. Where("id = ?", te.ID). Cols("task_id", "project_id", "start_time", "end_time", "comment"). Update(te) if err != nil { return err } // reload: Update wrote only the editable columns updated, err := getTimeEntryByID(s, te.ID) if err != nil { return err } *te = *updated doer, err := user.GetFromAuth(a) if err != nil { return err } events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: te, Doer: doer}) return nil } func (te *TimeEntry) Delete(s *xorm.Session, a web.Auth) (err error) { entry, err := getTimeEntryByID(s, te.ID) if err != nil { return err } if _, err = s.Where("id = ?", te.ID).Delete(&TimeEntry{}); err != nil { return err } doer, err := user.GetFromAuth(a) if err != nil { return err } events.DispatchOnCommit(s, &TimeEntryDeletedEvent{TimeEntry: entry, Doer: doer}) return nil } func getTimeEntryByID(s *xorm.Session, id int64) (*TimeEntry, error) { entry := &TimeEntry{} exists, err := s.Where("id = ?", id).Get(entry) if err != nil { return nil, err } if !exists { return nil, ErrTimeEntryDoesNotExist{TimeEntryID: id} } return entry, nil } func (te *TimeEntry) stop(s *xorm.Session) (err error) { now := time.Now() te.EndTime = &now _, err = s.ID(te.ID).Update(te) return err } // --- Permissions --- // Returns the loaded entry rather than mutating te, so Update keeps its payload. func (te *TimeEntry) canDoTimeEntry(s *xorm.Session, a web.Auth, fetch bool) (*TimeEntry, bool, int, error) { // The route gate already 404s when time tracking is off for the user; this // guards non-route callers and keeps the permission on the model. enabled, err := IsProFeatureEnabledForAuth(s, a, license.FeatureTimeTracking) if err != nil { return nil, false, -1, err } if !enabled { return &TimeEntry{}, false, 0, nil } entry := &TimeEntry{TaskID: te.TaskID, ProjectID: te.ProjectID} if fetch { var err error entry, err = getTimeEntryByID(s, te.ID) if err != nil { return nil, false, -1, err } } switch { case entry.TaskID != 0: task, err := GetTaskByIDSimple(s, entry.TaskID) if err != nil { return entry, false, -1, err } can, maxPerm, err := task.CanRead(s, a) return entry, can, maxPerm, err case entry.ProjectID != 0: project, _, err := getProjectSimple(s, builder.Eq{"id": entry.ProjectID}) if err != nil { return entry, false, -1, err } can, maxPerm, err := project.CanRead(s, a) return entry, can, maxPerm, err default: return entry, false, 0, nil } } func (te *TimeEntry) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { if _, isShareAuth := a.(*LinkSharing); isShareAuth { return false, 0, nil } entry, can, maxPerm, err := te.canDoTimeEntry(s, a, true) if err != nil { return false, maxPerm, err } *te = *entry // ReadOne is a no-op; populate te here return can, maxPerm, nil } // validateContainer enforces the XOR invariant: exactly one of task or project. func (te *TimeEntry) validateContainer() error { if (te.TaskID == 0) == (te.ProjectID == 0) { return ErrTimeEntryInvalidContainer{TaskID: te.TaskID, ProjectID: te.ProjectID} } return nil } // validateTimes rejects a completed entry whose end precedes its start (a // negative interval). A null end is a running timer and is always valid; an end // equal to the start is allowed (a zero-length entry). func (te *TimeEntry) validateTimes() error { if te.EndTime != nil && te.EndTime.Before(te.StartTime) { return ErrTimeEntryEndBeforeStart{TimeEntryID: te.ID} } return nil } func (te *TimeEntry) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { if _, isShareAuth := a.(*LinkSharing); isShareAuth { return false, nil } if err := te.validateContainer(); err != nil { return false, err } _, can, _, err := te.canDoTimeEntry(s, a, false) return can, err } // CanUpdate allows the author to edit their entry, including moving it between // task / project: on top of the author check it validates the (possibly new) // container (XOR) and requires read access to it, mirroring create. func (te *TimeEntry) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { if _, isShareAuth := a.(*LinkSharing); isShareAuth { return false, nil } existing, err := getTimeEntryByID(s, te.ID) if err != nil { return false, err } if existing.UserID != a.GetID() { return false, nil } // A request that omits the container keeps the existing one — an entry // always has exactly one, so "clearing" it is never valid. if te.TaskID == 0 && te.ProjectID == 0 { te.TaskID = existing.TaskID te.ProjectID = existing.ProjectID } if err := te.validateContainer(); err != nil { return false, err } _, canReadContainer, _, err := te.canDoTimeEntry(s, a, false) return canReadContainer, err } func (te *TimeEntry) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { return te.canModify(s, a) } // canModify gates delete: read access to the container plus being the author. func (te *TimeEntry) canModify(s *xorm.Session, a web.Auth) (bool, error) { if _, isShareAuth := a.(*LinkSharing); isShareAuth { return false, nil } entry, canRead, _, err := te.canDoTimeEntry(s, a, true) if err != nil { return false, err } if !canRead { return false, nil } return entry.UserID == a.GetID(), nil } // addTimeEntriesCountToTasks attaches each task's time-entry count for the // `time_entries_count` expand. Mirrors addCommentCountToTasks, but follows the // same gates as the time-entry endpoints: the count is left unset (absent) for // link shares or when time tracking is off for the requesting user, so it // can't leak that way. func addTimeEntriesCountToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map[int64]*Task) error { if _, isShare := a.(*LinkSharing); isShare { return nil } enabled, err := IsProFeatureEnabledForAuth(s, a, license.FeatureTimeTracking) if err != nil { return err } if !enabled { return nil } if len(taskIDs) == 0 { return nil } zero := int64(0) for _, taskID := range taskIDs { if task, ok := taskMap[taskID]; ok { task.TimeEntriesCount = &zero } } type timeEntriesCount struct { TaskID int64 `xorm:"task_id"` Count int64 `xorm:"count"` } counts := []timeEntriesCount{} if err := s. Select("task_id, COUNT(*) as count"). Where(builder.In("task_id", taskIDs)). GroupBy("task_id"). Table("time_entries"). Find(&counts); err != nil { return err } for _, c := range counts { if task, ok := taskMap[c.TaskID]; ok { task.TimeEntriesCount = &c.Count } } return nil }